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

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