Files
blackheart-website/src/app/admin/bookings/GenericBookingsList.tsx
diana.dolgolyova 49d710b2e7 refactor: move status filter from per-tab pills to global search bar
- Remove FilterTabs from inside each booking tab
- Add compact status chips (Все/Новая/Связались/Подтверждено/Отказ)
  below the search bar — one global filter for all tabs
- Filter chips hidden during active text search
- Status filter toggles on click (click again to deselect)
- GenericBookingsList accepts filter as prop instead of managing internally
2026-03-24 17:15:47 +03:00

206 lines
8.6 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, sortByStatus } from "./types";
import { 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;
filter: BookingFilter;
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,
filter,
onItemsChange,
groups,
renderExtra,
onConfirm,
}: GenericBookingsListProps<T>) {
const [showArchived, setShowArchived] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const { showError } = useToast();
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 truncate max-w-[200px]">{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);
const allArchived = activeGroups.length === 0 && archivedCount > 0 && filter === "all";
function renderGroup(group: BookingGroup<T>) {
const isOpen = expanded[group.key] ?? !group.isArchived;
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
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>
{allArchived && (
<p className="text-sm text-neutral-500 py-4">Все записи в архиве</p>
)}
{!allArchived && (
<div className="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 || allArchived) ? "Скрыть архив" : `Архив (${archivedCount} записей)`}
{(showArchived || allArchived) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{(showArchived || allArchived) && (
<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>
<div className="space-y-2">
{filtered.length === 0 && <EmptyState total={items.length} />}
{filtered.map((item) => renderItem(item, false))}
</div>
</div>
);
}