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:
@@ -2,17 +2,15 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X } from "lucide-react";
|
||||
import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import {
|
||||
type BookingStatus, type BookingFilter,
|
||||
BOOKING_STATUSES, SHORT_DAYS,
|
||||
LoadingSpinner, EmptyState, DeleteBtn, ContactLinks,
|
||||
FilterTabs, StatusBadge, StatusActions, BookingCard,
|
||||
fmtDate, countStatuses,
|
||||
} from "./_shared";
|
||||
import { McRegistrationsTab } from "./_McRegistrationsTab";
|
||||
import { OpenDayBookingsTab } from "./_OpenDayBookingsTab";
|
||||
import { type BookingStatus, type SearchResult, BOOKING_STATUSES, SHORT_DAYS, fmtDate } from "./types";
|
||||
import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, DeleteBtn } from "./BookingComponents";
|
||||
import { GenericBookingsList } from "./GenericBookingsList";
|
||||
import { AddBookingModal } from "./AddBookingModal";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { McRegistrationsTab } from "./McRegistrationsTab";
|
||||
import { OpenDayBookingsTab } from "./OpenDayBookingsTab";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -203,7 +201,8 @@ function ConfirmModal({
|
||||
<button
|
||||
onClick={() => {
|
||||
if (group && date) {
|
||||
onConfirm({ group, date, comment: comment.trim() || undefined });
|
||||
const groupLabel = groups.find((g) => g.value === group)?.label || group;
|
||||
onConfirm({ group: groupLabel, date, comment: comment.trim() || undefined });
|
||||
}
|
||||
}}
|
||||
disabled={!group || !date}
|
||||
@@ -226,7 +225,7 @@ function GroupBookingsTab() {
|
||||
const [bookings, setBookings] = useState<GroupBooking[]>([]);
|
||||
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<BookingFilter>("all");
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -240,15 +239,7 @@ function GroupBookingsTab() {
|
||||
const shortAddr = loc.address?.split(",")[0] || loc.name;
|
||||
for (const day of loc.days) {
|
||||
for (const cls of day.classes) {
|
||||
classes.push({
|
||||
type: cls.type,
|
||||
trainer: cls.trainer,
|
||||
time: cls.time,
|
||||
day: day.day,
|
||||
hall: loc.name,
|
||||
address: shortAddr,
|
||||
groupId: cls.groupId,
|
||||
});
|
||||
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name, address: shortAddr, groupId: cls.groupId });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,163 +249,54 @@ function GroupBookingsTab() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||
for (const b of bookings) c[b.status] = (c[b.status] || 0) + 1;
|
||||
return c;
|
||||
}, [bookings]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = filter === "all" ? bookings : bookings.filter((b) => b.status === filter);
|
||||
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
|
||||
return [...list].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0));
|
||||
}, [bookings, filter]);
|
||||
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
|
||||
|
||||
async function handleStatus(id: number, status: BookingStatus, confirmation?: { group: string; date: string; comment?: string }) {
|
||||
setBookings((prev) => prev.map((b) => b.id === id ? {
|
||||
...b, status,
|
||||
confirmedDate: confirmation?.date,
|
||||
confirmedGroup: confirmation?.group,
|
||||
confirmedComment: confirmation?.comment,
|
||||
async function handleConfirm(data: { group: string; date: string; comment?: string }) {
|
||||
if (!confirmingId) return;
|
||||
const existing = bookings.find((b) => b.id === confirmingId);
|
||||
const notes = data.comment
|
||||
? (existing?.notes ? `${existing.notes}\n${data.comment}` : data.comment)
|
||||
: existing?.notes;
|
||||
setBookings((prev) => prev.map((b) => b.id === confirmingId ? {
|
||||
...b, status: "confirmed" as BookingStatus,
|
||||
confirmedDate: data.date, confirmedGroup: data.group, notes,
|
||||
} : b));
|
||||
await adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id, status, confirmation }),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await adminFetch(`/api/admin/group-bookings?id=${id}`, { method: "DELETE" });
|
||||
setBookings((prev) => prev.filter((b) => b.id !== id));
|
||||
await Promise.all([
|
||||
adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, date: data.date } }),
|
||||
}),
|
||||
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||
}) : Promise.resolve(),
|
||||
]);
|
||||
setConfirmingId(null);
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilter("all")}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Все <span className="text-neutral-500 ml-1">{bookings.length}</span>
|
||||
</button>
|
||||
{BOOKING_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setFilter(s.key)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bookings list */}
|
||||
<div className="mt-3 space-y-2">
|
||||
{filtered.length === 0 && <EmptyState total={bookings.length} />}
|
||||
{filtered.map((b) => {
|
||||
const statusConf = BOOKING_STATUSES.find((s) => s.key === b.status) || BOOKING_STATUSES[0];
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={`rounded-xl border p-4 transition-colors ${
|
||||
b.status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||
: b.status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||
: b.status === "new" ? "border-gold/20 bg-gold/[0.03]"
|
||||
: "border-white/10 bg-neutral-900"
|
||||
}`}
|
||||
>
|
||||
<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">{b.name}</span>
|
||||
<a href={`tel:${b.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{b.phone}
|
||||
</a>
|
||||
{b.instagram && (
|
||||
<a href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||
<Instagram size={10} />{b.instagram}
|
||||
</a>
|
||||
)}
|
||||
{b.telegram && (
|
||||
<a href={`https://t.me/${b.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||
<Send size={10} />{b.telegram}
|
||||
</a>
|
||||
)}
|
||||
{b.groupInfo && (
|
||||
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
|
||||
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Linear status flow */}
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{/* Current status badge */}
|
||||
<span className={`text-[10px] font-medium ${statusConf.bg} ${statusConf.color} border ${statusConf.border} rounded-full px-2.5 py-0.5`}>
|
||||
{statusConf.label}
|
||||
</span>
|
||||
|
||||
{b.status === "confirmed" && (
|
||||
<span className="text-[10px] text-emerald-400/70">
|
||||
{b.confirmedGroup}
|
||||
{b.confirmedDate && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
|
||||
{b.confirmedComment && ` · ${b.confirmedComment}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action buttons based on current state */}
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{b.status === "new" && (
|
||||
<button
|
||||
onClick={() => handleStatus(b.id, "contacted")}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20 transition-all"
|
||||
>
|
||||
Связались →
|
||||
</button>
|
||||
)}
|
||||
{b.status === "contacted" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setConfirmingId(b.id)}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20 transition-all"
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatus(b.id, "declined")}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-all"
|
||||
>
|
||||
Отказ
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(b.status === "confirmed" || b.status === "declined") && (
|
||||
<button
|
||||
onClick={() => handleStatus(b.id, "contacted")}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300 transition-all"
|
||||
>
|
||||
Вернуть
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<GenericBookingsList<GroupBooking>
|
||||
items={bookings}
|
||||
endpoint="/api/admin/group-bookings"
|
||||
onItemsChange={setBookings}
|
||||
onConfirm={(id) => setConfirmingId(id)}
|
||||
renderExtra={(b) => (
|
||||
<>
|
||||
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
|
||||
{b.status === "confirmed" && (b.confirmedGroup || b.confirmedDate) && (
|
||||
<span className="text-[10px] text-emerald-400/70">
|
||||
{b.confirmedGroup}
|
||||
{b.confirmedDate && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmingId !== null}
|
||||
@@ -422,12 +304,9 @@ function GroupBookingsTab() {
|
||||
groupInfo={confirmingBooking?.groupInfo}
|
||||
allClasses={allClasses}
|
||||
onClose={() => setConfirmingId(null)}
|
||||
onConfirm={(data) => {
|
||||
if (confirmingId) handleStatus(confirmingId, "confirmed", data);
|
||||
setConfirmingId(null);
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -767,41 +646,115 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
|
||||
export default function BookingsPage() {
|
||||
const [tab, setTab] = useState<Tab>("reminders");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const lastTotalRef = useRef<number | null>(null);
|
||||
|
||||
// Poll for new bookings every 10s
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
adminFetch("/api/admin/unread-counts")
|
||||
.then((r) => r.json())
|
||||
.then((data: { total: number }) => {
|
||||
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}
|
||||
lastTotalRef.current = data.total;
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { class: "Занятие", mc: "Мастер-класс", "open-day": "Open Day" };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Записи</h1>
|
||||
|
||||
{/* Dashboard — what needs attention */}
|
||||
<DashboardSummary onNavigate={setTab} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-5 flex border-b border-white/10">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||
tab === t.key
|
||||
? "text-gold"
|
||||
: "text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{tab === t.key && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gold rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">Записи</h1>
|
||||
<button
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-gold/10 text-gold border border-gold/30 hover:bg-gold/20 transition-all"
|
||||
title="Добавить запись"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="mt-4">
|
||||
{tab === "reminders" && <RemindersTab />}
|
||||
{tab === "classes" && <GroupBookingsTab />}
|
||||
{tab === "master-classes" && <McRegistrationsTab />}
|
||||
{tab === "open-day" && <OpenDayBookingsTab />}
|
||||
{/* Search */}
|
||||
<div className="mt-3">
|
||||
<SearchBar
|
||||
onResults={setSearchResults}
|
||||
onClear={() => setSearchResults(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchResults ? (
|
||||
/* Search results */
|
||||
<div className="mt-4 space-y-2">
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 py-8 text-center">Ничего не найдено</p>
|
||||
) : (
|
||||
searchResults.map((r) => (
|
||||
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
|
||||
<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="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span>
|
||||
<span className="font-medium text-white">{r.name}</span>
|
||||
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
|
||||
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
|
||||
</div>
|
||||
<span className="text-neutral-600 text-xs shrink-0">{fmtDate(r.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<StatusBadge status={r.status as BookingStatus} />
|
||||
{r.notes && <span className="text-[10px] text-neutral-500 truncate">{r.notes}</span>}
|
||||
</div>
|
||||
</BookingCard>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Dashboard — what needs attention */}
|
||||
<DashboardSummary onNavigate={setTab} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-5 flex border-b border-white/10">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||
tab === t.key
|
||||
? "text-gold"
|
||||
: "text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{tab === t.key && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gold rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="mt-4" key={refreshKey}>
|
||||
{tab === "reminders" && <RemindersTab />}
|
||||
{tab === "classes" && <GroupBookingsTab />}
|
||||
{tab === "master-classes" && <McRegistrationsTab />}
|
||||
{tab === "open-day" && <OpenDayBookingsTab />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AddBookingModal
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onAdded={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user