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:
2026-03-19 17:14:51 +03:00
parent e4a9b71bfe
commit 0ec2361a16
3 changed files with 190 additions and 30 deletions

View File

@@ -15,6 +15,8 @@ interface GroupBooking {
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
status: BookingStatus;
confirmedDate?: string;
createdAt: string;
}
@@ -48,12 +50,22 @@ interface OpenDayBooking {
}
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 ---
function GroupBookingsTab() {
const [bookings, setBookings] = useState<GroupBooking[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<BookingFilter>("all");
useEffect(() => {
adminFetch("/api/admin/group-bookings")
@@ -63,6 +75,27 @@ function GroupBookingsTab() {
.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) {
await adminFetch(`/api/admin/group-bookings?id=${id}`, { method: "DELETE" });
setBookings((prev) => prev.filter((b) => b.id !== id));
@@ -71,12 +104,45 @@ function GroupBookingsTab() {
if (loading) return <LoadingSpinner />;
return (
<div className="space-y-2">
{bookings.length === 0 && <EmptyState total={0} />}
{bookings.map((b) => (
<div>
{/* Filter tabs */}
<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
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-center gap-2 flex-wrap text-sm min-w-0">
@@ -103,8 +169,62 @@ function GroupBookingsTab() {
<DeleteBtn onClick={() => handleDelete(b.id)} />
</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>
);
}

View File

@@ -1,5 +1,6 @@
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() {
const bookings = getGroupBookings();
@@ -22,6 +23,15 @@ export async function PUT(request: NextRequest) {
toggleGroupBookingNotification(id, field, value);
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 });
} catch (err) {
console.error("[admin/group-bookings] error:", err);

View File

@@ -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) {
@@ -581,9 +594,13 @@ interface GroupBookingRow {
notified_confirm: number;
notified_reminder: number;
reminder_status: string | null;
status: string;
confirmed_date: string | null;
created_at: string;
}
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
export interface GroupBooking {
id: number;
name: string;
@@ -594,6 +611,8 @@ export interface GroupBooking {
notifiedConfirm: boolean;
notifiedReminder: boolean;
reminderStatus?: string;
status: BookingStatus;
confirmedDate?: string;
createdAt: string;
}
@@ -628,10 +647,21 @@ export function getGroupBookings(): GroupBooking[] {
notifiedConfirm: !!r.notified_confirm,
notifiedReminder: !!r.notified_reminder,
reminderStatus: r.reminder_status ?? undefined,
status: (r.status || "new") as BookingStatus,
confirmedDate: r.confirmed_date ?? undefined,
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(
id: number,
name: string,