feat: dashboard summary on bookings page, archive expired MC and Open Day

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 19:13:49 +03:00
parent b906216317
commit 87f488e2c1
3 changed files with 370 additions and 126 deletions

View File

@@ -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<DashboardCounts | null>(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 (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-4">
{cards.map((c) => {
const total = c.urgent + c.pending;
if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
<p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p>
</div>
);
return (
<button
key={c.tab}
onClick={() => onNavigate(c.tab)}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}
>
<p className="text-xs text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1">
{c.urgent > 0 && (
<span className={`text-lg font-bold ${c.urgentColor}`}>{c.urgent}</span>
)}
{c.urgent > 0 && (
<span className="text-[10px] text-neutral-500">{c.urgentLabel}</span>
)}
{c.pending > 0 && (
<>
{c.urgent > 0 && <span className="text-neutral-700">·</span>}
<span className="text-sm font-medium text-neutral-400">{c.pending}</span>
<span className="text-[10px] text-neutral-500">{c.pendingLabel}</span>
</>
)}
</div>
</button>
);
})}
</div>
);
}
// --- Main Page ---
const TABS: { key: Tab; label: string }[] = [
@@ -659,9 +771,9 @@ export default function BookingsPage() {
return (
<div>
<h1 className="text-2xl font-bold">Записи</h1>
<p className="mt-1 text-neutral-400 text-sm">
Все заявки и записи в одном месте
</p>
{/* Dashboard — what needs attention */}
<DashboardSummary onNavigate={setTab} />
{/* Tabs */}
<div className="mt-5 flex border-b border-white/10">