Files
blackheart-website/src/app/admin/bookings/GenericBookingsList.tsx
diana.dolgolyova aa0cfe35c3 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
2026-03-24 15:54:22 +03:00

202 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useMemo } from "react";
import { ChevronDown, ChevronRight, Archive } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, countStatuses, sortByStatus } from "./types";
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[];
endpoint: string;
onItemsChange: (fn: (prev: T[]) => T[]) => void;
groups?: BookingGroup<T>[];
renderExtra?: (item: T) => React.ReactNode;
onConfirm?: (id: number) => void;
}
export function GenericBookingsList<T extends BaseBooking>({
items,
endpoint,
onItemsChange,
groups,
renderExtra,
onConfirm,
}: GenericBookingsListProps<T>) {
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]);
async function handleStatus(id: number, status: BookingStatus) {
if (status === "confirmed" && onConfirm) {
onConfirm(id);
return;
}
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) {
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) {
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) {
return (
<BookingCard key={item.id} status={item.status}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-white">{item.name}</span>
<ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
{renderExtra?.(item)}
</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)} name={item.name} />
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<StatusBadge status={item.status} />
{!isArchived && <StatusActions status={item.status} onStatus={(s) => handleStatus(item.id, s)} />}
</div>
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
</BookingCard>
);
}
if (groups) {
const filteredGroups = groups.map((g) => ({
...g,
items: filter === "all" ? sortByStatus(g.items) : sortByStatus(g.items.filter((b) => b.status === filter)),
})).filter((g) => g.items.length > 0);
const activeGroups = filteredGroups.filter((g) => !g.isArchived);
const archivedGroups = filteredGroups.filter((g) => g.isArchived);
const archivedCount = archivedGroups.reduce((sum, g) => sum + g.items.length, 0);
function renderGroup(group: BookingGroup<T>) {
const isOpen = expanded[group.key] ?? !group.isArchived;
const groupCounts = countStatuses(group.items);
return (
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}>
<button
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))}
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-900/50 hover:bg-neutral-800/50" : "bg-neutral-900 hover:bg-neutral-800/80"}`}
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
{group.sublabel && (
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span>
)}
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-400" : "text-white"}`}>{group.label}</span>
{group.dateBadge && (
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-gold bg-gold/10"
}`}>
{group.dateBadge}
</span>
)}
{group.isArchived && (
<span className="text-[10px] text-neutral-600 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">архив</span>
)}
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
{!group.isArchived && (
<div className="flex gap-2 ml-auto text-[10px]">
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>}
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>}
</div>
)}
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-2">
{group.items.map((item) => renderItem(item, group.isArchived))}
</div>
)}
</div>
);
}
return (
<div>
<FilterTabs filter={filter} counts={counts} total={items.length} onFilter={setFilter} />
<div className="mt-3 space-y-2">
{activeGroups.length === 0 && archivedGroups.length === 0 && <EmptyState total={items.length} />}
{activeGroups.map(renderGroup)}
</div>
{archivedCount > 0 && (
<div className="mt-4">
<button
onClick={() => setShowArchived((v) => !v)}
className="flex items-center gap-2 text-xs text-neutral-500 hover:text-neutral-300 transition-colors"
>
<Archive size={13} />
{showArchived ? "Скрыть архив" : `Архив (${archivedCount} записей)`}
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{showArchived && (
<div className="mt-2 space-y-2">
{archivedGroups.map(renderGroup)}
</div>
)}
</div>
)}
</div>
);
}
const filtered = useMemo(() => {
const list = filter === "all" ? items : items.filter((b) => b.status === filter);
return sortByStatus(list);
}, [items, filter]);
return (
<div>
<FilterTabs filter={filter} counts={counts} total={items.length} onFilter={setFilter} />
<div className="mt-3 space-y-2">
{filtered.length === 0 && <EmptyState total={items.length} />}
{filtered.map((item) => renderItem(item, false))}
</div>
</div>
);
}