feat: add status workflow to MC and Open Day bookings, refactor into separate files

- DB migration v12: add status column to mc_registrations and open_day_bookings
- MC and Open Day tabs now have full status workflow (new → contacted → confirmed/declined)
- Filter tabs with counts, status badges, action buttons matching group bookings
- Extract shared components (_shared.tsx): FilterTabs, StatusBadge, StatusActions, BookingCard, ContactLinks
- Split monolith into _McRegistrationsTab.tsx, _OpenDayBookingsTab.tsx, _shared.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 19:05:44 +03:00
parent 575c684cc5
commit b906216317
7 changed files with 501 additions and 294 deletions

View File

@@ -0,0 +1,144 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import {
type BookingStatus, type BookingFilter,
LoadingSpinner, EmptyState, DeleteBtn, ContactLinks,
FilterTabs, StatusBadge, StatusActions, BookingCard,
fmtDate, countStatuses, sortByStatus,
} from "./_shared";
interface OpenDayBooking {
id: number;
classId: number;
eventId: number;
name: string;
phone: string;
instagram?: string;
telegram?: string;
status: BookingStatus;
createdAt: string;
classStyle?: string;
classTrainer?: string;
classTime?: string;
classHall?: string;
}
export function OpenDayBookingsTab() {
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<BookingFilter>("all");
useEffect(() => {
adminFetch("/api/admin/open-day")
.then((r) => r.json())
.then((events: { id: number; date: string }[]) => {
if (events.length === 0) {
setLoading(false);
return;
}
const ev = events[0];
return adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`)
.then((r) => r.json())
.then((data: OpenDayBooking[]) => setBookings(data));
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const counts = useMemo(() => countStatuses(bookings), [bookings]);
const grouped = useMemo(() => {
const filtered = filter === "all" ? bookings : bookings.filter((b) => b.status === filter);
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[] }> = {};
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);
}
for (const g of Object.values(map)) {
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 [expanded, setExpanded] = useState<Record<string, boolean>>({});
async function handleStatus(id: number, status: BookingStatus) {
setBookings((prev) => prev.map((b) => b.id === id ? { ...b, status } : b));
await adminFetch("/api/admin/open-day/bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id, status }),
});
}
async function handleDelete(id: number) {
await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
setBookings((prev) => prev.filter((b) => b.id !== id));
}
if (loading) return <LoadingSpinner />;
return (
<div>
<FilterTabs filter={filter} counts={counts} total={bookings.length} onFilter={setFilter} />
<div className="mt-3 space-y-2">
{grouped.length === 0 && <EmptyState total={bookings.length} />}
{grouped.map(([key, group]) => {
const isOpen = expanded[key] ?? true;
const groupCounts = countStatuses(group.items);
return (
<div key={key} className="rounded-xl border border-white/10 overflow-hidden">
<button
onClick={() => setExpanded((p) => ({ ...p, [key]: !isOpen }))}
className="w-full flex items-center gap-3 px-4 py-3 bg-neutral-900 hover:bg-neutral-800/80 transition-colors text-left"
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
<span className="text-gold text-xs font-medium shrink-0">{group.time}</span>
<span className="font-medium text-white text-sm truncate">{group.style}</span>
<span className="text-xs text-neutral-500 truncate hidden sm:inline">· {group.trainer}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0 ml-auto">{group.hall}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
<div className="flex gap-2 text-[10px] shrink-0">
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} нов.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>}
</div>
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-2">
{group.items.map((b) => (
<BookingCard key={b.id} status={b.status}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-white">{b.name}</span>
<ContactLinks phone={b.phone} instagram={b.instagram} telegram={b.telegram} />
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(b.id)} />
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<StatusBadge status={b.status} />
<StatusActions status={b.status} onStatus={(s) => handleStatus(b.id, s)} />
</div>
</BookingCard>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}