Compare commits

...

2 Commits

Author SHA1 Message Date
diana.dolgolyova e64119aaa0 feat: hall info on booking cards, notes styling, sort + highlight fixes
- Add hall badge to Open Day and Classes booking cards
- Hall in group labels for Open Day and MC tabs
- Hall in reminders event labels
- Save confirmed_hall for group bookings (migration 17)
- Page-level hall filter for all tabs
- Waiting list uses total bookings (matches public display)
- Notes styling: subtle gray text, gold icon + white text on hover
- Cards: sort newly changed to top of status group
- Fix Open Day notes not showing (missing from row type + mapper)
2026-03-25 14:40:24 +03:00
diana.dolgolyova eb949f1a37 feat: booking UX improvements — waiting list, card focus, sort order
- Auto-note "Лист ожидания" for registrations when class is full
- Waiting list triggers on confirmed count (not total registrations)
- Card highlight + scroll after status change
- Hover effect on booking cards
- Freshly changed cards appear first in their status group
- Polling no longer remounts tabs (fixes page jump on approve)
- Fix MasterClassesData missing waitingListText type
- Add Turbopack troubleshooting docs to CLAUDE.md
2026-03-25 12:53:45 +03:00
12 changed files with 166 additions and 49 deletions
+9
View File
@@ -158,6 +158,15 @@ src/
- Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table - Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table
- Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety - Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety
## Turbopack / Dev Server Troubleshooting
If the dev server hangs on "Compiling..." or shows a white page:
1. Kill all node processes: `taskkill /F /IM node.exe`
2. Remove stale lock: `rm -f .next/dev/lock`
3. Clear cache: `rm -rf .next node_modules/.cache`
4. Restart: `npm run dev`
- This often happens after shutting down the PC without stopping the server first
- Always stop the dev server (Ctrl+C) before shutting down
## Git ## Git
- Remote: Gitea at `git.dolgolyov-family.by` - Remote: Gitea at `git.dolgolyov-family.by`
- User: diana.dolgolyova - User: diana.dolgolyova
+7 -7
View File
@@ -159,15 +159,15 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS
); );
} }
export function BookingCard({ status, children }: { status: BookingStatus; children: React.ReactNode }) { export function BookingCard({ status, highlight, children }: { status: BookingStatus; highlight?: boolean; children: React.ReactNode }) {
return ( return (
<div <div
className={`rounded-lg border p-3 transition-colors ${ className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50" status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30"
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]" : status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]"
: status === "new" ? "border-gold/20 bg-gold/[0.03]" : status === "new" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]"
: "border-white/10 bg-neutral-800/30" : "border-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50"
}`} }${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
> >
{children} {children}
</div> </div>
+24 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { ChevronDown, ChevronRight, Archive } from "lucide-react"; import { ChevronDown, ChevronRight, Archive } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types"; import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
@@ -32,8 +32,20 @@ export function GenericBookingsList<T extends BaseBooking>({
}: GenericBookingsListProps<T>) { }: GenericBookingsListProps<T>) {
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [highlightId, setHighlightId] = useState<number | null>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const { showError } = useToast(); 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) { async function handleStatus(id: number, status: BookingStatus) {
if (status === "confirmed" && onConfirm) { if (status === "confirmed" && onConfirm) {
onConfirm(id); onConfirm(id);
@@ -41,7 +53,13 @@ export function GenericBookingsList<T extends BaseBooking>({
} }
const prev = items.find((b) => b.id === id); const prev = items.find((b) => b.id === id);
const prevStatus = prev?.status; const prevStatus = prev?.status;
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status } : b)); // 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 { try {
const res = await adminFetch(endpoint, { const res = await adminFetch(endpoint, {
method: "PUT", method: "PUT",
@@ -85,8 +103,10 @@ export function GenericBookingsList<T extends BaseBooking>({
} }
function renderItem(item: T, isArchived: boolean) { function renderItem(item: T, isArchived: boolean) {
const isHighlighted = highlightId === item.id;
return ( return (
<BookingCard key={item.id} status={item.status}> <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-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0"> <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> <span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
@@ -104,6 +124,7 @@ export function GenericBookingsList<T extends BaseBooking>({
</div> </div>
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} /> <InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
</BookingCard> </BookingCard>
</div>
); );
} }
+3 -3
View File
@@ -44,10 +44,10 @@ export function InlineNotes({ value, onSave }: { value: string; onSave: (notes:
return ( return (
<button <button
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
className="mt-2 flex items-start gap-1.5 rounded-md bg-amber-500/[0.06] border border-amber-500/10 px-2.5 py-1.5 text-left transition-colors hover:bg-amber-500/10" className="mt-2 inline-flex items-start gap-1.5 text-left transition-colors group"
> >
<StickyNote size={11} className="shrink-0 mt-0.5 text-amber-500/60" /> <StickyNote size={11} className="shrink-0 mt-0.5 text-neutral-500 group-hover:text-gold transition-colors" />
<span className="text-[11px] text-amber-200/70 leading-relaxed whitespace-pre-wrap">{value}</span> <span className="text-[11px] text-neutral-400 leading-relaxed whitespace-pre-wrap group-hover:text-white transition-colors">{value}</span>
</button> </button>
); );
} }
@@ -11,11 +11,12 @@ interface McRegistration extends BaseBooking {
} }
interface McSlot { date: string; startTime: string } interface McSlot { date: string; startTime: string }
interface McItem { title: string; slots: McSlot[] } interface McItem { title: string; slots: McSlot[]; location?: string }
export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) { export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
const [regs, setRegs] = useState<McRegistration[]>([]); const [regs, setRegs] = useState<McRegistration[]>([]);
const [mcDates, setMcDates] = useState<Record<string, string>>({}); const [mcDates, setMcDates] = useState<Record<string, string>>({});
const [mcLocations, setMcLocations] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -25,10 +26,12 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => { ]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => {
setRegs(regData); setRegs(regData);
const dates: Record<string, string> = {}; const dates: Record<string, string> = {};
const locations: Record<string, string> = {};
const mcItems = mcData.items || []; const mcItems = mcData.items || [];
for (const mc of mcItems) { for (const mc of mcItems) {
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? ""); const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
if (earliestSlot) dates[mc.title] = earliestSlot; if (earliestSlot) dates[mc.title] = earliestSlot;
if (mc.location) locations[mc.title] = mc.location;
} }
const regTitles = new Set(regData.map((r) => r.masterClassTitle)); const regTitles = new Set(regData.map((r) => r.masterClassTitle));
for (const regTitle of regTitles) { for (const regTitle of regTitles) {
@@ -43,6 +46,7 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
} }
} }
setMcDates(dates); setMcDates(dates);
setMcLocations(locations);
}).catch(() => {}).finally(() => setLoading(false)); }).catch(() => {}).finally(() => setLoading(false));
}, []); }, []);
@@ -59,13 +63,13 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
const isArchived = !date || date < today; const isArchived = !date || date < today;
return { return {
key: title, key: title,
label: title, label: mcLocations[title] ? `${title} · ${mcLocations[title]}` : title,
dateBadge: date ? new Date(date + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined, dateBadge: date ? new Date(date + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
items, items,
isArchived, isArchived,
}; };
}); });
}, [regs, mcDates, today]); }, [regs, mcDates, mcLocations, today]);
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;
+14 -5
View File
@@ -17,7 +17,7 @@ interface OpenDayBooking extends BaseBooking {
interface EventInfo { id: number; date: string; title?: string } interface EventInfo { id: number; date: string; title?: string }
export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) { export function OpenDayBookingsTab({ filter, hallFilter = "all", onDataChange }: { filter: BookingFilter; hallFilter?: string; onDataChange?: () => void }) {
const [bookings, setBookings] = useState<OpenDayBooking[]>([]); const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [events, setEvents] = useState<EventInfo[]>([]); const [events, setEvents] = useState<EventInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -47,9 +47,13 @@ export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFi
return map; return map;
}, [events]); }, [events]);
const filteredBookings = useMemo(() =>
hallFilter === "all" ? bookings : bookings.filter((b) => b.classHall === hallFilter),
[bookings, hallFilter]);
const groups = useMemo((): BookingGroup<OpenDayBooking>[] => { const groups = useMemo((): BookingGroup<OpenDayBooking>[] => {
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[]; eventId: number }> = {}; const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[]; eventId: number }> = {};
for (const b of bookings) { for (const b of filteredBookings) {
const key = `${b.eventId}|${b.classHall}|${b.classTime}|${b.classStyle}`; const key = `${b.eventId}|${b.classHall}|${b.classTime}|${b.classStyle}`;
if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [], eventId: b.eventId }; if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [], eventId: b.eventId };
map[key].items.push(b); map[key].items.push(b);
@@ -64,25 +68,30 @@ export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFi
const isArchived = eventDate ? eventDate < today : false; const isArchived = eventDate ? eventDate < today : false;
return { return {
key, key,
label: g.style, label: `${g.style} · ${g.hall}`,
sublabel: g.time, sublabel: g.time,
dateBadge: isArchived && eventDate ? new Date(eventDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined, dateBadge: isArchived && eventDate ? new Date(eventDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
items: g.items, items: g.items,
isArchived, isArchived,
}; };
}); });
}, [bookings, eventDateMap, today]); }, [filteredBookings, eventDateMap, today]);
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;
return ( return (
<GenericBookingsList<OpenDayBooking> <GenericBookingsList<OpenDayBooking>
items={bookings} items={filteredBookings}
endpoint="/api/admin/open-day/bookings" endpoint="/api/admin/open-day/bookings"
filter={filter} filter={filter}
onItemsChange={setBookings} onItemsChange={setBookings}
onDataChange={onDataChange} onDataChange={onDataChange}
groups={groups} groups={groups}
renderExtra={(b) => (
<>
{b.classHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.classHall}</span>}
</>
)}
/> />
); );
} }
+52 -12
View File
@@ -53,7 +53,7 @@ function ConfirmModal({
existingDate?: string; existingDate?: string;
existingGroup?: string; existingGroup?: string;
allClasses: ScheduleClassInfo[]; allClasses: ScheduleClassInfo[];
onConfirm: (data: { group: string; date: string; comment?: string }) => void; onConfirm: (data: { group: string; hall?: string; date: string; comment?: string }) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [hall, setHall] = useState(""); const [hall, setHall] = useState("");
@@ -144,9 +144,9 @@ function ConfirmModal({
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (canSubmit) { if (canSubmit) {
const groupLabel = groups.find((g) => g.value === group)?.label || group; const groupLabel = groups.find((g) => g.value === group)?.label || group;
onConfirm({ group: groupLabel, date, comment: comment.trim() || undefined }); onConfirm({ group: groupLabel, hall: hall || undefined, date, comment: comment.trim() || undefined });
} }
}, [canSubmit, group, date, comment, groups, onConfirm]); }, [canSubmit, group, hall, date, comment, groups, onConfirm]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -272,7 +272,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
const confirmingBooking = bookings.find((b) => b.id === confirmingId); const confirmingBooking = bookings.find((b) => b.id === confirmingId);
async function handleConfirm(data: { group: string; date: string; comment?: string }) { async function handleConfirm(data: { group: string; hall?: string; date: string; comment?: string }) {
if (!confirmingId) return; if (!confirmingId) return;
const existing = bookings.find((b) => b.id === confirmingId); const existing = bookings.find((b) => b.id === confirmingId);
const notes = data.comment const notes = data.comment
@@ -280,13 +280,13 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
: existing?.notes; : existing?.notes;
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { setBookings((prev) => prev.map((b) => b.id === confirmingId ? {
...b, status: "confirmed" as BookingStatus, ...b, status: "confirmed" as BookingStatus,
confirmedDate: data.date, confirmedGroup: data.group, notes, confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
} : b)); } : b));
await Promise.all([ await Promise.all([
adminFetch("/api/admin/group-bookings", { adminFetch("/api/admin/group-bookings", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, date: data.date } }), body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
}), }),
data.comment ? adminFetch("/api/admin/group-bookings", { data.comment ? adminFetch("/api/admin/group-bookings", {
method: "PUT", method: "PUT",
@@ -313,6 +313,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
renderExtra={(b) => ( 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.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.confirmedHall}</span>}
{(b.confirmedGroup || b.confirmedDate) && ( {(b.confirmedGroup || b.confirmedDate) && (
<button <button
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }} onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
@@ -354,6 +355,7 @@ interface ReminderItem {
telegram?: string; telegram?: string;
reminderStatus?: string; reminderStatus?: string;
eventLabel: string; eventLabel: string;
eventHall?: string;
eventDate: string; eventDate: string;
} }
@@ -537,7 +539,7 @@ function RemindersTab() {
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden"> <div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900"> <div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
<TypeIcon size={13} className={typeConf.color} /> <TypeIcon size={13} className={typeConf.color} />
<span className="text-sm font-medium text-white">{eg.label}</span> <span className="text-sm font-medium text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span> <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
<div className="flex gap-2 ml-auto text-[10px]"> <div className="flex gap-2 ml-auto text-[10px]">
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>} {egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
@@ -785,12 +787,25 @@ function BookingsPageInner() {
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null); const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [statusFilter, setStatusFilter] = useState<BookingFilter>("all"); const [statusFilter, setStatusFilter] = useState<BookingFilter>("all");
const [hallFilter, setHallFilter] = useState("all");
const [halls, setHalls] = useState<string[]>([]);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [dashboardKey, setDashboardKey] = useState(0); const [dashboardKey, setDashboardKey] = useState(0);
const refreshDashboard = useCallback(() => setDashboardKey((k) => k + 1), []); const refreshDashboard = useCallback(() => setDashboardKey((k) => k + 1), []);
const lastTotalRef = useRef<number | null>(null); const lastTotalRef = useRef<number | null>(null);
const { showError } = useToast(); const { showError } = useToast();
// Fetch available halls from schedule
useEffect(() => {
adminFetch("/api/admin/sections/schedule")
.then((r) => r.json())
.then((data: { locations?: { name: string }[] }) => {
const names = data.locations?.map((l) => l.name).filter(Boolean) ?? [];
setHalls([...new Set(names)]);
})
.catch(() => {});
}, []);
// Poll for new bookings, auto-refresh silently // Poll for new bookings, auto-refresh silently
useEffect(() => { useEffect(() => {
const id = setInterval(() => { const id = setInterval(() => {
@@ -799,7 +814,7 @@ function BookingsPageInner() {
.then((r) => r.json()) .then((r) => r.json())
.then((data: { total: number }) => { .then((data: { total: number }) => {
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) { if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
setRefreshKey((k) => k + 1); refreshDashboard();
} }
lastTotalRef.current = data.total; lastTotalRef.current = data.total;
}) })
@@ -863,6 +878,31 @@ function BookingsPageInner() {
/> />
</div> </div>
{/* Hall filter */}
{halls.length > 1 && (
<div className="mt-3 flex gap-2 flex-wrap">
<button
onClick={() => setHallFilter("all")}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
}`}
>
Все залы
</button>
{halls.map((hall) => (
<button
key={hall}
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
}`}
>
{hall}
</button>
))}
</div>
)}
{searchResults ? ( {searchResults ? (
/* #5: Actionable search results — filtered by status */ /* #5: Actionable search results — filtered by status */
(() => { (() => {
@@ -921,11 +961,11 @@ function BookingsPageInner() {
</div> </div>
{/* Tab content */} {/* Tab content */}
<div className="mt-4" key={`tab-${refreshKey}`}> <div className="mt-4">
{tab === "reminders" && <RemindersTab />} {tab === "reminders" && <RemindersTab key={refreshKey} />}
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />} {tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />} {tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />} {tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
</div> </div>
</> </>
)} )}
@@ -933,7 +973,7 @@ function BookingsPageInner() {
<AddBookingModal <AddBookingModal
open={addOpen} open={addOpen}
onClose={() => setAddOpen(false)} onClose={() => setAddOpen(false)}
onAdded={() => setRefreshKey((k) => k + 1)} onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
/> />
</div> </div>
); );
+4 -1
View File
@@ -36,7 +36,10 @@ export function countStatuses(items: { status: string }[]): Record<string, numbe
export function sortByStatus<T extends { status: string }>(items: T[]): T[] { export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 }; const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
return [...items].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0)); const UNKNOWN_STATUS_ORDER = 4;
return [...items].sort((a, b) =>
(order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER)
);
} }
export interface BookingGroup<T extends BaseBooking> { export interface BookingGroup<T extends BaseBooking> {
+1
View File
@@ -35,6 +35,7 @@ function PriceField({ label, value, onChange, placeholder }: { label: string; va
interface MasterClassesData { interface MasterClassesData {
title: string; title: string;
successMessage?: string; successMessage?: string;
waitingListText?: string;
items: MasterClassItem[]; items: MasterClassItem[];
} }
+4 -2
View File
@@ -38,7 +38,8 @@ export async function POST(request: Request) {
let isWaiting = false; let isWaiting = false;
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) { if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
const currentRegs = getMcRegistrations(cleanTitle); const currentRegs = getMcRegistrations(cleanTitle);
isWaiting = currentRegs.length >= mcItem.maxParticipants; const confirmedCount = currentRegs.filter((r) => r.status === "confirmed").length;
isWaiting = confirmedCount >= mcItem.maxParticipants;
} }
const id = addMcRegistration( const id = addMcRegistration(
@@ -46,7 +47,8 @@ export async function POST(request: Request) {
cleanName, cleanName,
sanitizeHandle(instagram) ?? "", sanitizeHandle(instagram) ?? "",
sanitizeHandle(telegram), sanitizeHandle(telegram),
cleanPhone cleanPhone,
isWaiting ? "Лист ожидания" : undefined
); );
return NextResponse.json({ ok: true, id, isWaiting }); return NextResponse.json({ ok: true, id, isWaiting });
+2 -1
View File
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 }); return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
} }
// Check if class is full (event-level max) — if so, booking goes to waiting list // Check if class is full (event-level max, total bookings) — if so, booking goes to waiting list
const cls = getOpenDayClassById(classId); const cls = getOpenDayClassById(classId);
const event = getOpenDayEvent(eventId); const event = getOpenDayEvent(eventId);
const maxP = event?.maxParticipants ?? 0; const maxP = event?.maxParticipants ?? 0;
@@ -46,6 +46,7 @@ export async function POST(request: NextRequest) {
phone: cleanPhone, phone: cleanPhone,
instagram: sanitizeHandle(instagram), instagram: sanitizeHandle(instagram),
telegram: sanitizeHandle(telegram), telegram: sanitizeHandle(telegram),
notes: isWaiting ? "Лист ожидания" : undefined,
}); });
// Return total bookings for this person (for discount calculation) // Return total bookings for this person (for discount calculation)
+39 -12
View File
@@ -288,6 +288,16 @@ const migrations: Migration[] = [
} }
}, },
}, },
{
version: 17,
name: "add_confirmed_hall_to_group_bookings",
up: (db) => {
const cols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
if (!cols.some((c) => c.name === "confirmed_hall")) {
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_hall TEXT");
}
},
},
]; ];
function runMigrations(db: Database.Database) { function runMigrations(db: Database.Database) {
@@ -593,15 +603,16 @@ export function addMcRegistration(
name: string, name: string,
instagram: string, instagram: string,
telegram?: string, telegram?: string,
phone?: string phone?: string,
notes?: string
): number { ): number {
const db = getDb(); const db = getDb();
const result = db const result = db
.prepare( .prepare(
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone) `INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone, notes)
VALUES (?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?)`
) )
.run(masterClassTitle, name, instagram, telegram || null, phone || null); .run(masterClassTitle, name, instagram, telegram || null, phone || null, notes || null);
return result.lastInsertRowid as number; return result.lastInsertRowid as number;
} }
@@ -688,6 +699,7 @@ interface GroupBookingRow {
status: string; status: string;
confirmed_date: string | null; confirmed_date: string | null;
confirmed_group: string | null; confirmed_group: string | null;
confirmed_hall: string | null;
confirmed_comment: string | null; confirmed_comment: string | null;
notes: string | null; notes: string | null;
created_at: string; created_at: string;
@@ -708,6 +720,7 @@ export interface GroupBooking {
status: BookingStatus; status: BookingStatus;
confirmedDate?: string; confirmedDate?: string;
confirmedGroup?: string; confirmedGroup?: string;
confirmedHall?: string;
confirmedComment?: string; confirmedComment?: string;
notes?: string; notes?: string;
createdAt: string; createdAt: string;
@@ -747,6 +760,7 @@ export function getGroupBookings(): GroupBooking[] {
status: (r.status || "new") as BookingStatus, status: (r.status || "new") as BookingStatus,
confirmedDate: r.confirmed_date ?? undefined, confirmedDate: r.confirmed_date ?? undefined,
confirmedGroup: r.confirmed_group ?? undefined, confirmedGroup: r.confirmed_group ?? undefined,
confirmedHall: r.confirmed_hall ?? undefined,
confirmedComment: r.confirmed_comment ?? undefined, confirmedComment: r.confirmed_comment ?? undefined,
notes: r.notes ?? undefined, notes: r.notes ?? undefined,
createdAt: r.created_at, createdAt: r.created_at,
@@ -756,7 +770,7 @@ export function getGroupBookings(): GroupBooking[] {
export function setGroupBookingStatus( export function setGroupBookingStatus(
id: number, id: number,
status: BookingStatus, status: BookingStatus,
confirmation?: { date: string; group: string; comment?: string } confirmation?: { date: string; group: string; hall?: string; comment?: string }
): void { ): void {
const db = getDb(); const db = getDb();
if (status === "confirmed" && confirmation) { if (status === "confirmed" && confirmation) {
@@ -765,8 +779,8 @@ export function setGroupBookingStatus(
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0]; const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
const reminderStatus = (confirmation.date === today || confirmation.date === tomorrow) ? "coming" : null; const reminderStatus = (confirmation.date === today || confirmation.date === tomorrow) ? "coming" : null;
db.prepare( db.prepare(
"UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_comment = ?, notified_confirm = 1, reminder_status = ? WHERE id = ?" "UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_hall = ?, confirmed_comment = ?, notified_confirm = 1, reminder_status = ? WHERE id = ?"
).run(status, confirmation.date, confirmation.group, confirmation.comment || null, reminderStatus, id); ).run(status, confirmation.date, confirmation.group, confirmation.hall || null, confirmation.comment || null, reminderStatus, id);
} else { } else {
db.prepare( db.prepare(
"UPDATE group_bookings SET status = ?, confirmed_date = NULL, confirmed_group = NULL, confirmed_comment = NULL, notified_confirm = 1 WHERE id = ?" "UPDATE group_bookings SET status = ?, confirmed_date = NULL, confirmed_group = NULL, confirmed_comment = NULL, notified_confirm = 1 WHERE id = ?"
@@ -919,6 +933,7 @@ export function getUpcomingReminders(): ReminderItem[] {
telegram: r.telegram ?? undefined, telegram: r.telegram ?? undefined,
reminderStatus: r.reminder_status ?? undefined, reminderStatus: r.reminder_status ?? undefined,
eventLabel: r.confirmed_group || "Занятие", eventLabel: r.confirmed_group || "Занятие",
eventHall: r.confirmed_hall ?? undefined,
eventDate: r.confirmed_date!, eventDate: r.confirmed_date!,
}); });
} }
@@ -946,7 +961,8 @@ export function getUpcomingReminders(): ReminderItem[] {
instagram: r.instagram ?? undefined, instagram: r.instagram ?? undefined,
telegram: r.telegram ?? undefined, telegram: r.telegram ?? undefined,
reminderStatus: r.reminder_status ?? undefined, reminderStatus: r.reminder_status ?? undefined,
eventLabel: `${r.class_style} · ${r.class_trainer} · ${r.class_time} (${r.class_hall})`, eventLabel: `${r.class_style} · ${r.class_trainer} · ${r.class_time}`,
eventHall: r.class_hall ?? undefined,
eventDate: ev.date, eventDate: ev.date,
}); });
} }
@@ -1044,6 +1060,7 @@ interface OpenDayBookingRow {
notified_reminder: number; notified_reminder: number;
reminder_status: string | null; reminder_status: string | null;
status: string; status: string;
notes: string | null;
created_at: string; created_at: string;
class_style?: string; class_style?: string;
class_trainer?: string; class_trainer?: string;
@@ -1063,6 +1080,7 @@ export interface OpenDayBooking {
notifiedReminder: boolean; notifiedReminder: boolean;
reminderStatus?: string; reminderStatus?: string;
status: string; status: string;
notes?: string;
createdAt: string; createdAt: string;
classStyle?: string; classStyle?: string;
classTrainer?: string; classTrainer?: string;
@@ -1103,6 +1121,14 @@ function mapClassRow(r: OpenDayClassRow): OpenDayClass {
}; };
} }
export function getConfirmedOpenDayBookingCount(classId: number): number {
const db = getDb();
const row = db.prepare(
"SELECT COUNT(*) as cnt FROM open_day_bookings WHERE class_id = ? AND status = 'confirmed'"
).get(classId) as { cnt: number };
return row.cnt;
}
export function setOpenDayBookingStatus(id: number, status: string): void { export function setOpenDayBookingStatus(id: number, status: string): void {
const db = getDb(); const db = getDb();
db.prepare("UPDATE open_day_bookings SET status = ?, notified_confirm = 1 WHERE id = ?").run(status, id); db.prepare("UPDATE open_day_bookings SET status = ?, notified_confirm = 1 WHERE id = ?").run(status, id);
@@ -1121,6 +1147,7 @@ function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking {
notifiedReminder: !!r.notified_reminder, notifiedReminder: !!r.notified_reminder,
reminderStatus: r.reminder_status ?? undefined, reminderStatus: r.reminder_status ?? undefined,
status: r.status || "new", status: r.status || "new",
notes: r.notes ?? undefined,
createdAt: r.created_at, createdAt: r.created_at,
classStyle: r.class_style ?? undefined, classStyle: r.class_style ?? undefined,
classTrainer: r.class_trainer ?? undefined, classTrainer: r.class_trainer ?? undefined,
@@ -1297,15 +1324,15 @@ export function deleteOpenDayClass(id: number): void {
export function addOpenDayBooking( export function addOpenDayBooking(
classId: number, classId: number,
eventId: number, eventId: number,
data: { name: string; phone: string; instagram?: string; telegram?: string } data: { name: string; phone: string; instagram?: string; telegram?: string; notes?: string }
): number { ): number {
const db = getDb(); const db = getDb();
const result = db const result = db
.prepare( .prepare(
`INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram) `INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram, notes)
VALUES (?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?)`
) )
.run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null); .run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null, data.notes || null);
return result.lastInsertRowid as number; return result.lastInsertRowid as number;
} }