From 87f488e2c12ee18a1c1cebff27cc7e5d6b3d33c9 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Mon, 23 Mar 2026 19:13:49 +0300 Subject: [PATCH] feat: dashboard summary on bookings page, archive expired MC and Open Day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard cards show new/pending counts per tab, click to navigate - MC tab: expired master classes (past date or deleted) move to collapsible archive - Open Day tab: past events move to archive section - Date badges on MC group headers (gold active, strikethrough archived) - Fix MC content API key (masterClasses not master-classes) - Fuzzy title matching for MC registration → content date lookup Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/bookings/_McRegistrationsTab.tsx | 188 +++++++++++------ .../admin/bookings/_OpenDayBookingsTab.tsx | 190 ++++++++++++------ src/app/admin/bookings/page.tsx | 118 ++++++++++- 3 files changed, 370 insertions(+), 126 deletions(-) diff --git a/src/app/admin/bookings/_McRegistrationsTab.tsx b/src/app/admin/bookings/_McRegistrationsTab.tsx index 3f459d7..4153898 100644 --- a/src/app/admin/bookings/_McRegistrationsTab.tsx +++ b/src/app/admin/bookings/_McRegistrationsTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, Archive } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; import { type BookingStatus, type BookingFilter, @@ -21,35 +21,75 @@ interface McRegistration { createdAt: string; } +interface McSlot { date: string; startTime: string } +interface McItem { title: string; slots: McSlot[] } + export function McRegistrationsTab() { const [regs, setRegs] = useState([]); + const [mcDates, setMcDates] = useState>({}); // title → latest slot date const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("all"); + const [showArchived, setShowArchived] = useState(false); useEffect(() => { - adminFetch("/api/admin/mc-registrations") - .then((r) => r.json()) - .then((data: McRegistration[]) => setRegs(data)) - .catch(() => {}) - .finally(() => setLoading(false)); + Promise.all([ + adminFetch("/api/admin/mc-registrations").then((r) => r.json()), + adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()), + ]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => { + setRegs(regData); + // Build lookup: MC title → latest slot date + // Use both exact match and fuzzy match (registration title contains MC title or vice versa) + const dates: Record = {}; + const mcItems = mcData.items || []; + for (const mc of mcItems) { + const latestSlot = mc.slots?.reduce((latest, s) => s.date > latest ? s.date : latest, ""); + if (latestSlot) dates[mc.title] = latestSlot; + } + // Also match registration titles that don't exactly match MC titles + const regTitles = new Set(regData.map((r) => r.masterClassTitle)); + for (const regTitle of regTitles) { + if (dates[regTitle]) continue; // already matched + // Try fuzzy: find MC whose title is contained in regTitle or vice versa + for (const mc of mcItems) { + const latestSlot = mc.slots?.reduce((latest, s) => s.date > latest ? s.date : latest, ""); + if (!latestSlot) continue; + if (regTitle.toLowerCase().includes(mc.title.toLowerCase()) || mc.title.toLowerCase().includes(regTitle.toLowerCase())) { + dates[regTitle] = latestSlot; + break; + } + } + } + setMcDates(dates); + }).catch(() => {}).finally(() => setLoading(false)); }, []); + const today = new Date().toISOString().split("T")[0]; + + function isExpired(title: string) { + const date = mcDates[title]; + if (!date) return true; // no matching MC in content = deleted/archived + return date < today; + } + const counts = useMemo(() => countStatuses(regs), [regs]); - const grouped = useMemo(() => { - const map: Record = {}; + const { active, archived } = useMemo(() => { + const active: Record = {}; + const archived: Record = {}; for (const r of regs) { if (filter !== "all" && r.status !== filter) continue; - if (!map[r.masterClassTitle]) map[r.masterClassTitle] = []; - map[r.masterClassTitle].push(r); + const target = isExpired(r.masterClassTitle) ? archived : active; + if (!target[r.masterClassTitle]) target[r.masterClassTitle] = []; + target[r.masterClassTitle].push(r); } - for (const items of Object.values(map)) { + for (const items of [...Object.values(active), ...Object.values(archived)]) { const sorted = sortByStatus(items); items.length = 0; items.push(...sorted); } - return map; - }, [regs, filter]); + return { active, archived }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [regs, filter, mcDates]); const [expanded, setExpanded] = useState>({}); @@ -69,56 +109,90 @@ export function McRegistrationsTab() { if (loading) return ; + function renderGroup(title: string, items: McRegistration[], isArchived: boolean) { + const isOpen = expanded[title] ?? !isArchived; + const groupCounts = countStatuses(items); + return ( +
+ + {isOpen && ( +
+ {items.map((r) => ( + +
+
+ {r.name} + +
+
+ {fmtDate(r.createdAt)} + handleDelete(r.id)} /> +
+
+
+ + {!isArchived && handleStatus(r.id, s)} />} +
+
+ ))} +
+ )} +
+ ); + } + + const archivedCount = Object.values(archived).reduce((sum, items) => sum + items.length, 0); + return (
- {Object.keys(grouped).length === 0 && } - {Object.entries(grouped).map(([title, items]) => { - const isOpen = expanded[title] ?? true; - const groupCounts = countStatuses(items); - return ( -
- - {isOpen && ( -
- {items.map((r) => ( - -
-
- {r.name} - -
-
- {fmtDate(r.createdAt)} - handleDelete(r.id)} /> -
-
-
- - handleStatus(r.id, s)} /> -
-
- ))} -
- )} -
- ); - })} + {Object.keys(active).length === 0 && Object.keys(archived).length === 0 && } + {Object.entries(active).map(([title, items]) => renderGroup(title, items, false))}
+ + {archivedCount > 0 && ( +
+ + {showArchived && ( +
+ {Object.entries(archived).map(([title, items]) => renderGroup(title, items, true))} +
+ )} +
+ )}
); } diff --git a/src/app/admin/bookings/_OpenDayBookingsTab.tsx b/src/app/admin/bookings/_OpenDayBookingsTab.tsx index 0bf2096..ea0f7df 100644 --- a/src/app/admin/bookings/_OpenDayBookingsTab.tsx +++ b/src/app/admin/bookings/_OpenDayBookingsTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, Archive } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; import { type BookingStatus, type BookingFilter, @@ -26,48 +26,76 @@ interface OpenDayBooking { classHall?: string; } +interface EventInfo { id: number; date: string; title?: string } + export function OpenDayBookingsTab() { const [bookings, setBookings] = useState([]); + const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("all"); + const [showArchived, setShowArchived] = useState(false); useEffect(() => { adminFetch("/api/admin/open-day") .then((r) => r.json()) - .then((events: { id: number; date: string }[]) => { - if (events.length === 0) { - setLoading(false); - return; + .then(async (evts: EventInfo[]) => { + setEvents(evts); + if (evts.length === 0) return; + // Fetch bookings for all events + const allBookings: OpenDayBooking[] = []; + for (const ev of evts) { + const data = await adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`).then((r) => r.json()); + allBookings.push(...data); } - const ev = events[0]; - return adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`) - .then((r) => r.json()) - .then((data: OpenDayBooking[]) => setBookings(data)); + setBookings(allBookings); }) .catch(() => {}) .finally(() => setLoading(false)); }, []); + const today = new Date().toISOString().split("T")[0]; + + const eventDateMap = useMemo(() => { + const map: Record = {}; + for (const ev of events) map[ev.id] = ev.date; + return map; + }, [events]); + + function isExpired(eventId: number) { + const date = eventDateMap[eventId]; + return date ? date < today : false; + } + const counts = useMemo(() => countStatuses(bookings), [bookings]); - const grouped = useMemo(() => { + type GroupInfo = { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[]; eventId: number }; + + const { activeGroups, archivedGroups } = useMemo(() => { const filtered = filter === "all" ? bookings : bookings.filter((b) => b.status === filter); - const map: Record = {}; + const activeMap: Record = {}; + const archivedMap: Record = {}; for (const b of filtered) { - const key = `${b.classHall}|${b.classTime}|${b.classStyle}`; - if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [] }; - map[key].items.push(b); + const key = `${b.eventId}|${b.classHall}|${b.classTime}|${b.classStyle}`; + const target = isExpired(b.eventId) ? archivedMap : activeMap; + if (!target[key]) target[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [], eventId: b.eventId }; + target[key].items.push(b); } - for (const g of Object.values(map)) { + for (const g of [...Object.values(activeMap), ...Object.values(archivedMap)]) { const sorted = sortByStatus(g.items); g.items.length = 0; g.items.push(...sorted); } - return Object.entries(map).sort(([, a], [, b]) => { - const hallCmp = a.hall.localeCompare(b.hall); - return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time); - }); - }, [bookings, filter]); + const sortGroups = (entries: [string, GroupInfo][]) => + entries.sort(([, a], [, b]) => { + const hallCmp = a.hall.localeCompare(b.hall); + return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time); + }); + return { + activeGroups: sortGroups(Object.entries(activeMap)), + archivedGroups: sortGroups(Object.entries(archivedMap)), + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookings, filter, eventDateMap]); const [expanded, setExpanded] = useState>({}); @@ -87,58 +115,88 @@ export function OpenDayBookingsTab() { if (loading) return ; + function renderGroup(key: string, group: GroupInfo, isArchived: boolean) { + const isOpen = expanded[key] ?? !isArchived; + const groupCounts = countStatuses(group.items); + const eventDate = eventDateMap[group.eventId]; + return ( +
+ + {isOpen && ( +
+ {group.items.map((b) => ( + +
+
+ {b.name} + +
+
+ {fmtDate(b.createdAt)} + handleDelete(b.id)} /> +
+
+
+ + {!isArchived && handleStatus(b.id, s)} />} +
+
+ ))} +
+ )} +
+ ); + } + + const archivedCount = archivedGroups.reduce((sum, [, g]) => sum + g.items.length, 0); + return (
- {grouped.length === 0 && } - {grouped.map(([key, group]) => { - const isOpen = expanded[key] ?? true; - const groupCounts = countStatuses(group.items); - return ( -
- - {isOpen && ( -
- {group.items.map((b) => ( - -
-
- {b.name} - -
-
- {fmtDate(b.createdAt)} - handleDelete(b.id)} /> -
-
-
- - handleStatus(b.id, s)} /> -
-
- ))} -
- )} -
- ); - })} + {activeGroups.length === 0 && archivedGroups.length === 0 && } + {activeGroups.map(([key, group]) => renderGroup(key, group, false))}
+ + {archivedCount > 0 && ( +
+ + {showArchived && ( +
+ {archivedGroups.map(([key, group]) => renderGroup(key, group, true))} +
+ )} +
+ )}
); } diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index bef05ad..eb7a76e 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -644,6 +644,118 @@ function RemindersTab() { ); } +// --- Dashboard Summary --- + +interface DashboardCounts { + classesNew: number; + classesContacted: number; + mcNew: number; + mcContacted: number; + odNew: number; + odContacted: number; + remindersToday: number; + remindersTomorrow: number; +} + +function DashboardSummary({ onNavigate }: { onNavigate: (tab: Tab) => void }) { + const [counts, setCounts] = useState(null); + + useEffect(() => { + Promise.all([ + adminFetch("/api/admin/group-bookings").then((r) => r.json()), + adminFetch("/api/admin/mc-registrations").then((r) => r.json()), + adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: { id: number }[]) => { + if (events.length === 0) return []; + return adminFetch(`/api/admin/open-day/bookings?eventId=${events[0].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 }[]]) => { + const today = new Date().toISOString().split("T")[0]; + const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0]; + setCounts({ + classesNew: gb.filter((b) => b.status === "new").length, + classesContacted: gb.filter((b) => b.status === "contacted").length, + mcNew: mc.filter((b) => b.status === "new").length, + mcContacted: mc.filter((b) => b.status === "contacted").length, + odNew: od.filter((b) => b.status === "new").length, + odContacted: od.filter((b) => b.status === "contacted").length, + 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; urgent: number; urgentLabel: string; pending: number; pendingLabel: string; color: string; urgentColor: string }[] = [ + { + tab: "reminders", label: "Напоминания", + urgent: counts.remindersToday, urgentLabel: "сегодня", + pending: counts.remindersTomorrow, pendingLabel: "завтра", + color: "border-amber-500/20", urgentColor: "text-amber-400", + }, + { + tab: "classes", label: "Занятия", + urgent: counts.classesNew, urgentLabel: "новых", + pending: counts.classesContacted, pendingLabel: "в работе", + color: "border-gold/20", urgentColor: "text-gold", + }, + { + tab: "master-classes", label: "Мастер-классы", + urgent: counts.mcNew, urgentLabel: "новых", + pending: counts.mcContacted, pendingLabel: "в работе", + color: "border-purple-500/20", urgentColor: "text-purple-400", + }, + { + tab: "open-day", label: "Open Day", + urgent: counts.odNew, urgentLabel: "новых", + pending: counts.odContacted, pendingLabel: "в работе", + color: "border-blue-500/20", urgentColor: "text-blue-400", + }, + ]; + + const hasWork = cards.some((c) => c.urgent > 0 || c.pending > 0); + if (!hasWork) return null; + + return ( +
+ {cards.map((c) => { + const total = c.urgent + c.pending; + if (total === 0) return ( +
+

{c.label}

+

+
+ ); + return ( + + ); + })} +
+ ); +} + // --- Main Page --- const TABS: { key: Tab; label: string }[] = [ @@ -659,9 +771,9 @@ export default function BookingsPage() { return (

Записи

-

- Все заявки и записи в одном месте -

+ + {/* Dashboard — what needs attention */} + {/* Tabs */}