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;
|
||||
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,40 +104,127 @@ function GroupBookingsTab() {
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{bookings.length === 0 && <EmptyState total={0} />}
|
||||
{bookings.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="rounded-xl border border-white/10 bg-neutral-900 p-4"
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||
<span className="font-medium text-white">{b.name}</span>
|
||||
<a href={`tel:${b.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{b.phone}
|
||||
</a>
|
||||
{b.instagram && (
|
||||
<a href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||
<Instagram size={10} />{b.instagram}
|
||||
Все <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 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">
|
||||
<span className="font-medium text-white">{b.name}</span>
|
||||
<a href={`tel:${b.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{b.phone}
|
||||
</a>
|
||||
)}
|
||||
{b.telegram && (
|
||||
<a href={`https://t.me/${b.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||
<Send size={10} />{b.telegram}
|
||||
</a>
|
||||
)}
|
||||
{b.groupInfo && (
|
||||
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
|
||||
)}
|
||||
{b.instagram && (
|
||||
<a href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||
<Instagram size={10} />{b.instagram}
|
||||
</a>
|
||||
)}
|
||||
{b.telegram && (
|
||||
<a href={`https://t.me/${b.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||
<Send size={10} />{b.telegram}
|
||||
</a>
|
||||
)}
|
||||
{b.groupInfo && (
|
||||
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
|
||||
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
|
||||
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user