From c87c63bc4f858426bc2d27fe04a3b43260981cf2 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Tue, 24 Mar 2026 13:34:16 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20booking=20panel=20upgrade=20=E2=80=94?= =?UTF-8?q?=20refactor,=20notes,=20search,=20manual=20add,=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/admin/bookings/AddBookingModal.tsx | 209 ++++++++++ .../{_shared.tsx => BookingComponents.tsx} | 60 +-- .../admin/bookings/GenericBookingsList.tsx | 178 +++++++++ src/app/admin/bookings/InlineNotes.tsx | 68 ++++ src/app/admin/bookings/McRegistrationsTab.tsx | 80 ++++ src/app/admin/bookings/OpenDayBookingsTab.tsx | 86 +++++ src/app/admin/bookings/SearchBar.tsx | 55 +++ .../admin/bookings/_McRegistrationsTab.tsx | 198 ---------- .../admin/bookings/_OpenDayBookingsTab.tsx | 202 ---------- src/app/admin/bookings/page.tsx | 361 ++++++++---------- src/app/admin/bookings/types.ts | 54 +++ src/app/admin/layout.tsx | 4 +- src/app/api/admin/bookings/search/route.ts | 64 ++++ src/app/api/admin/group-bookings/route.ts | 23 +- src/app/api/admin/mc-registrations/route.ts | 10 +- src/app/api/admin/open-day/bookings/route.ts | 7 + src/app/api/admin/unread-counts/route.ts | 2 +- src/lib/db.ts | 58 ++- 18 files changed, 1055 insertions(+), 664 deletions(-) create mode 100644 src/app/admin/bookings/AddBookingModal.tsx rename src/app/admin/bookings/{_shared.tsx => BookingComponents.tsx} (57%) create mode 100644 src/app/admin/bookings/GenericBookingsList.tsx create mode 100644 src/app/admin/bookings/InlineNotes.tsx create mode 100644 src/app/admin/bookings/McRegistrationsTab.tsx create mode 100644 src/app/admin/bookings/OpenDayBookingsTab.tsx create mode 100644 src/app/admin/bookings/SearchBar.tsx delete mode 100644 src/app/admin/bookings/_McRegistrationsTab.tsx delete mode 100644 src/app/admin/bookings/_OpenDayBookingsTab.tsx create mode 100644 src/app/admin/bookings/types.ts create mode 100644 src/app/api/admin/bookings/search/route.ts diff --git a/src/app/admin/bookings/AddBookingModal.tsx b/src/app/admin/bookings/AddBookingModal.tsx new file mode 100644 index 0000000..a6a4e0c --- /dev/null +++ b/src/app/admin/bookings/AddBookingModal.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { adminFetch } from "@/lib/csrf"; + +type Tab = "classes" | "events"; +type EventType = "master-class" | "open-day"; + +interface McOption { title: string; date: string } +interface OdClass { id: number; style: string; time: string; hall: string } +interface OdEvent { id: number; date: string; title?: string } + +export function AddBookingModal({ + open, + onClose, + onAdded, +}: { + open: boolean; + onClose: () => void; + onAdded: () => void; +}) { + const [tab, setTab] = useState("classes"); + const [eventType, setEventType] = useState("master-class"); + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [mcTitle, setMcTitle] = useState(""); + const [mcOptions, setMcOptions] = useState([]); + const [odClasses, setOdClasses] = useState([]); + const [odEventId, setOdEventId] = useState(null); + const [odClassId, setOdClassId] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + setName(""); setPhone(""); setMcTitle(""); setOdClassId(""); + + // Fetch upcoming MCs (filter out expired) + adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => { + const today = new Date().toISOString().split("T")[0]; + const upcoming = (data.items || []) + .filter((mc) => mc.slots?.some((s) => s.date >= today)) + .map((mc) => ({ + title: mc.title, + date: mc.slots.reduce((latest, s) => s.date > latest ? s.date : latest, ""), + })); + setMcOptions(upcoming); + if (upcoming.length === 0 && tab === "events") setEventType("open-day"); + }).catch(() => {}); + + // Fetch active Open Day event + classes + adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: OdEvent[]) => { + const today = new Date().toISOString().split("T")[0]; + const active = events.find((e) => e.date >= today); + if (!active) { setOdEventId(null); setOdClasses([]); return; } + setOdEventId(active.id); + const classes = await adminFetch(`/api/admin/open-day/classes?eventId=${active.id}`).then((r) => r.json()); + setOdClasses(classes); + }).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + const hasUpcomingMc = mcOptions.length > 0; + const hasOpenDay = odEventId !== null && odClasses.length > 0; + const hasEvents = hasUpcomingMc || hasOpenDay; + + async function handleSubmit() { + if (!name.trim() || !phone.trim()) return; + setSaving(true); + try { + if (tab === "classes") { + await adminFetch("/api/admin/group-bookings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim(), phone: phone.trim() }), + }); + } else if (eventType === "master-class") { + const title = mcTitle || mcOptions[0]?.title || "Мастер-класс"; + await adminFetch("/api/admin/mc-registrations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ masterClassTitle: title, name: name.trim(), instagram: "-", phone: phone.trim() }), + }); + } else if (eventType === "open-day" && odClassId && odEventId) { + await adminFetch("/api/open-day-register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classId: Number(odClassId), eventId: odEventId, name: name.trim(), phone: phone.trim() }), + }); + } + onAdded(); + onClose(); + } finally { + setSaving(false); + } + } + + if (!open) return null; + + const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500"; + const tabBtn = (key: Tab, label: string, disabled?: boolean) => ( + + ); + + const canSubmit = name.trim() && phone.trim() && !saving + && (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc) + || (tab === "events" && eventType === "open-day" && odClassId)); + + return createPortal( +
+
+
e.stopPropagation()}> + + +

Добавить запись

+

Ручная запись (Instagram, звонок, лично)

+ +
+ {/* Tab: Classes vs Events */} +
+ {tabBtn("classes", "Занятие")} + {tabBtn("events", "Мероприятие", !hasEvents)} +
+ + {/* Events sub-selector */} + {tab === "events" && ( +
+ {hasUpcomingMc && ( + + )} + {hasOpenDay && ( + + )} +
+ )} + + {/* MC selector */} + {tab === "events" && eventType === "master-class" && mcOptions.length > 0 && ( + + )} + + {/* Open Day class selector */} + {tab === "events" && eventType === "open-day" && odClasses.length > 0 && ( + + )} + + setName(e.target.value)} placeholder="Имя" className={inputClass} /> + setPhone(e.target.value)} placeholder="Телефон" className={inputClass} /> +
+ + +
+
, + document.body + ); +} diff --git a/src/app/admin/bookings/_shared.tsx b/src/app/admin/bookings/BookingComponents.tsx similarity index 57% rename from src/app/admin/bookings/_shared.tsx rename to src/app/admin/bookings/BookingComponents.tsx index 962422e..e1ee54f 100644 --- a/src/app/admin/bookings/_shared.tsx +++ b/src/app/admin/bookings/BookingComponents.tsx @@ -1,25 +1,7 @@ "use client"; import { Loader2, Trash2, Phone, Instagram, Send } from "lucide-react"; - -// --- Types --- - -export type BookingStatus = "new" | "contacted" | "confirmed" | "declined"; -export type BookingFilter = "all" | BookingStatus; - -export const SHORT_DAYS: Record = { - "Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ", - "Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС", -}; - -export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [ - { key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" }, - { key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" }, - { key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" }, - { key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" }, -]; - -// --- Shared Components --- +import { type BookingStatus, type BookingFilter, BOOKING_STATUSES } from "./types"; export function LoadingSpinner() { return ( @@ -115,28 +97,21 @@ export function StatusBadge({ status }: { status: BookingStatus }) { } export function StatusActions({ status, onStatus }: { status: BookingStatus; onStatus: (s: BookingStatus) => void }) { + const actionBtn = (label: string, onClick: () => void, cls: string) => ( + + ); return (
- {status === "new" && ( - - )} + {status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")} {status === "contacted" && ( <> - - + {actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")} + {actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")} )} - {(status === "confirmed" || status === "declined") && ( - - )} + {(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
); } @@ -155,18 +130,3 @@ export function BookingCard({ status, children }: { status: BookingStatus; child
); } - -export function fmtDate(iso: string): string { - return new Date(iso).toLocaleDateString("ru-RU"); -} - -export function countStatuses(items: { status: string }[]): Record { - const c: Record = { new: 0, contacted: 0, confirmed: 0, declined: 0 }; - for (const i of items) c[i.status] = (c[i.status] || 0) + 1; - return c; -} - -export function sortByStatus(items: T[]): T[] { - const order: Record = { new: 0, contacted: 1, confirmed: 2, declined: 3 }; - return [...items].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0)); -} diff --git a/src/app/admin/bookings/GenericBookingsList.tsx b/src/app/admin/bookings/GenericBookingsList.tsx new file mode 100644 index 0000000..dff207a --- /dev/null +++ b/src/app/admin/bookings/GenericBookingsList.tsx @@ -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 { + items: T[]; + endpoint: string; + onItemsChange: (fn: (prev: T[]) => T[]) => void; + groups?: BookingGroup[]; + renderExtra?: (item: T) => React.ReactNode; + onConfirm?: (id: number) => void; +} + +export function GenericBookingsList({ + items, + endpoint, + onItemsChange, + groups, + renderExtra, + onConfirm, +}: GenericBookingsListProps) { + const [filter, setFilter] = useState("all"); + const [showArchived, setShowArchived] = useState(false); + const [expanded, setExpanded] = useState>({}); + + 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 ( + +
+
+ {item.name} + + {renderExtra?.(item)} +
+
+ {fmtDate(item.createdAt)} + handleDelete(item.id)} /> +
+
+
+ + {!isArchived && handleStatus(item.id, s)} />} +
+ handleNotes(item.id, notes)} /> +
+ ); + } + + 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) { + const isOpen = expanded[group.key] ?? !group.isArchived; + const groupCounts = countStatuses(group.items); + return ( +
+ + {isOpen && ( +
+ {group.items.map((item) => renderItem(item, group.isArchived))} +
+ )} +
+ ); + } + + return ( +
+ +
+ {activeGroups.length === 0 && archivedGroups.length === 0 && } + {activeGroups.map(renderGroup)} +
+ {archivedCount > 0 && ( +
+ + {showArchived && ( +
+ {archivedGroups.map(renderGroup)} +
+ )} +
+ )} +
+ ); + } + + const filtered = useMemo(() => { + const list = filter === "all" ? items : items.filter((b) => b.status === filter); + return sortByStatus(list); + }, [items, filter]); + + return ( +
+ +
+ {filtered.length === 0 && } + {filtered.map((item) => renderItem(item, false))} +
+
+ ); +} diff --git a/src/app/admin/bookings/InlineNotes.tsx b/src/app/admin/bookings/InlineNotes.tsx new file mode 100644 index 0000000..d14c9d8 --- /dev/null +++ b/src/app/admin/bookings/InlineNotes.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { StickyNote } from "lucide-react"; + +export function InlineNotes({ value, onSave }: { value: string; onSave: (notes: string) => void }) { + const [editing, setEditing] = useState(false); + const [text, setText] = useState(value); + const timerRef = useRef>(undefined); + const textareaRef = useRef(null); + + useEffect(() => { setText(value); }, [value]); + + const save = useCallback((v: string) => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => onSave(v), 800); + }, [onSave]); + + function handleChange(v: string) { + setText(v); + save(v); + } + + useEffect(() => { + if (editing && textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.selectionStart = textareaRef.current.value.length; + } + }, [editing]); + + if (!editing && !value) { + return ( + + ); + } + + if (!editing) { + return ( + + ); + } + + return ( +
+