diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index 25450ca..6665585 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -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 = { + "Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ", + "Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС", +}; + 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(); + 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( +
+
+
e.stopPropagation()}> + + +

Подтвердить запись

+

{bookingName}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + setDate(e.target.value)} + className={selectClass} + /> +
+
+ + 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" + /> +
+
+ + +
+
, + 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([]); + const [allClasses, setAllClasses] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("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(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} - {b.status === "confirmed" && b.confirmedDate && ( + {b.status === "confirmed" && ( - Дата: {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}`} )} @@ -194,20 +421,17 @@ function GroupBookingsTab() { )} {b.status === "contacted" && ( <> - { - 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="Подтвердить — выберите дату занятия" - /> + )} @@ -225,6 +449,18 @@ function GroupBookingsTab() { ); })}
+ + setConfirmingId(null)} + onConfirm={(data) => { + if (confirmingId) handleStatus(confirmingId, "confirmed", data); + setConfirmingId(null); + }} + /> ); } diff --git a/src/app/api/admin/group-bookings/route.ts b/src/app/api/admin/group-bookings/route.ts index 254f7ad..1e7d8f6 100644 --- a/src/app/api/admin/group-bookings/route.ts +++ b/src/app/api/admin/group-bookings/route.ts @@ -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 }); diff --git a/src/lib/db.ts b/src/lib/db.ts index bb4ba5f..935a69f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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); } }