feat: add linear booking workflow — Новая → Связались → Подтверждено/Отказ
- Add status + confirmed_date columns to group_bookings (migration #10) - Linear flow: Новая shows "Связались →", Связались shows date picker + "Отказ" - Date picker for confirmation allows only today and future dates - Confirmed bookings show the scheduled date - Filter chips: Все / Новая / Связались / Подтверждено / Отказ with counts - Declined bookings sorted to bottom of list - "Сбросить" button on confirmed/declined to restart flow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ interface GroupBooking {
|
|||||||
telegram?: string;
|
telegram?: string;
|
||||||
notifiedConfirm: boolean;
|
notifiedConfirm: boolean;
|
||||||
notifiedReminder: boolean;
|
notifiedReminder: boolean;
|
||||||
|
status: BookingStatus;
|
||||||
|
confirmedDate?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +50,22 @@ interface OpenDayBooking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
|
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
|
||||||
|
type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||||
|
type BookingFilter = "all" | BookingStatus;
|
||||||
|
|
||||||
|
const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
|
||||||
|
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||||
|
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
|
||||||
|
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
|
||||||
|
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
|
||||||
|
];
|
||||||
|
|
||||||
// --- Group Bookings Tab ---
|
// --- Group Bookings Tab ---
|
||||||
|
|
||||||
function GroupBookingsTab() {
|
function GroupBookingsTab() {
|
||||||
const [bookings, setBookings] = useState<GroupBooking[]>([]);
|
const [bookings, setBookings] = useState<GroupBooking[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState<BookingFilter>("all");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminFetch("/api/admin/group-bookings")
|
adminFetch("/api/admin/group-bookings")
|
||||||
@@ -63,6 +75,27 @@ function GroupBookingsTab() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||||
|
for (const b of bookings) c[b.status] = (c[b.status] || 0) + 1;
|
||||||
|
return c;
|
||||||
|
}, [bookings]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const list = filter === "all" ? bookings : bookings.filter((b) => b.status === filter);
|
||||||
|
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
|
||||||
|
return [...list].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0));
|
||||||
|
}, [bookings, filter]);
|
||||||
|
|
||||||
|
async function handleStatus(id: number, status: BookingStatus, confirmedDate?: string) {
|
||||||
|
setBookings((prev) => prev.map((b) => b.id === id ? { ...b, status, confirmedDate } : b));
|
||||||
|
await adminFetch("/api/admin/group-bookings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "set-status", id, status, confirmedDate }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
await adminFetch(`/api/admin/group-bookings?id=${id}`, { method: "DELETE" });
|
await adminFetch(`/api/admin/group-bookings?id=${id}`, { method: "DELETE" });
|
||||||
setBookings((prev) => prev.filter((b) => b.id !== id));
|
setBookings((prev) => prev.filter((b) => b.id !== id));
|
||||||
@@ -71,12 +104,45 @@ function GroupBookingsTab() {
|
|||||||
if (loading) return <LoadingSpinner />;
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
{bookings.length === 0 && <EmptyState total={0} />}
|
{/* Filter tabs */}
|
||||||
{bookings.map((b) => (
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Все <span className="text-neutral-500 ml-1">{bookings.length}</span>
|
||||||
|
</button>
|
||||||
|
{BOOKING_STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => setFilter(s.key)}
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bookings list */}
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{filtered.length === 0 && <EmptyState total={bookings.length} />}
|
||||||
|
{filtered.map((b) => {
|
||||||
|
const statusConf = BOOKING_STATUSES.find((s) => s.key === b.status) || BOOKING_STATUSES[0];
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={b.id}
|
key={b.id}
|
||||||
className="rounded-xl border border-white/10 bg-neutral-900 p-4"
|
className={`rounded-xl border p-4 transition-colors ${
|
||||||
|
b.status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||||
|
: b.status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||||
|
: b.status === "new" ? "border-gold/20 bg-gold/[0.03]"
|
||||||
|
: "border-white/10 bg-neutral-900"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||||
@@ -103,8 +169,62 @@ function GroupBookingsTab() {
|
|||||||
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Linear status flow */}
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
{/* Current status badge */}
|
||||||
|
<span className={`text-[10px] font-medium ${statusConf.bg} ${statusConf.color} border ${statusConf.border} rounded-full px-2.5 py-0.5`}>
|
||||||
|
{statusConf.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{b.status === "confirmed" && b.confirmedDate && (
|
||||||
|
<span className="text-[10px] text-emerald-400/70">
|
||||||
|
Дата: {new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons based on current state */}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
{b.status === "new" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "contacted")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Связались →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{b.status === "contacted" && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
min={new Date().toISOString().split("T")[0]}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) handleStatus(b.id, "confirmed", e.target.value);
|
||||||
|
}}
|
||||||
|
className="h-6 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 px-2 text-[10px] cursor-pointer hover:bg-emerald-500/20 transition-all [color-scheme:dark]"
|
||||||
|
title="Подтвердить — выберите дату занятия"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "declined")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Отказ ✗
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(b.status === "confirmed" || b.status === "declined") && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "new")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300 transition-all"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getGroupBookings, toggleGroupBookingNotification, deleteGroupBooking } from "@/lib/db";
|
import { getGroupBookings, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus } from "@/lib/db";
|
||||||
|
import type { BookingStatus } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const bookings = getGroupBookings();
|
const bookings = getGroupBookings();
|
||||||
@@ -22,6 +23,15 @@ export async function PUT(request: NextRequest) {
|
|||||||
toggleGroupBookingNotification(id, field, value);
|
toggleGroupBookingNotification(id, field, value);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
if (body.action === "set-status") {
|
||||||
|
const { id, status, confirmedDate } = body;
|
||||||
|
const valid: BookingStatus[] = ["new", "contacted", "confirmed", "declined"];
|
||||||
|
if (!id || !valid.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "id and valid status are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
setGroupBookingStatus(id, status, confirmedDate);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[admin/group-bookings] error:", err);
|
console.error("[admin/group-bookings] error:", err);
|
||||||
|
|||||||
@@ -200,6 +200,19 @@ const migrations: Migration[] = [
|
|||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 10,
|
||||||
|
name: "add_group_booking_status",
|
||||||
|
up: (db) => {
|
||||||
|
const cols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
|
||||||
|
if (!cols.some((c) => c.name === "status")) {
|
||||||
|
db.exec("ALTER TABLE group_bookings ADD COLUMN status TEXT NOT NULL DEFAULT 'new'");
|
||||||
|
}
|
||||||
|
if (!cols.some((c) => c.name === "confirmed_date")) {
|
||||||
|
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_date TEXT");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function runMigrations(db: Database.Database) {
|
function runMigrations(db: Database.Database) {
|
||||||
@@ -581,9 +594,13 @@ interface GroupBookingRow {
|
|||||||
notified_confirm: number;
|
notified_confirm: number;
|
||||||
notified_reminder: number;
|
notified_reminder: number;
|
||||||
reminder_status: string | null;
|
reminder_status: string | null;
|
||||||
|
status: string;
|
||||||
|
confirmed_date: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||||
|
|
||||||
export interface GroupBooking {
|
export interface GroupBooking {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -594,6 +611,8 @@ export interface GroupBooking {
|
|||||||
notifiedConfirm: boolean;
|
notifiedConfirm: boolean;
|
||||||
notifiedReminder: boolean;
|
notifiedReminder: boolean;
|
||||||
reminderStatus?: string;
|
reminderStatus?: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
confirmedDate?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,10 +647,21 @@ export function getGroupBookings(): GroupBooking[] {
|
|||||||
notifiedConfirm: !!r.notified_confirm,
|
notifiedConfirm: !!r.notified_confirm,
|
||||||
notifiedReminder: !!r.notified_reminder,
|
notifiedReminder: !!r.notified_reminder,
|
||||||
reminderStatus: r.reminder_status ?? undefined,
|
reminderStatus: r.reminder_status ?? undefined,
|
||||||
|
status: (r.status || "new") as BookingStatus,
|
||||||
|
confirmedDate: r.confirmed_date ?? undefined,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setGroupBookingStatus(id: number, status: BookingStatus, confirmedDate?: string): void {
|
||||||
|
const db = getDb();
|
||||||
|
if (status === "confirmed" && confirmedDate) {
|
||||||
|
db.prepare("UPDATE group_bookings SET status = ?, confirmed_date = ? WHERE id = ?").run(status, confirmedDate, id);
|
||||||
|
} else {
|
||||||
|
db.prepare("UPDATE group_bookings SET status = ?, confirmed_date = NULL WHERE id = ?").run(status, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function updateGroupBooking(
|
export function updateGroupBooking(
|
||||||
id: number,
|
id: number,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user