16ac56f62e
- Team carousel: simple swipe on mobile instead of drag - Schedule: filter button inline with hall tabs, larger on mobile - Schedule filters: fix nested button hydration error - Admin bookings: select dropdown on mobile, filter highlight on dashboard cards - Admin bookings: searchable dropdowns in add booking modal with class selector - Admin bookings: waiting list divider inside groups - Admin bookings: new bookings appear without page reload - Admin open-day: action buttons visible on mobile, confirm dialog, click-outside to close edit - API: pass groupInfo on group booking creation - SignupModal: Instagram link on success popup
245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useRef, useEffect, useCallback } 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;
|
|
onDataChange?: () => void;
|
|
groups?: BookingGroup<T>[];
|
|
renderExtra?: (item: T) => React.ReactNode;
|
|
onConfirm?: (id: number) => void;
|
|
}
|
|
|
|
export function GenericBookingsList<T extends BaseBooking>({
|
|
items,
|
|
endpoint,
|
|
filter,
|
|
onItemsChange,
|
|
onDataChange,
|
|
groups,
|
|
renderExtra,
|
|
onConfirm,
|
|
}: GenericBookingsListProps<T>) {
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
const [highlightId, setHighlightId] = useState<number | null>(null);
|
|
const highlightRef = useRef<HTMLDivElement>(null);
|
|
const { showError } = useToast();
|
|
|
|
// Scroll to highlighted card and clear highlight after animation
|
|
useEffect(() => {
|
|
if (highlightId === null) return;
|
|
const timer = setTimeout(() => {
|
|
highlightRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}, 50);
|
|
const clear = setTimeout(() => setHighlightId(null), 2000);
|
|
return () => { clearTimeout(timer); clearTimeout(clear); };
|
|
}, [highlightId]);
|
|
|
|
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;
|
|
// Move changed item to front so it appears first in its status group after sort
|
|
onItemsChange((list) => {
|
|
const item = list.find((b) => b.id === id);
|
|
if (!item) return list;
|
|
return [{ ...item, status }, ...list.filter((b) => b.id !== id)];
|
|
});
|
|
setHighlightId(id);
|
|
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();
|
|
onDataChange?.();
|
|
} 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));
|
|
onDataChange?.();
|
|
} 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) {
|
|
const isHighlighted = highlightId === item.id;
|
|
return (
|
|
<div key={item.id} ref={isHighlighted ? highlightRef : undefined}>
|
|
<BookingCard status={item.status} highlight={isHighlighted}>
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 && (() => {
|
|
const regular = group.items.filter((i) => !i.notes?.includes("Лист ожидания"));
|
|
const waiting = group.items.filter((i) => i.notes?.includes("Лист ожидания"));
|
|
return (
|
|
<div className="px-4 pb-3 pt-1 space-y-2">
|
|
{regular.map((item) => renderItem(item, group.isArchived))}
|
|
{waiting.length > 0 && (
|
|
<>
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<div className="flex-1 h-px bg-amber-500/20" />
|
|
<span className="text-[10px] text-amber-400 shrink-0">лист ожидания</span>
|
|
<div className="flex-1 h-px bg-amber-500/20" />
|
|
</div>
|
|
{waiting.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>
|
|
);
|
|
}
|