Files
blackheart-website/src/app/admin/bookings/page.tsx
diana.dolgolyova 67d8f6330c refactor: dashboard cards with clickable status counts + tab bar restored
- Dashboard cards show all 4 statuses inline: new (gold), contacted (blue),
  confirmed (green), declined (red) — big numbers with consistent status colors
- Each number+label is clickable to filter the tab by that status
- Tab bar restored below dashboard
- Removed filter chips from SearchBar (dashboard handles filtering)
- Open Day card uses cyan border (distinct from blue contacted status)
2026-03-24 18:56:39 +03:00

916 lines
41 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, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { MS_PER_DAY } from "@/lib/constants";
import { type BookingStatus, type BookingFilter, type SearchResult, 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,
existingDate,
existingGroup,
allClasses,
onConfirm,
onClose,
}: {
open: boolean;
bookingName: string;
groupInfo?: string;
existingDate?: string;
existingGroup?: 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;
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
setDate(existingDate && existingDate.length === 10 ? existingDate : tomorrow); setComment("");
// Try to match groupInfo or existingGroup against schedule to pre-fill
const matchText = existingGroup || groupInfo;
if (matchText && allClasses.length > 0) {
const info = matchText.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, existingDate, existingGroup, 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 today = open ? new Date().toISOString().split("T")[0] : "";
const canSubmit = group && date && date.length === 10 && date >= today;
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 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}
max={new Date(Date.now() + MS_PER_DAY * 365).toISOString().split("T")[0]}
disabled={!group}
onChange={(e) => setDate(e.target.value)}
className={`${selectClass} ${date && (date < today || date.length !== 10) ? "!border-red-500/50" : ""}`}
/>
{date && (date < today || date.length !== 10) && (
<p className="text-[10px] text-red-400 mt-1">{date < today ? "Дата не может быть в прошлом" : "Неверный формат даты"}</p>
)}
</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, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
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);
onDataChange?.();
}
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}
onDataChange={onDataChange}
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) && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
className="text-[10px] text-emerald-400/70 hover:text-emerald-300 transition-colors cursor-pointer"
title="Изменить"
>
{b.confirmedGroup}
{b.confirmedDate && b.confirmedDate.length === 10 && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
{" ✎"}
</button>
)}
</>
)}
/>
<ConfirmModal
open={confirmingId !== null}
bookingName={confirmingBooking?.name ?? ""}
groupInfo={confirmingBooking?.groupInfo}
existingDate={confirmingBooking?.confirmedDate}
existingGroup={confirmingBooking?.confirmedGroup}
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 TabCounts { new: number; contacted: number; confirmed: number; declined: number }
interface DashboardCounts {
classes: TabCounts;
mc: TabCounts;
od: TabCounts;
remindersToday: number;
remindersTomorrow: number;
}
function countByStatus(items: { status: string }[]): TabCounts {
const c = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const i of items) if (i.status in c) c[i.status as keyof TabCounts]++;
return c;
}
function DashboardSummary({ statusFilter, onNavigate, onFilter }: {
statusFilter: BookingFilter;
onNavigate: (tab: Tab) => void;
onFilter: (f: BookingFilter) => void;
}) {
const [counts, setCounts] = useState<DashboardCounts | null>(null);
useEffect(() => {
const today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
Promise.all([
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
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 }[] }[] }]) => {
const upcomingTitles = new Set<string>();
for (const mc of mcData.items || []) {
const earliest = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
if (earliest && earliest >= today) upcomingTitles.add(mc.title);
}
return regs.filter((r) => upcomingTitles.has(r.masterClassTitle));
}),
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({
classes: countByStatus(gb),
mc: countByStatus(mc),
od: countByStatus(od),
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; counts: TabCounts | null; color: string; urgentColor: string }[] = [
{ tab: "reminders", label: "Напоминания", counts: null, color: "border-amber-500/20", urgentColor: "text-amber-400" },
{ tab: "classes", label: "Занятия", counts: counts.classes, color: "border-gold/20", urgentColor: "text-gold" },
{ tab: "master-classes", label: "Мастер-классы", counts: counts.mc, color: "border-purple-500/20", urgentColor: "text-purple-400" },
{ tab: "open-day", label: "Open Day", counts: counts.od, color: "border-cyan-500/20", urgentColor: "text-cyan-400" },
];
const hasWork = cards.some((c) => {
if (c.counts) return c.counts.new + c.counts.contacted + c.counts.confirmed + c.counts.declined > 0;
return counts.remindersToday + counts.remindersTomorrow > 0;
});
if (!hasWork) return null;
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-4">
{cards.map((c) => {
// Reminders card
if (c.tab === "reminders") {
const total = counts.remindersToday + counts.remindersTomorrow;
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">
{counts.remindersToday > 0 && <><span className={`text-lg font-bold ${c.urgentColor}`}>{counts.remindersToday}</span><span className="text-[10px] text-neutral-500">сегодня</span></>}
{counts.remindersTomorrow > 0 && <><span className="text-sm font-medium text-neutral-400">{counts.remindersTomorrow}</span><span className="text-[10px] text-neutral-500">завтра</span></>}
</div>
</button>
);
}
// Booking cards — big numbers for new/contacted, small chips for confirmed/declined
const tc = c.counts!;
const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
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); onFilter("all"); }}
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 flex-wrap">
{tc.new > 0 && (
<>
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:opacity-80 transition-opacity"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(statusFilter === "new" ? "all" : "new"); }}>
<span className="text-lg font-bold text-gold">{tc.new}</span>
<span className="text-[10px] text-neutral-500">новых</span>
</span>
</>
)}
{tc.contacted > 0 && (
<>
{tc.new > 0 && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:opacity-80 transition-opacity"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(statusFilter === "contacted" ? "all" : "contacted"); }}>
<span className="text-sm font-medium text-blue-400">{tc.contacted}</span>
<span className="text-[10px] text-neutral-500">в работе</span>
</span>
</>
)}
{tc.confirmed > 0 && (
<>
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:opacity-80 transition-opacity"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(statusFilter === "confirmed" ? "all" : "confirmed"); }}>
<span className="text-sm font-medium text-emerald-400">{tc.confirmed}</span>
<span className="text-[10px] text-neutral-500">подтв.</span>
</span>
</>
)}
{tc.declined > 0 && (
<>
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:opacity-80 transition-opacity"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(statusFilter === "declined" ? "all" : "declined"); }}>
<span className="text-sm font-medium text-red-400">{tc.declined}</span>
<span className="text-[10px] text-neutral-500">отказ</span>
</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 [refreshKey, setRefreshKey] = useState(0);
const [dashboardKey, setDashboardKey] = useState(0);
const refreshDashboard = useCallback(() => setDashboardKey((k) => k + 1), []);
const lastTotalRef = useRef<number | null>(null);
const { showError } = useToast();
// Poll for new bookings, auto-refresh silently
useEffect(() => {
const 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) {
setRefreshKey((k) => k + 1);
}
lastTotalRef.current = data.total;
})
.catch(() => {});
}, 10000);
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>
{/* Search */}
<div className="mt-3">
<SearchBar
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 key={`dash-${dashboardKey}-${refreshKey}`} statusFilter={statusFilter} onNavigate={setTab} onFilter={setStatusFilter} />
{/* 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 */}
<div className="mt-4" key={`tab-${refreshKey}`}>
{tab === "reminders" && <RemindersTab />}
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
</div>
</>
)}
<AddBookingModal
open={addOpen}
onClose={() => setAddOpen(false)}
onAdded={() => setRefreshKey((k) => k + 1)}
/>
</div>
);
}
export default function BookingsPage() {
return (
<ToastProvider>
<BookingsPageInner />
</ToastProvider>
);
}