- 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>
145 lines
6.1 KiB
TypeScript
145 lines
6.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|