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:
@@ -25,6 +25,8 @@ export function AddBookingModal({
|
||||
const [eventType, setEventType] = useState<EventType>("master-class");
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [instagram, setInstagram] = useState("");
|
||||
const [telegram, setTelegram] = useState("");
|
||||
const [mcTitle, setMcTitle] = useState("");
|
||||
const [mcOptions, setMcOptions] = useState<McOption[]>([]);
|
||||
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
|
||||
@@ -34,7 +36,7 @@ export function AddBookingModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(""); setPhone(""); setMcTitle(""); setOdClassId("");
|
||||
setName(""); setPhone(""); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
|
||||
|
||||
// Fetch upcoming MCs (filter out expired)
|
||||
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", {
|
||||
method: "POST",
|
||||
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") {
|
||||
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="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>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"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";
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onClick={() => setConfirming(true)}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingG
|
||||
import { FilterTabs, EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
|
||||
import { fmtDate } from "./types";
|
||||
import { InlineNotes } from "./InlineNotes";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface GenericBookingsListProps<T extends BaseBooking> {
|
||||
items: T[];
|
||||
@@ -28,6 +29,7 @@ export function GenericBookingsList<T extends BaseBooking>({
|
||||
const [filter, setFilter] = useState<BookingFilter>("all");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const { showError } = useToast();
|
||||
|
||||
const counts = useMemo(() => countStatuses(items), [items]);
|
||||
|
||||
@@ -36,26 +38,47 @@ export function GenericBookingsList<T extends BaseBooking>({
|
||||
onConfirm(id);
|
||||
return;
|
||||
}
|
||||
onItemsChange((prev) => prev.map((b) => b.id === id ? { ...b, status } : b));
|
||||
await adminFetch(endpoint, {
|
||||
const prev = items.find((b) => b.id === id);
|
||||
const prevStatus = prev?.status;
|
||||
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status } : b));
|
||||
try {
|
||||
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) {
|
||||
await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" });
|
||||
onItemsChange((prev) => prev.filter((b) => b.id !== id));
|
||||
try {
|
||||
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) {
|
||||
onItemsChange((prev) => prev.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b));
|
||||
await adminFetch(endpoint, {
|
||||
const prev = items.find((b) => b.id === id);
|
||||
const prevNotes = prev?.notes;
|
||||
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b));
|
||||
try {
|
||||
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) {
|
||||
@@ -69,7 +92,7 @@ export function GenericBookingsList<T extends BaseBooking>({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<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 className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
|
||||
@@ -58,7 +58,7 @@ export function InlineNotes({ value, onSave }: { value: string; onSave: (notes:
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
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="Заметка..."
|
||||
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"
|
||||
|
||||
68
src/app/admin/bookings/Toast.tsx
Normal file
68
src/app/admin/bookings/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { adminFetch } from "@/lib/csrf";
|
||||
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 { AddBookingModal } from "./AddBookingModal";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { McRegistrationsTab } from "./McRegistrationsTab";
|
||||
import { OpenDayBookingsTab } from "./OpenDayBookingsTab";
|
||||
import { ToastProvider, useToast } from "./Toast";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -27,6 +28,7 @@ interface GroupBooking {
|
||||
confirmedDate?: string;
|
||||
confirmedGroup?: string;
|
||||
confirmedComment?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -86,13 +88,6 @@ function ConfirmModal({
|
||||
setHall(""); setTrainer(""); setGroup("");
|
||||
}, [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
|
||||
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
|
||||
|
||||
@@ -136,6 +131,25 @@ function ConfirmModal({
|
||||
// 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];
|
||||
@@ -199,13 +213,8 @@ function ConfirmModal({
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (group && date) {
|
||||
const groupLabel = groups.find((g) => g.value === group)?.label || group;
|
||||
onConfirm({ group: groupLabel, date, comment: comment.trim() || undefined });
|
||||
}
|
||||
}}
|
||||
disabled={!group || !date}
|
||||
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"
|
||||
>
|
||||
Подтвердить
|
||||
@@ -226,6 +235,7 @@ function GroupBookingsTab() {
|
||||
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([
|
||||
@@ -245,7 +255,7 @@ function GroupBookingsTab() {
|
||||
}
|
||||
setAllClasses(classes);
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -277,6 +287,7 @@ function GroupBookingsTab() {
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <p className="text-sm text-red-400 py-8 text-center">Не удалось загрузить данные</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -342,25 +353,40 @@ const TYPE_CONFIG = {
|
||||
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(() => {})
|
||||
.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));
|
||||
await adminFetch("/api/admin/reminders", {
|
||||
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];
|
||||
@@ -407,6 +433,7 @@ function RemindersTab() {
|
||||
|
||||
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}`}
|
||||
@@ -435,7 +462,7 @@ function RemindersTab() {
|
||||
<Send size={10} />{item.telegram}
|
||||
</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) => {
|
||||
const conf = STATUS_CONFIG[st];
|
||||
const Icon = conf.icon;
|
||||
@@ -444,6 +471,7 @@ function RemindersTab() {
|
||||
<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}`
|
||||
@@ -561,7 +589,7 @@ function DashboardSummary({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
|
||||
remindersToday: rem.filter((r) => r.eventDate === today).length,
|
||||
remindersTomorrow: rem.filter((r) => r.eventDate === tomorrow).length,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}).catch(() => {}); // Dashboard is non-critical, silent fail OK
|
||||
}, []);
|
||||
|
||||
if (!counts) return null;
|
||||
@@ -644,29 +672,74 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
{ 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 [addOpen, setAddOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [newBookingsBanner, setNewBookingsBanner] = useState(false);
|
||||
const lastTotalRef = useRef<number | null>(null);
|
||||
const { showError } = useToast();
|
||||
|
||||
// Poll for new bookings every 10s
|
||||
// #10: Pause polling when browser tab not visible
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
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) {
|
||||
setRefreshKey((k) => k + 1);
|
||||
// #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 (
|
||||
@@ -682,6 +755,16 @@ export default function BookingsPage() {
|
||||
</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
|
||||
@@ -691,7 +774,7 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
|
||||
{searchResults ? (
|
||||
/* Search results */
|
||||
/* #5: Actionable search results */
|
||||
<div className="mt-4 space-y-2">
|
||||
{searchResults.length === 0 ? (
|
||||
<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} />
|
||||
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
|
||||
</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 className="flex items-center gap-2 mt-2">
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<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>
|
||||
{r.notes && <p className="mt-1.5 text-[10px] text-neutral-500 truncate">{r.notes}</p>}
|
||||
</BookingCard>
|
||||
))
|
||||
)}
|
||||
@@ -740,8 +827,8 @@ export default function BookingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="mt-4" key={refreshKey}>
|
||||
{/* Tab content — no key={refreshKey}, banner handles new data */}
|
||||
<div className="mt-4">
|
||||
{tab === "reminders" && <RemindersTab />}
|
||||
{tab === "classes" && <GroupBookingsTab />}
|
||||
{tab === "master-classes" && <McRegistrationsTab />}
|
||||
@@ -753,8 +840,16 @@ export default function BookingsPage() {
|
||||
<AddBookingModal
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onAdded={() => setRefreshKey((k) => k + 1)}
|
||||
onAdded={() => setNewBookingsBanner(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookingsPage() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<BookingsPageInner />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ export async function PUT(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, phone } = body;
|
||||
const { name, phone, instagram, telegram } = body;
|
||||
if (!name?.trim() || !phone?.trim()) {
|
||||
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 });
|
||||
} catch (err) {
|
||||
console.error("[admin/group-bookings] POST error:", err);
|
||||
|
||||
Reference in New Issue
Block a user