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 [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

View File

@@ -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}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить"
>
<Trash2 size={14} />
</button>
<>
<button
type="button"
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
)}
</>
);
}

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 { 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, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id, status }),
});
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, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-notes", id, notes }),
});
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">

View File

@@ -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"

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 { 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", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ table: item.table, id: item.id, status }),
});
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(() => {
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);
let id: ReturnType<typeof setInterval>;
function startPolling() {
id = setInterval(() => {
if (document.hidden) return;
adminFetch("/api/admin/unread-counts")
.then((r) => r.json())
.then((data: { total: number }) => {
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
// #6: Show banner instead of remounting with key
setNewBookingsBanner(true);
}
lastTotalRef.current = data.total;
})
.catch(() => {});
}, 10000);
}
startPolling();
return () => clearInterval(id);
}, []);
// #5: Search result status change
async function handleSearchStatus(result: SearchResult, status: BookingStatus) {
const endpoint = ENDPOINT_MAP[result.type];
if (!endpoint) return;
const prevStatus = result.status;
setSearchResults((prev) => prev?.map((r) => r.id === result.id && r.type === result.type ? { ...r, status } : r) ?? null);
try {
const res = await adminFetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id: result.id, status }),
});
if (!res.ok) throw new Error();
} catch {
setSearchResults((prev) => prev?.map((r) => r.id === result.id && r.type === result.type ? { ...r, status: prevStatus } : r) ?? null);
showError("Не удалось обновить статус");
}
}
// #5: Search result delete
async function handleSearchDelete(result: SearchResult) {
const endpoint = ENDPOINT_MAP[result.type];
if (!endpoint) return;
try {
const res = await adminFetch(`${endpoint}?id=${result.id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
setSearchResults((prev) => prev?.filter((r) => !(r.id === result.id && r.type === result.type)) ?? null);
} catch {
showError("Не удалось удалить запись");
}
}
const TYPE_LABELS: Record<string, string> = { class: "Занятие", mc: "Мастер-класс", "open-day": "Open Day" };
return (
@@ -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>
<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} />
{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>
);
}

View File

@@ -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);