- #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
202 lines
8.5 KiB
TypeScript
202 lines
8.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|