Files
blackheart-website/src/app/admin/bookings/page.tsx
diana.dolgolyova b48cc040e1 fix: search and status filter work together consistently
- Status filter chips stay visible during text search
- Search results filtered by selected status (search + filter = AND)
- Shows "Нет записей по фильтру" when search has results but filter excludes all
2026-03-24 17:23:16 +03:00

879 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type BookingFilter, type SearchResult, BOOKING_STATUSES, 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,
allClasses,
onConfirm,
onClose,
}: {
open: boolean;
bookingName: string;
groupInfo?: 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;
setDate(""); setComment("");
// Try to match groupInfo against schedule to pre-fill
if (groupInfo && allClasses.length > 0) {
const info = groupInfo.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, 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<string, { type: string; slots: { day: string; time: string }[]; id: string }>();
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 canSubmit = group && date;
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 today = new Date().toISOString().split("T")[0];
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(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<X size={16} />
</button>
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
<div className="mt-4 space-y-3">
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
<input
type="date"
value={date}
min={today}
disabled={!group}
onChange={(e) => setDate(e.target.value)}
className={selectClass}
/>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
<input
type="text"
value={comment}
disabled={!group}
onChange={(e) => 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"
/>
</div>
</div>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="mt-5 w-full rounded-lg bg-emerald-600 py-2.5 text-sm font-semibold text-white transition-all hover:bg-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed"
>
Подтвердить
</button>
</div>
</div>,
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 }: { filter: BookingFilter }) {
const [bookings, setBookings] = useState<GroupBooking[]>([]);
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
const [loading, setLoading] = useState(true);
const [confirmingId, setConfirmingId] = useState<number | null>(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);
}
if (loading) return <LoadingSpinner />;
if (error) return <p className="text-sm text-red-400 py-8 text-center">Не удалось загрузить данные</p>;
return (
<>
<GenericBookingsList<GroupBooking>
items={bookings}
endpoint="/api/admin/group-bookings"
filter={filter}
onItemsChange={setBookings}
onConfirm={(id) => setConfirmingId(id)}
renderExtra={(b) => (
<>
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
{(b.confirmedGroup || b.confirmedDate) && (
<span className="text-[10px] text-emerald-400/70">
{b.confirmedGroup}
{b.confirmedDate && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
</span>
)}
</>
)}
/>
<ConfirmModal
open={confirmingId !== null}
bookingName={confirmingBooking?.name ?? ""}
groupInfo={confirmingBooking?.groupInfo}
allClasses={allClasses}
onClose={() => 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<ReminderStatus, { label: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }> = {
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<ReminderItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [savingIds, setSavingIds] = useState<Set<string>>(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 <LoadingSpinner />;
if (error) return <p className="text-sm text-red-400 py-8 text-center">Не удалось загрузить напоминания</p>;
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 (
<div className="py-12 text-center">
<Bell size={32} className="mx-auto text-neutral-600 mb-3" />
<p className="text-neutral-400">Нет напоминаний все на контроле</p>
<p className="text-xs text-neutral-600 mt-1">Здесь появятся записи на сегодня и завтра</p>
</div>
);
}
// Group items by event within each day
function groupByEvent(dayItems: ReminderItem[]) {
const map: Record<string, { type: ReminderItem["type"]; label: string; items: ReminderItem[] }> = {};
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 (
<div
key={`${item.table}-${item.id}`}
className={`rounded-lg border p-3 transition-colors ${
!currentStatus ? "border-gold/20 bg-gold/[0.03]"
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
: "border-white/5 bg-neutral-800/30"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{item.name}</span>
{item.phone && (
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{item.phone}
</a>
)}
{item.instagram && (
<a href={`https://ig.me/m/${item.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
<Instagram size={10} />{item.instagram}
</a>
)}
{item.telegram && (
<a href={`https://t.me/${item.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
<Send size={10} />{item.telegram}
</a>
)}
<div className={`flex gap-1 ml-auto ${isSaving ? "opacity-50 pointer-events-none" : ""}`}>
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
const conf = STATUS_CONFIG[st];
const Icon = conf.icon;
const active = currentStatus === st;
return (
<button
key={st}
onClick={() => setStatus(item, active ? null : st)}
disabled={isSaving}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
active
? `${conf.bg} ${conf.color} border ${conf.border}`
: "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300"
}`}
>
<Icon size={10} />
{conf.label}
</button>
);
})}
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{[
{ label: "Сегодня", date: today, items: todayItems },
{ label: "Завтра", date: tomorrow, items: tomorrowItems },
]
.filter((g) => g.items.length > 0)
.map((group) => {
const eventGroups = groupByEvent(group.items);
return (
<div key={group.date}>
<div className="flex items-center gap-3 mb-3">
<h3 className="text-sm font-bold text-white">{group.label}</h3>
<span className="text-[10px] text-neutral-500">
{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
</span>
</div>
<div className="space-y-3">
{eventGroups.map((eg) => {
const typeConf = TYPE_CONFIG[eg.type];
const TypeIcon = typeConf.icon;
const egStats = countByStatus(eg.items);
return (
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
<TypeIcon size={13} className={typeConf.color} />
<span className="text-sm font-medium text-white">{eg.label}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
<div className="flex gap-2 ml-auto text-[10px]">
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
{egStats.pending > 0 && <span className="text-amber-400">{egStats.pending} нет ответа</span>}
{egStats.notAsked > 0 && <span className="text-gold">{egStats.notAsked} не спрошены</span>}
</div>
</div>
<div className="px-4 pb-3 pt-1">
{STATUS_SECTIONS
.map((sec) => ({ ...sec, items: eg.items.filter(sec.match) }))
.filter((sec) => sec.items.length > 0)
.map((sec) => (
<div key={sec.key} className="mt-2 first:mt-0">
<span className={`text-[10px] font-medium ${sec.color} ${sec.bg} rounded-full px-2 py-0.5`}>
{sec.label} · {sec.items.length}
</span>
<div className="mt-1.5 space-y-1.5">
{sec.items.map(renderPerson)}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// --- 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(() => {
const today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0];
Promise.all([
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
// Fetch MC registrations + section data to filter out archived
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 }[] }[] }]) => {
// Build set of upcoming MC titles
const upcomingTitles = new Set<string>();
for (const mc of mcData.items || []) {
if (mc.slots?.some((s) => s.date >= today)) upcomingTitles.add(mc.title);
}
return regs.filter((r) => upcomingTitles.has(r.masterClassTitle));
}),
// Fetch Open Day — only upcoming events
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({
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(() => {}); // Dashboard is non-critical, silent fail OK
}, []);
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 }[] = [
{ key: "reminders", label: "Напоминания" },
{ key: "classes", label: "Занятия" },
{ key: "master-classes", label: "Мастер-классы" },
{ key: "open-day", label: "День открытых дверей" },
];
const ENDPOINT_MAP: Record<string, string> = {
class: "/api/admin/group-bookings",
mc: "/api/admin/mc-registrations",
"open-day": "/api/admin/open-day/bookings",
};
function BookingsPageInner() {
const [tab, setTab] = useState<Tab>("reminders");
const [addOpen, setAddOpen] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [statusFilter, setStatusFilter] = useState<BookingFilter>("all");
const [newBookingsBanner, setNewBookingsBanner] = useState(false);
const lastTotalRef = useRef<number | null>(null);
const { showError } = useToast();
// #10: Pause polling when browser tab not visible
useEffect(() => {
let id: ReturnType<typeof setInterval>;
function startPolling() {
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) {
// #6: Show banner instead of remounting with key
setNewBookingsBanner(true);
}
lastTotalRef.current = data.total;
})
.catch(() => {});
}, 10000);
}
startPolling();
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<string, string> = { class: "Занятие", mc: "Мастер-класс", "open-day": "Open Day" };
return (
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">Записи</h1>
<button
onClick={() => setAddOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-gold/10 text-gold border border-gold/30 hover:bg-gold/20 transition-all"
title="Добавить запись"
>
<Plus size={16} />
</button>
</div>
{/* #6: New bookings banner instead of full remount */}
{newBookingsBanner && (
<button
onClick={() => { setNewBookingsBanner(false); window.location.reload(); }}
className="mt-3 w-full rounded-lg border border-gold/30 bg-gold/10 px-4 py-2 text-sm text-gold hover:bg-gold/20 transition-all text-center"
>
Появились новые записи нажмите для обновления
</button>
)}
{/* Search */}
<div className="mt-3">
<SearchBar
filter={statusFilter}
onFilterChange={setStatusFilter}
onResults={setSearchResults}
onClear={() => setSearchResults(null)}
/>
</div>
{searchResults ? (
/* #5: Actionable search results — filtered by status */
(() => {
const filtered = statusFilter === "all" ? searchResults : searchResults.filter((r) => r.status === statusFilter);
return (
<div className="mt-4 space-y-2">
{filtered.length === 0 ? (
<p className="text-sm text-neutral-500 py-8 text-center">{searchResults.length === 0 ? "Ничего не найдено" : "Нет записей по фильтру"}</p>
) : (
filtered.map((r) => (
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
<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="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span>
<span className="font-medium text-white">{r.name}</span>
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
<DeleteBtn onClick={() => handleSearchDelete(r)} name={r.name} />
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<StatusBadge status={r.status as BookingStatus} />
<StatusActions status={r.status as BookingStatus} onStatus={(s) => handleSearchStatus(r, s)} />
</div>
{r.notes && <p className="mt-1.5 text-[10px] text-neutral-500 truncate">{r.notes}</p>}
</BookingCard>
))
)}
</div>
);
})()
) : (
<>
{/* Dashboard — what needs attention */}
<DashboardSummary onNavigate={setTab} />
{/* Tabs */}
<div className="mt-5 flex border-b border-white/10">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
tab === t.key
? "text-gold"
: "text-neutral-400 hover:text-white"
}`}
>
{t.label}
{tab === t.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gold rounded-full" />
)}
</button>
))}
</div>
{/* Tab content — no key={refreshKey}, banner handles new data */}
<div className="mt-4">
{tab === "reminders" && <RemindersTab />}
{tab === "classes" && <GroupBookingsTab filter={statusFilter} />}
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} />}
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} />}
</div>
</>
)}
<AddBookingModal
open={addOpen}
onClose={() => setAddOpen(false)}
onAdded={() => setNewBookingsBanner(true)}
/>
</div>
);
}
export default function BookingsPage() {
return (
<ToastProvider>
<BookingsPageInner />
</ToastProvider>
);
}