fix: comprehensive bookings admin UX improvements

- #1 Delete confirmation dialog before removing bookings
- #2 Error toasts instead of silent .catch(() => {})
- #3 Optimistic rollback — UI reverts on API failure
- #4 Loading indicator on reminder status buttons
- #5 Search results are now actionable (status change + delete)
- #6 New bookings banner instead of full tab remount
- #7 Error states for failed data loads
- #8 InlineNotes only saves on blur when value changed
- #9 AddBookingModal supports Instagram/Telegram fields
- #10 Polling pauses when browser tab is hidden
- #11 Enter key submits ConfirmModal
This commit is contained in:
2026-03-24 15:54:22 +03:00
parent 669c4a3023
commit aa0cfe35c3
7 changed files with 315 additions and 75 deletions

View File

@@ -25,6 +25,8 @@ export function AddBookingModal({
const [eventType, setEventType] = useState<EventType>("master-class"); const [eventType, setEventType] = useState<EventType>("master-class");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [instagram, setInstagram] = useState("");
const [telegram, setTelegram] = useState("");
const [mcTitle, setMcTitle] = useState(""); const [mcTitle, setMcTitle] = useState("");
const [mcOptions, setMcOptions] = useState<McOption[]>([]); const [mcOptions, setMcOptions] = useState<McOption[]>([]);
const [odClasses, setOdClasses] = useState<OdClass[]>([]); const [odClasses, setOdClasses] = useState<OdClass[]>([]);
@@ -34,7 +36,7 @@ export function AddBookingModal({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setName(""); setPhone(""); setMcTitle(""); setOdClassId(""); setName(""); setPhone(""); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
// Fetch upcoming MCs (filter out expired) // Fetch upcoming MCs (filter out expired)
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => { adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => {
@@ -80,7 +82,12 @@ export function AddBookingModal({
await adminFetch("/api/admin/group-bookings", { await adminFetch("/api/admin/group-bookings", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), phone: phone.trim() }), body: JSON.stringify({
name: name.trim(),
phone: phone.trim(),
...(instagram.trim() && { instagram: instagram.trim() }),
...(telegram.trim() && { telegram: telegram.trim() }),
}),
}); });
} else if (eventType === "master-class") { } else if (eventType === "master-class") {
const title = mcTitle || mcOptions[0]?.title || "Мастер-класс"; const title = mcTitle || mcOptions[0]?.title || "Мастер-класс";
@@ -193,6 +200,10 @@ export function AddBookingModal({
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} /> <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Телефон" className={inputClass} /> <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Телефон" className={inputClass} />
<div className="flex gap-2">
<input type="text" value={instagram} onChange={(e) => setInstagram(e.target.value)} placeholder="Instagram" className={inputClass} />
<input type="text" value={telegram} onChange={(e) => setTelegram(e.target.value)} placeholder="Telegram" className={inputClass} />
</div>
</div> </div>
<button <button

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { Loader2, Trash2, Phone, Instagram, Send } from "lucide-react"; import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Loader2, Trash2, Phone, Instagram, Send, X } from "lucide-react";
import { type BookingStatus, type BookingFilter, BOOKING_STATUSES } from "./types"; import { type BookingStatus, type BookingFilter, BOOKING_STATUSES } from "./types";
export function LoadingSpinner() { export function LoadingSpinner() {
@@ -20,16 +22,57 @@ export function EmptyState({ total }: { total: number }) {
); );
} }
export function DeleteBtn({ onClick }: { onClick: () => void }) { // --- #1: Delete with confirmation ---
export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: string }) {
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (!confirming) return;
function onKey(e: KeyboardEvent) { if (e.key === "Escape") setConfirming(false); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [confirming]);
return ( return (
<button <>
type="button" <button
onClick={onClick} type="button"
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors" onClick={() => setConfirming(true)}
title="Удалить" className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
> title="Удалить"
<Trash2 size={14} /> >
</button> <Trash2 size={14} />
</button>
{confirming && createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setConfirming(false)} 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-sm font-bold text-white">Удалить запись?</h3>
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>}
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => setConfirming(false)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors"
>
Отмена
</button>
<button
onClick={() => { setConfirming(false); onClick(); }}
className="flex-1 rounded-lg bg-red-600 py-2 text-xs font-medium text-white hover:bg-red-500 transition-colors"
>
Удалить
</button>
</div>
</div>
</div>,
document.body
)}
</>
); );
} }

View File

@@ -7,6 +7,7 @@ import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingG
import { FilterTabs, EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents"; import { FilterTabs, EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
import { fmtDate } from "./types"; import { fmtDate } from "./types";
import { InlineNotes } from "./InlineNotes"; import { InlineNotes } from "./InlineNotes";
import { useToast } from "./Toast";
interface GenericBookingsListProps<T extends BaseBooking> { interface GenericBookingsListProps<T extends BaseBooking> {
items: T[]; items: T[];
@@ -28,6 +29,7 @@ export function GenericBookingsList<T extends BaseBooking>({
const [filter, setFilter] = useState<BookingFilter>("all"); const [filter, setFilter] = useState<BookingFilter>("all");
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const { showError } = useToast();
const counts = useMemo(() => countStatuses(items), [items]); const counts = useMemo(() => countStatuses(items), [items]);
@@ -36,26 +38,47 @@ export function GenericBookingsList<T extends BaseBooking>({
onConfirm(id); onConfirm(id);
return; return;
} }
onItemsChange((prev) => prev.map((b) => b.id === id ? { ...b, status } : b)); const prev = items.find((b) => b.id === id);
await adminFetch(endpoint, { const prevStatus = prev?.status;
method: "PUT", onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status } : b));
headers: { "Content-Type": "application/json" }, try {
body: JSON.stringify({ action: "set-status", id, status }), const res = await adminFetch(endpoint, {
}); method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id, status }),
});
if (!res.ok) throw new Error();
} catch {
if (prevStatus) onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status: prevStatus } : b));
showError("Не удалось обновить статус");
}
} }
async function handleDelete(id: number) { async function handleDelete(id: number) {
await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" }); try {
onItemsChange((prev) => prev.filter((b) => b.id !== id)); const res = await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
onItemsChange((list) => list.filter((b) => b.id !== id));
} catch {
showError("Не удалось удалить запись");
}
} }
async function handleNotes(id: number, notes: string) { async function handleNotes(id: number, notes: string) {
onItemsChange((prev) => prev.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b)); const prev = items.find((b) => b.id === id);
await adminFetch(endpoint, { const prevNotes = prev?.notes;
method: "PUT", onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b));
headers: { "Content-Type": "application/json" }, try {
body: JSON.stringify({ action: "set-notes", id, notes }), const res = await adminFetch(endpoint, {
}); method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-notes", id, notes }),
});
if (!res.ok) throw new Error();
} catch {
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: prevNotes } : b));
showError("Не удалось сохранить заметку");
}
} }
function renderItem(item: T, isArchived: boolean) { function renderItem(item: T, isArchived: boolean) {
@@ -69,7 +92,7 @@ export function GenericBookingsList<T extends BaseBooking>({
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(item.createdAt)}</span> <span className="text-neutral-600 text-xs">{fmtDate(item.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(item.id)} /> <DeleteBtn onClick={() => handleDelete(item.id)} name={item.name} />
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-2 flex-wrap"> <div className="flex items-center gap-2 mt-2 flex-wrap">

View File

@@ -58,7 +58,7 @@ export function InlineNotes({ value, onSave }: { value: string; onSave: (notes:
ref={textareaRef} ref={textareaRef}
value={text} value={text}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onBlur={() => { if (!text.trim()) { onSave(""); } setEditing(false); }} onBlur={() => { clearTimeout(timerRef.current); if (text !== value) onSave(text.trim() ? text : ""); setEditing(false); }}
placeholder="Заметка..." placeholder="Заметка..."
rows={2} rows={2}
className="w-full rounded-md border border-amber-500/20 bg-amber-500/[0.06] px-2.5 py-1.5 text-[11px] text-amber-200/80 placeholder-neutral-600 outline-none focus:border-amber-500/40 resize-none" className="w-full rounded-md border border-amber-500/20 bg-amber-500/[0.06] px-2.5 py-1.5 text-[11px] text-amber-200/80 placeholder-neutral-600 outline-none focus:border-amber-500/40 resize-none"

View File

@@ -0,0 +1,68 @@
"use client";
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { X, AlertCircle, CheckCircle2 } from "lucide-react";
interface ToastItem {
id: number;
message: string;
type: "error" | "success";
}
interface ToastContextValue {
showError: (message: string) => void;
showSuccess: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue>({
showError: () => {},
showSuccess: () => {},
});
export function useToast() {
return useContext(ToastContext);
}
let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const addToast = useCallback((message: string, type: "error" | "success") => {
const id = ++nextId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
}, []);
const showError = useCallback((message: string) => addToast(message, "error"), [addToast]);
const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]);
return (
<ToastContext.Provider value={{ showError, showSuccess }}>
{children}
{toasts.length > 0 && (
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
{toasts.map((t) => (
<div
key={t.id}
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm shadow-lg animate-in slide-in-from-right ${
t.type === "error"
? "bg-red-950/90 border-red-500/30 text-red-200"
: "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
}`}
>
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
<span className="flex-1">{t.message}</span>
<button
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
className="shrink-0 text-neutral-400 hover:text-white"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</ToastContext.Provider>
);
}

View File

@@ -5,12 +5,13 @@ import { createPortal } from "react-dom";
import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react"; import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type SearchResult, BOOKING_STATUSES, SHORT_DAYS, fmtDate } from "./types"; import { type BookingStatus, type SearchResult, BOOKING_STATUSES, SHORT_DAYS, fmtDate } from "./types";
import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, DeleteBtn } from "./BookingComponents"; import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList"; import { GenericBookingsList } from "./GenericBookingsList";
import { AddBookingModal } from "./AddBookingModal"; import { AddBookingModal } from "./AddBookingModal";
import { SearchBar } from "./SearchBar"; import { SearchBar } from "./SearchBar";
import { McRegistrationsTab } from "./McRegistrationsTab"; import { McRegistrationsTab } from "./McRegistrationsTab";
import { OpenDayBookingsTab } from "./OpenDayBookingsTab"; import { OpenDayBookingsTab } from "./OpenDayBookingsTab";
import { ToastProvider, useToast } from "./Toast";
// --- Types --- // --- Types ---
@@ -27,6 +28,7 @@ interface GroupBooking {
confirmedDate?: string; confirmedDate?: string;
confirmedGroup?: string; confirmedGroup?: string;
confirmedComment?: string; confirmedComment?: string;
notes?: string;
createdAt: string; createdAt: string;
} }
@@ -86,13 +88,6 @@ function ConfirmModal({
setHall(""); setTrainer(""); setGroup(""); setHall(""); setTrainer(""); setGroup("");
}, [open, groupInfo, allClasses]); }, [open, groupInfo, allClasses]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Cascading options // Cascading options
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]); const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
@@ -136,6 +131,25 @@ function ConfirmModal({
// Reset init flag when modal closes // Reset init flag when modal closes
useEffect(() => { if (!open) initRef.current = false; }, [open]); 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; if (!open) return null;
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
@@ -199,13 +213,8 @@ function ConfirmModal({
</div> </div>
<button <button
onClick={() => { onClick={handleSubmit}
if (group && date) { disabled={!canSubmit}
const groupLabel = groups.find((g) => g.value === group)?.label || group;
onConfirm({ group: groupLabel, date, comment: comment.trim() || undefined });
}
}}
disabled={!group || !date}
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" 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"
> >
Подтвердить Подтвердить
@@ -226,6 +235,7 @@ function GroupBookingsTab() {
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]); const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [confirmingId, setConfirmingId] = useState<number | null>(null); const [confirmingId, setConfirmingId] = useState<number | null>(null);
const [error, setError] = useState(false);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -245,7 +255,7 @@ function GroupBookingsTab() {
} }
setAllClasses(classes); setAllClasses(classes);
}) })
.catch(() => {}) .catch(() => setError(true))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -277,6 +287,7 @@ function GroupBookingsTab() {
} }
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;
if (error) return <p className="text-sm text-red-400 py-8 text-center">Не удалось загрузить данные</p>;
return ( return (
<> <>
@@ -342,25 +353,40 @@ const TYPE_CONFIG = {
function RemindersTab() { function RemindersTab() {
const [items, setItems] = useState<ReminderItem[]>([]); const [items, setItems] = useState<ReminderItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
const { showError } = useToast();
useEffect(() => { useEffect(() => {
adminFetch("/api/admin/reminders") adminFetch("/api/admin/reminders")
.then((r) => r.json()) .then((r) => r.json())
.then((data: ReminderItem[]) => setItems(data)) .then((data: ReminderItem[]) => setItems(data))
.catch(() => {}) .catch(() => setError(true))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
async function setStatus(item: ReminderItem, status: ReminderStatus | null) { 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)); setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: status ?? undefined } : i));
await adminFetch("/api/admin/reminders", { try {
method: "PUT", const res = await adminFetch("/api/admin/reminders", {
headers: { "Content-Type": "application/json" }, method: "PUT",
body: JSON.stringify({ table: item.table, id: item.id, status }), 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 (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 today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split("T")[0]; const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split("T")[0];
@@ -407,6 +433,7 @@ function RemindersTab() {
function renderPerson(item: ReminderItem) { function renderPerson(item: ReminderItem) {
const currentStatus = item.reminderStatus as ReminderStatus | undefined; const currentStatus = item.reminderStatus as ReminderStatus | undefined;
const isSaving = savingIds.has(`${item.table}-${item.id}`);
return ( return (
<div <div
key={`${item.table}-${item.id}`} key={`${item.table}-${item.id}`}
@@ -435,7 +462,7 @@ function RemindersTab() {
<Send size={10} />{item.telegram} <Send size={10} />{item.telegram}
</a> </a>
)} )}
<div className="flex gap-1 ml-auto"> <div className={`flex gap-1 ml-auto ${isSaving ? "opacity-50 pointer-events-none" : ""}`}>
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => { {(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
const conf = STATUS_CONFIG[st]; const conf = STATUS_CONFIG[st];
const Icon = conf.icon; const Icon = conf.icon;
@@ -444,6 +471,7 @@ function RemindersTab() {
<button <button
key={st} key={st}
onClick={() => setStatus(item, active ? null : 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 ${ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
active active
? `${conf.bg} ${conf.color} border ${conf.border}` ? `${conf.bg} ${conf.color} border ${conf.border}`
@@ -561,7 +589,7 @@ function DashboardSummary({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
remindersToday: rem.filter((r) => r.eventDate === today).length, remindersToday: rem.filter((r) => r.eventDate === today).length,
remindersTomorrow: rem.filter((r) => r.eventDate === tomorrow).length, remindersTomorrow: rem.filter((r) => r.eventDate === tomorrow).length,
}); });
}).catch(() => {}); }).catch(() => {}); // Dashboard is non-critical, silent fail OK
}, []); }, []);
if (!counts) return null; if (!counts) return null;
@@ -644,29 +672,74 @@ const TABS: { key: Tab; label: string }[] = [
{ key: "open-day", label: "День открытых дверей" }, { key: "open-day", label: "День открытых дверей" },
]; ];
export default function BookingsPage() { 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 [tab, setTab] = useState<Tab>("reminders");
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null); const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [refreshKey, setRefreshKey] = useState(0); const [newBookingsBanner, setNewBookingsBanner] = useState(false);
const lastTotalRef = useRef<number | null>(null); const lastTotalRef = useRef<number | null>(null);
const { showError } = useToast();
// Poll for new bookings every 10s // #10: Pause polling when browser tab not visible
useEffect(() => { useEffect(() => {
const id = setInterval(() => { let id: ReturnType<typeof setInterval>;
adminFetch("/api/admin/unread-counts") function startPolling() {
.then((r) => r.json()) id = setInterval(() => {
.then((data: { total: number }) => { if (document.hidden) return;
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) { adminFetch("/api/admin/unread-counts")
setRefreshKey((k) => k + 1); .then((r) => r.json())
} .then((data: { total: number }) => {
lastTotalRef.current = data.total; if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
}) // #6: Show banner instead of remounting with key
.catch(() => {}); setNewBookingsBanner(true);
}, 10000); }
lastTotalRef.current = data.total;
})
.catch(() => {});
}, 10000);
}
startPolling();
return () => clearInterval(id); 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" }; const TYPE_LABELS: Record<string, string> = { class: "Занятие", mc: "Мастер-класс", "open-day": "Open Day" };
return ( return (
@@ -682,6 +755,16 @@ export default function BookingsPage() {
</button> </button>
</div> </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 */} {/* Search */}
<div className="mt-3"> <div className="mt-3">
<SearchBar <SearchBar
@@ -691,7 +774,7 @@ export default function BookingsPage() {
</div> </div>
{searchResults ? ( {searchResults ? (
/* Search results */ /* #5: Actionable search results */
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{searchResults.length === 0 ? ( {searchResults.length === 0 ? (
<p className="text-sm text-neutral-500 py-8 text-center">Ничего не найдено</p> <p className="text-sm text-neutral-500 py-8 text-center">Ничего не найдено</p>
@@ -705,12 +788,16 @@ export default function BookingsPage() {
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} /> <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>} {r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
</div> </div>
<span className="text-neutral-600 text-xs shrink-0">{fmtDate(r.createdAt)}</span> <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>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2 flex-wrap">
<StatusBadge status={r.status as BookingStatus} /> <StatusBadge status={r.status as BookingStatus} />
{r.notes && <span className="text-[10px] text-neutral-500 truncate">{r.notes}</span>} <StatusActions status={r.status as BookingStatus} onStatus={(s) => handleSearchStatus(r, s)} />
</div> </div>
{r.notes && <p className="mt-1.5 text-[10px] text-neutral-500 truncate">{r.notes}</p>}
</BookingCard> </BookingCard>
)) ))
)} )}
@@ -740,8 +827,8 @@ export default function BookingsPage() {
))} ))}
</div> </div>
{/* Tab content */} {/* Tab content — no key={refreshKey}, banner handles new data */}
<div className="mt-4" key={refreshKey}> <div className="mt-4">
{tab === "reminders" && <RemindersTab />} {tab === "reminders" && <RemindersTab />}
{tab === "classes" && <GroupBookingsTab />} {tab === "classes" && <GroupBookingsTab />}
{tab === "master-classes" && <McRegistrationsTab />} {tab === "master-classes" && <McRegistrationsTab />}
@@ -753,8 +840,16 @@ export default function BookingsPage() {
<AddBookingModal <AddBookingModal
open={addOpen} open={addOpen}
onClose={() => setAddOpen(false)} onClose={() => setAddOpen(false)}
onAdded={() => setRefreshKey((k) => k + 1)} onAdded={() => setNewBookingsBanner(true)}
/> />
</div> </div>
); );
} }
export default function BookingsPage() {
return (
<ToastProvider>
<BookingsPageInner />
</ToastProvider>
);
}

View File

@@ -48,11 +48,11 @@ export async function PUT(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { name, phone } = body; const { name, phone, instagram, telegram } = body;
if (!name?.trim() || !phone?.trim()) { if (!name?.trim() || !phone?.trim()) {
return NextResponse.json({ error: "name and phone are required" }, { status: 400 }); return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
} }
const id = addGroupBooking(name.trim(), phone.trim()); const id = addGroupBooking(name.trim(), phone.trim(), undefined, instagram?.trim() || undefined, telegram?.trim() || undefined);
return NextResponse.json({ ok: true, id }); return NextResponse.json({ ok: true, id });
} catch (err) { } catch (err) {
console.error("[admin/group-bookings] POST error:", err); console.error("[admin/group-bookings] POST error:", err);