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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user