"use client"; import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { Phone, Instagram, Send, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; import { MS_PER_DAY } from "@/lib/constants"; import { type BookingStatus, type BookingFilter, type SearchResult, SHORT_DAYS, fmtDate } from "./types"; import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents"; import { GenericBookingsList } from "./GenericBookingsList"; import { AddBookingModal } from "./AddBookingModal"; import { SearchBar } from "./SearchBar"; import { McRegistrationsTab } from "./McRegistrationsTab"; import { OpenDayBookingsTab } from "./OpenDayBookingsTab"; import { ToastProvider, useToast } from "./Toast"; // --- Types --- interface GroupBooking { id: number; name: string; phone: string; groupInfo?: string; instagram?: string; telegram?: string; notifiedConfirm: boolean; notifiedReminder: boolean; status: BookingStatus; confirmedDate?: string; confirmedGroup?: string; confirmedComment?: string; notes?: string; createdAt: string; } type Tab = "reminders" | "classes" | "master-classes" | "open-day"; // --- Confirm Booking Modal --- function ConfirmModal({ open, bookingName, groupInfo, existingDate, existingGroup, allClasses, onConfirm, onClose, }: { open: boolean; bookingName: string; groupInfo?: string; existingDate?: string; existingGroup?: 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; const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0]; setDate(existingDate && existingDate.length === 10 ? existingDate : tomorrow); setComment(""); // Try to match groupInfo or existingGroup against schedule to pre-fill const matchText = existingGroup || groupInfo; if (matchText && allClasses.length > 0) { const info = matchText.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, existingDate, existingGroup, allClasses]); // 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]); // #11: Keyboard submit const today = open ? new Date().toISOString().split("T")[0] : ""; const canSubmit = group && date && date.length === 10 && date >= today; const handleSubmit = useCallback(() => { if (canSubmit) { const groupLabel = groups.find((g) => g.value === group)?.label || group; onConfirm({ group: groupLabel, date, comment: comment.trim() || undefined }); } }, [canSubmit, group, date, comment, groups, onConfirm]); useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); if (e.key === "Enter" && canSubmit) { e.preventDefault(); handleSubmit(); } } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open, onClose, canSubmit, handleSubmit]); if (!open) return null; 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} ${date && (date < today || date.length !== 10) ? "!border-red-500/50" : ""}`} /> {date && (date < today || date.length !== 10) && (

{date < today ? "Дата не может быть в прошлом" : "Неверный формат даты"}

)}
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({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) { const [bookings, setBookings] = useState([]); const [allClasses, setAllClasses] = useState([]); const [loading, setLoading] = useState(true); const [confirmingId, setConfirmingId] = useState(null); const [error, setError] = useState(false); useEffect(() => { 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(() => setError(true)) .finally(() => setLoading(false)); }, []); const confirmingBooking = bookings.find((b) => b.id === confirmingId); async function handleConfirm(data: { group: string; date: string; comment?: string }) { if (!confirmingId) return; const existing = bookings.find((b) => b.id === confirmingId); const notes = data.comment ? (existing?.notes ? `${existing.notes}\n${data.comment}` : data.comment) : existing?.notes; setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, status: "confirmed" as BookingStatus, confirmedDate: data.date, confirmedGroup: data.group, notes, } : b)); await Promise.all([ adminFetch("/api/admin/group-bookings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, date: data.date } }), }), data.comment ? adminFetch("/api/admin/group-bookings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }), }) : Promise.resolve(), ]); setConfirmingId(null); onDataChange?.(); } if (loading) return ; if (error) return

Не удалось загрузить данные

; return ( <> items={bookings} endpoint="/api/admin/group-bookings" filter={filter} onItemsChange={setBookings} onDataChange={onDataChange} onConfirm={(id) => setConfirmingId(id)} renderExtra={(b) => ( <> {b.groupInfo && {b.groupInfo}} {(b.confirmedGroup || b.confirmedDate) && ( )} )} /> setConfirmingId(null)} onConfirm={handleConfirm} /> ); } // --- Reminders Tab --- interface ReminderItem { id: number; type: "class" | "master-class" | "open-day"; table: "mc_registrations" | "group_bookings" | "open_day_bookings"; name: string; phone?: string; instagram?: string; telegram?: string; reminderStatus?: string; eventLabel: string; eventDate: string; } type ReminderStatus = "pending" | "coming" | "cancelled"; const STATUS_CONFIG: Record = { pending: { label: "Нет ответа", icon: Clock, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" }, coming: { label: "Придёт", icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" }, cancelled: { label: "Не придёт", icon: XCircle, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" }, }; const TYPE_CONFIG = { "master-class": { label: "МК", icon: Star, color: "text-purple-400" }, "open-day": { label: "Open Day", icon: DoorOpen, color: "text-gold" }, "class": { label: "Занятие", icon: Calendar, color: "text-blue-400" }, }; function RemindersTab() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [savingIds, setSavingIds] = useState>(new Set()); const { showError } = useToast(); useEffect(() => { adminFetch("/api/admin/reminders") .then((r) => r.json()) .then((data: ReminderItem[]) => setItems(data)) .catch(() => setError(true)) .finally(() => setLoading(false)); }, []); async function setStatus(item: ReminderItem, status: ReminderStatus | null) { const key = `${item.table}-${item.id}`; const prevStatus = item.reminderStatus; setSavingIds((prev) => new Set(prev).add(key)); setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: status ?? undefined } : i)); try { const res = await adminFetch("/api/admin/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ table: item.table, id: item.id, status }), }); if (!res.ok) throw new Error(); } catch { setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: prevStatus } : i)); showError("Не удалось обновить статус"); } finally { setSavingIds((prev) => { const next = new Set(prev); next.delete(key); return next; }); } } if (loading) return ; if (error) return

Не удалось загрузить напоминания

; const today = new Date().toISOString().split("T")[0]; const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split("T")[0]; const todayItems = items.filter((i) => i.eventDate === today); const tomorrowItems = items.filter((i) => i.eventDate === tomorrow); // Stats function countByStatus(list: ReminderItem[]) { const coming = list.filter((i) => i.reminderStatus === "coming").length; const cancelled = list.filter((i) => i.reminderStatus === "cancelled").length; const pending = list.filter((i) => i.reminderStatus === "pending").length; const notAsked = list.filter((i) => !i.reminderStatus).length; return { coming, cancelled, pending, notAsked, total: list.length }; } if (items.length === 0) { return (

Нет напоминаний — все на контроле

Здесь появятся записи на сегодня и завтра

); } // Group items by event within each day function groupByEvent(dayItems: ReminderItem[]) { const map: Record = {}; for (const item of dayItems) { const key = `${item.type}|${item.eventLabel}`; if (!map[key]) map[key] = { type: item.type, label: item.eventLabel, items: [] }; map[key].items.push(item); } return Object.values(map); } const STATUS_SECTIONS = [ { key: "not-asked", label: "Не спрошены", color: "text-gold", bg: "bg-gold/10", match: (i: ReminderItem) => !i.reminderStatus }, { key: "pending", label: "Нет ответа", color: "text-amber-400", bg: "bg-amber-500/10", match: (i: ReminderItem) => i.reminderStatus === "pending" }, { key: "coming", label: "Придёт", color: "text-emerald-400", bg: "bg-emerald-500/10", match: (i: ReminderItem) => i.reminderStatus === "coming" }, { key: "cancelled", label: "Не придёт", color: "text-red-400", bg: "bg-red-500/10", match: (i: ReminderItem) => i.reminderStatus === "cancelled" }, ]; function renderPerson(item: ReminderItem) { const currentStatus = item.reminderStatus as ReminderStatus | undefined; const isSaving = savingIds.has(`${item.table}-${item.id}`); return (
{item.name} {item.phone && ( {item.phone} )} {item.instagram && ( {item.instagram} )} {item.telegram && ( {item.telegram} )}
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => { const conf = STATUS_CONFIG[st]; const Icon = conf.icon; const active = currentStatus === st; return ( ); })}
); } return (
{[ { label: "Сегодня", date: today, items: todayItems }, { label: "Завтра", date: tomorrow, items: tomorrowItems }, ] .filter((g) => g.items.length > 0) .map((group) => { const eventGroups = groupByEvent(group.items); return (

{group.label}

{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
{eventGroups.map((eg) => { const typeConf = TYPE_CONFIG[eg.type]; const TypeIcon = typeConf.icon; const egStats = countByStatus(eg.items); return (
{eg.label} {eg.items.length} чел.
{egStats.coming > 0 && {egStats.coming} придёт} {egStats.cancelled > 0 && {egStats.cancelled} не придёт} {egStats.pending > 0 && {egStats.pending} нет ответа} {egStats.notAsked > 0 && {egStats.notAsked} не спрошены}
{STATUS_SECTIONS .map((sec) => ({ ...sec, items: eg.items.filter(sec.match) })) .filter((sec) => sec.items.length > 0) .map((sec) => (
{sec.label} · {sec.items.length}
{sec.items.map(renderPerson)}
))}
); })}
); })}
); } // --- Dashboard Summary --- interface TabCounts { new: number; contacted: number; confirmed: number; declined: number } interface DashboardCounts { classes: TabCounts; mc: TabCounts; od: TabCounts; remindersToday: number; remindersTomorrow: number; } function countByStatus(items: { status: string }[]): TabCounts { const c = { new: 0, contacted: 0, confirmed: 0, declined: 0 }; for (const i of items) if (i.status in c) c[i.status as keyof TabCounts]++; return c; } function DashboardSummary({ statusFilter, onNavigate, onFilter }: { statusFilter: BookingFilter; onNavigate: (tab: Tab) => void; onFilter: (f: BookingFilter) => void; }) { const [counts, setCounts] = useState(null); useEffect(() => { const today = new Date().toISOString().split("T")[0]; const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0]; Promise.all([ adminFetch("/api/admin/group-bookings").then((r) => r.json()), Promise.all([ adminFetch("/api/admin/mc-registrations").then((r) => r.json()), adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()), ]).then(([regs, mcData]: [{ status: string; masterClassTitle: string }[], { items?: { title: string; slots: { date: string }[] }[] }]) => { const upcomingTitles = new Set(); for (const mc of mcData.items || []) { const earliest = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? ""); if (earliest && earliest >= today) upcomingTitles.add(mc.title); } return regs.filter((r) => upcomingTitles.has(r.masterClassTitle)); }), adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: { id: number; date: string }[]) => { const active = events.find((e) => e.date >= today); if (!active) return []; return adminFetch(`/api/admin/open-day/bookings?eventId=${active.id}`).then((r) => r.json()); }), adminFetch("/api/admin/reminders").then((r) => r.json()).catch(() => []), ]).then(([gb, mc, od, rem]: [{ status: string }[], { status: string }[], { status: string }[], { eventDate: string }[]]) => { setCounts({ classes: countByStatus(gb), mc: countByStatus(mc), od: countByStatus(od), remindersToday: rem.filter((r) => r.eventDate === today).length, remindersTomorrow: rem.filter((r) => r.eventDate === tomorrow).length, }); }).catch(() => {}); }, []); if (!counts) return null; const cards: { tab: Tab; label: string; counts: TabCounts | null; color: string; urgentColor: string }[] = [ { tab: "reminders", label: "Напоминания", counts: null, color: "border-amber-500/20", urgentColor: "text-amber-400" }, { tab: "classes", label: "Занятия", counts: counts.classes, color: "border-gold/20", urgentColor: "text-gold" }, { tab: "master-classes", label: "Мастер-классы", counts: counts.mc, color: "border-purple-500/20", urgentColor: "text-purple-400" }, { tab: "open-day", label: "Open Day", counts: counts.od, color: "border-cyan-500/20", urgentColor: "text-cyan-400" }, ]; const hasWork = cards.some((c) => { if (c.counts) return c.counts.new + c.counts.contacted + c.counts.confirmed + c.counts.declined > 0; return counts.remindersToday + counts.remindersTomorrow > 0; }); if (!hasWork) return null; return (
{cards.map((c) => { // Reminders card if (c.tab === "reminders") { const total = counts.remindersToday + counts.remindersTomorrow; if (total === 0) return (

{c.label}

); return ( ); } // Booking cards — big numbers for new/contacted, small chips for confirmed/declined const tc = c.counts!; const total = tc.new + tc.contacted + tc.confirmed + tc.declined; if (total === 0) return (

{c.label}

); return ( ); })}
); } // --- Main Page --- const TABS: { key: Tab; label: string }[] = [ { key: "reminders", label: "Напоминания" }, { key: "classes", label: "Занятия" }, { key: "master-classes", label: "Мастер-классы" }, { key: "open-day", label: "День открытых дверей" }, ]; const ENDPOINT_MAP: Record = { class: "/api/admin/group-bookings", mc: "/api/admin/mc-registrations", "open-day": "/api/admin/open-day/bookings", }; function BookingsPageInner() { const [tab, setTab] = useState("reminders"); const [addOpen, setAddOpen] = useState(false); const [searchResults, setSearchResults] = useState(null); const [statusFilter, setStatusFilter] = useState("all"); const [refreshKey, setRefreshKey] = useState(0); const [dashboardKey, setDashboardKey] = useState(0); const refreshDashboard = useCallback(() => setDashboardKey((k) => k + 1), []); const lastTotalRef = useRef(null); const { showError } = useToast(); // Poll for new bookings, auto-refresh silently useEffect(() => { const id = setInterval(() => { if (document.hidden) return; adminFetch("/api/admin/unread-counts") .then((r) => r.json()) .then((data: { total: number }) => { if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) { setRefreshKey((k) => k + 1); } lastTotalRef.current = data.total; }) .catch(() => {}); }, 10000); return () => clearInterval(id); }, []); // #5: Search result status change async function handleSearchStatus(result: SearchResult, status: BookingStatus) { const endpoint = ENDPOINT_MAP[result.type]; if (!endpoint) return; const prevStatus = result.status; setSearchResults((prev) => prev?.map((r) => r.id === result.id && r.type === result.type ? { ...r, status } : r) ?? null); try { const res = await adminFetch(endpoint, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "set-status", id: result.id, status }), }); if (!res.ok) throw new Error(); } catch { setSearchResults((prev) => prev?.map((r) => r.id === result.id && r.type === result.type ? { ...r, status: prevStatus } : r) ?? null); showError("Не удалось обновить статус"); } } // #5: Search result delete async function handleSearchDelete(result: SearchResult) { const endpoint = ENDPOINT_MAP[result.type]; if (!endpoint) return; try { const res = await adminFetch(`${endpoint}?id=${result.id}`, { method: "DELETE" }); if (!res.ok) throw new Error(); setSearchResults((prev) => prev?.filter((r) => !(r.id === result.id && r.type === result.type)) ?? null); } catch { showError("Не удалось удалить запись"); } } const TYPE_LABELS: Record = { class: "Занятие", mc: "Мастер-класс", "open-day": "Open Day" }; return (

Записи

{/* Search */}
setSearchResults(null)} />
{searchResults ? ( /* #5: Actionable search results — filtered by status */ (() => { const filtered = statusFilter === "all" ? searchResults : searchResults.filter((r) => r.status === statusFilter); return (
{filtered.length === 0 ? (

{searchResults.length === 0 ? "Ничего не найдено" : "Нет записей по фильтру"}

) : ( filtered.map((r) => (
{TYPE_LABELS[r.type] || r.type} {r.name} {r.groupLabel && {r.groupLabel}}
{fmtDate(r.createdAt)} handleSearchDelete(r)} name={r.name} />
handleSearchStatus(r, s)} />
{r.notes &&

{r.notes}

}
)) )}
); })() ) : ( <> {/* Dashboard — what needs attention */} {/* Tabs */}
{TABS.map((t) => ( ))}
{/* Tab content */}
{tab === "reminders" && } {tab === "classes" && } {tab === "master-classes" && } {tab === "open-day" && }
)} setAddOpen(false)} onAdded={() => setRefreshKey((k) => k + 1)} />
); } export default function BookingsPage() { return ( ); }