feat: booking confirm modal with cascading Hall → Trainer → Group, linear workflow

- Add confirmed_date, confirmed_group, confirmed_comment to group_bookings (migration #10)
- Linear booking flow: Новая → Связались → Подтвердить (modal) / Отказ
- Confirm modal with cascading selects: Hall → Trainer → Group → Date → Comment
- Groups merged by groupId — shows all days/times (e.g. "СР 20:00, ПТ 16:15")
- Auto-prefill hall/trainer/group from booking's groupInfo via fuzzy scoring
- Proper SHORT_DAYS constant for weekday abbreviations
- Filter chips with status counts, declined sorted to bottom
- "Вернуть" on confirmed/declined returns to "Связались" (not "Новая")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 01:03:20 +03:00
parent 8d1e3fb596
commit 1bfd502930
3 changed files with 282 additions and 26 deletions

View File

@@ -1,7 +1,8 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen } from "lucide-react";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
// --- Types ---
@@ -17,6 +18,8 @@ interface GroupBooking {
notifiedReminder: boolean;
status: BookingStatus;
confirmedDate?: string;
confirmedGroup?: string;
confirmedComment?: string;
createdAt: string;
}
@@ -49,6 +52,11 @@ interface OpenDayBooking {
classHall?: string;
}
const SHORT_DAYS: Record<string, string> = {
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
};
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
type BookingFilter = "all" | BookingStatus;
@@ -60,17 +68,226 @@ const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg:
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
];
// --- Confirm Booking Modal ---
function ConfirmModal({
open,
bookingName,
groupInfo,
allClasses,
onConfirm,
onClose,
}: {
open: boolean;
bookingName: string;
groupInfo?: string;
allClasses: ScheduleClassInfo[];
onConfirm: (data: { group: string; date: string; comment?: string }) => void;
onClose: () => void;
}) {
const [hall, setHall] = useState("");
const [trainer, setTrainer] = useState("");
const [group, setGroup] = useState("");
const [date, setDate] = useState("");
const [comment, setComment] = useState("");
useEffect(() => {
if (!open) return;
setDate(""); setComment("");
// Try to match groupInfo against schedule to pre-fill
if (groupInfo && allClasses.length > 0) {
const info = groupInfo.toLowerCase();
// Score each class against groupInfo, pick best match
let bestMatch: ScheduleClassInfo | null = null;
let bestScore = 0;
for (const c of allClasses) {
let score = 0;
if (info.includes(c.type.toLowerCase())) score += 3;
if (info.includes(c.trainer.toLowerCase())) score += 3;
if (info.includes(c.time)) score += 2;
const dayShort = (SHORT_DAYS[c.day] || c.day.slice(0, 2)).toLowerCase();
if (info.includes(dayShort)) score += 1;
const hallWords = c.hall.toLowerCase().split(/[\s/,]+/);
if (hallWords.some((w) => w.length > 2 && info.includes(w))) score += 2;
if (score > bestScore) { bestScore = score; bestMatch = c; }
}
const match = bestScore >= 4 ? bestMatch : null;
if (match) {
setHall(match.hall);
setTrainer(match.trainer);
setGroup(match.groupId || `${match.type}|${match.time}|${match.address}`);
return;
}
}
setHall(""); setTrainer(""); setGroup("");
}, [open, groupInfo, allClasses]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Cascading options
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
const trainers = useMemo(() => {
if (!hall) return [];
return [...new Set(allClasses.filter((c) => c.hall === hall).map((c) => c.trainer))].sort();
}, [allClasses, hall]);
const groups = useMemo(() => {
if (!hall || !trainer) return [];
const filtered = allClasses.filter((c) => c.hall === hall && c.trainer === trainer);
// Group by groupId — merge days for the same group
const byId = new Map<string, { type: string; slots: { day: string; time: string }[]; id: string }>();
for (const c of filtered) {
const id = c.groupId || `${c.type}|${c.time}|${c.address}`;
const existing = byId.get(id);
if (existing) {
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
} else {
byId.set(id, { type: c.type, slots: [{ day: c.day, time: c.time }], id });
}
}
return [...byId.values()].map((g) => {
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
const label = sameTime
? `${g.type}, ${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
: `${g.type}, ${g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ")}`;
return { label, value: g.id };
}).sort((a, b) => a.label.localeCompare(b.label));
}, [allClasses, hall, trainer]);
// Reset downstream on upstream change (skip during initial pre-fill)
const initRef = useRef(false);
useEffect(() => {
if (initRef.current) { setTrainer(""); setGroup(""); }
initRef.current = true;
}, [hall]);
useEffect(() => {
if (initRef.current && trainer === "") setGroup("");
}, [trainer]);
// Reset init flag when modal closes
useEffect(() => { if (!open) initRef.current = false; }, [open]);
if (!open) return null;
const today = new Date().toISOString().split("T")[0];
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<X size={16} />
</button>
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
<div className="mt-4 space-y-3">
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
<input
type="date"
value={date}
min={today}
disabled={!group}
onChange={(e) => setDate(e.target.value)}
className={selectClass}
/>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
<input
type="text"
value={comment}
disabled={!group}
onChange={(e) => setComment(e.target.value)}
placeholder="Первое занятие, пробный"
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed"
/>
</div>
</div>
<button
onClick={() => {
if (group && date) {
onConfirm({ group, date, comment: comment.trim() || undefined });
}
}}
disabled={!group || !date}
className="mt-5 w-full rounded-lg bg-emerald-600 py-2.5 text-sm font-semibold text-white transition-all hover:bg-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed"
>
Подтвердить
</button>
</div>
</div>,
document.body
);
}
// --- Group Bookings Tab ---
interface ScheduleClassInfo { type: string; trainer: string; time: string; day: string; hall: string; address: string; groupId?: string }
interface ScheduleLocation { name: string; address: string; days: { day: string; classes: { time: string; trainer: string; type: string; groupId?: string }[] }[] }
function GroupBookingsTab() {
const [bookings, setBookings] = useState<GroupBooking[]>([]);
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<BookingFilter>("all");
useEffect(() => {
adminFetch("/api/admin/group-bookings")
.then((r) => r.json())
.then((data: GroupBooking[]) => setBookings(data))
Promise.all([
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
])
.then(([bookingData, scheduleData]: [GroupBooking[], { locations?: ScheduleLocation[] }]) => {
setBookings(bookingData);
const classes: ScheduleClassInfo[] = [];
for (const loc of scheduleData.locations || []) {
const shortAddr = loc.address?.split(",")[0] || loc.name;
for (const day of loc.days) {
for (const cls of day.classes) {
classes.push({
type: cls.type,
trainer: cls.trainer,
time: cls.time,
day: day.day,
hall: loc.name,
address: shortAddr,
groupId: cls.groupId,
});
}
}
}
setAllClasses(classes);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
@@ -87,12 +304,20 @@ function GroupBookingsTab() {
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));
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
async function handleStatus(id: number, status: BookingStatus, confirmation?: { group: string; date: string; comment?: string }) {
setBookings((prev) => prev.map((b) => b.id === id ? {
...b, status,
confirmedDate: confirmation?.date,
confirmedGroup: confirmation?.group,
confirmedComment: confirmation?.comment,
} : b));
await adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id, status, confirmedDate }),
body: JSON.stringify({ action: "set-status", id, status, confirmation }),
});
}
@@ -176,9 +401,11 @@ function GroupBookingsTab() {
{statusConf.label}
</span>
{b.status === "confirmed" && b.confirmedDate && (
{b.status === "confirmed" && (
<span className="text-[10px] text-emerald-400/70">
Дата: {new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}
{b.confirmedGroup}
{b.confirmedDate && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
{b.confirmedComment && ` · ${b.confirmedComment}`}
</span>
)}
@@ -194,20 +421,17 @@ function GroupBookingsTab() {
)}
{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={() => setConfirmingId(b.id)}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20 transition-all"
>
Подтвердить
</button>
<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>
</>
)}
@@ -225,6 +449,18 @@ function GroupBookingsTab() {
);
})}
</div>
<ConfirmModal
open={confirmingId !== null}
bookingName={confirmingBooking?.name ?? ""}
groupInfo={confirmingBooking?.groupInfo}
allClasses={allClasses}
onClose={() => setConfirmingId(null)}
onConfirm={(data) => {
if (confirmingId) handleStatus(confirmingId, "confirmed", data);
setConfirmingId(null);
}}
/>
</div>
);
}

View File

@@ -24,12 +24,12 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ ok: true });
}
if (body.action === "set-status") {
const { id, status, confirmedDate } = body;
const { id, status, confirmation } = 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);
setGroupBookingStatus(id, status, confirmation);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });

View File

@@ -211,6 +211,12 @@ const migrations: Migration[] = [
if (!cols.some((c) => c.name === "confirmed_date")) {
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_date TEXT");
}
if (!cols.some((c) => c.name === "confirmed_group")) {
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_group TEXT");
}
if (!cols.some((c) => c.name === "confirmed_comment")) {
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_comment TEXT");
}
},
},
];
@@ -596,6 +602,8 @@ interface GroupBookingRow {
reminder_status: string | null;
status: string;
confirmed_date: string | null;
confirmed_group: string | null;
confirmed_comment: string | null;
created_at: string;
}
@@ -613,6 +621,8 @@ export interface GroupBooking {
reminderStatus?: string;
status: BookingStatus;
confirmedDate?: string;
confirmedGroup?: string;
confirmedComment?: string;
createdAt: string;
}
@@ -649,16 +659,26 @@ export function getGroupBookings(): GroupBooking[] {
reminderStatus: r.reminder_status ?? undefined,
status: (r.status || "new") as BookingStatus,
confirmedDate: r.confirmed_date ?? undefined,
confirmedGroup: r.confirmed_group ?? undefined,
confirmedComment: r.confirmed_comment ?? undefined,
createdAt: r.created_at,
}));
}
export function setGroupBookingStatus(id: number, status: BookingStatus, confirmedDate?: string): void {
export function setGroupBookingStatus(
id: number,
status: BookingStatus,
confirmation?: { date: string; group: string; comment?: string }
): void {
const db = getDb();
if (status === "confirmed" && confirmedDate) {
db.prepare("UPDATE group_bookings SET status = ?, confirmed_date = ? WHERE id = ?").run(status, confirmedDate, id);
if (status === "confirmed" && confirmation) {
db.prepare(
"UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_comment = ? WHERE id = ?"
).run(status, confirmation.date, confirmation.group, confirmation.comment || null, id);
} else {
db.prepare("UPDATE group_bookings SET status = ?, confirmed_date = NULL WHERE id = ?").run(status, id);
db.prepare(
"UPDATE group_bookings SET status = ?, confirmed_date = NULL, confirmed_group = NULL, confirmed_comment = NULL WHERE id = ?"
).run(status, id);
}
}