feat: booking panel upgrade — refactor, notes, search, manual add, polling
Phase 1 — Refactor: - Split monolith _shared.tsx into types.ts, BookingComponents, InlineNotes, GenericBookingsList, AddBookingModal, SearchBar (no more _ prefix) - All 3 tabs use GenericBookingsList — shared status workflow, filters, archive Phase 2 — Features: - DB migration 13: add notes column to all booking tables - Inline notes with amber highlight, auto-save 800ms debounce - Confirm modal comment saves to notes field - Manual add: 2 tabs (Занятие / Мероприятие), filters expired MCs, Open Day support - Search bar: cross-table search by name/phone - 10s polling for real-time updates (bookings page + sidebar badge) - Status change marks booking as seen (fixes unread count on reset) - Confirm modal stores human-readable group label instead of raw groupId - Confirmed group bookings appear in Reminders tab Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
178
src/app/admin/bookings/GenericBookingsList.tsx
Normal file
178
src/app/admin/bookings/GenericBookingsList.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"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";
|
||||
|
||||
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 counts = useMemo(() => countStatuses(items), [items]);
|
||||
|
||||
async function handleStatus(id: number, status: BookingStatus) {
|
||||
if (status === "confirmed" && onConfirm) {
|
||||
onConfirm(id);
|
||||
return;
|
||||
}
|
||||
onItemsChange((prev) => prev.map((b) => b.id === id ? { ...b, status } : b));
|
||||
await adminFetch(endpoint, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id, status }),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" });
|
||||
onItemsChange((prev) => prev.filter((b) => b.id !== id));
|
||||
}
|
||||
|
||||
async function handleNotes(id: number, notes: string) {
|
||||
onItemsChange((prev) => prev.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b));
|
||||
await adminFetch(endpoint, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-notes", id, notes }),
|
||||
});
|
||||
}
|
||||
|
||||
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)} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user