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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user