diff --git a/src/app/admin/bookings/InlineNotes.tsx b/src/app/admin/bookings/InlineNotes.tsx index 6b9151c..dd8c917 100644 --- a/src/app/admin/bookings/InlineNotes.tsx +++ b/src/app/admin/bookings/InlineNotes.tsx @@ -44,10 +44,10 @@ export function InlineNotes({ value, onSave }: { value: string; onSave: (notes: return ( ); } diff --git a/src/app/admin/bookings/McRegistrationsTab.tsx b/src/app/admin/bookings/McRegistrationsTab.tsx index eb3cf2c..db1c544 100644 --- a/src/app/admin/bookings/McRegistrationsTab.tsx +++ b/src/app/admin/bookings/McRegistrationsTab.tsx @@ -11,11 +11,12 @@ interface McRegistration extends BaseBooking { } 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 }) { const [regs, setRegs] = useState([]); const [mcDates, setMcDates] = useState>({}); + const [mcLocations, setMcLocations] = useState>({}); const [loading, setLoading] = useState(true); useEffect(() => { @@ -25,10 +26,12 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi ]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => { setRegs(regData); const dates: Record = {}; + const locations: Record = {}; const mcItems = mcData.items || []; for (const mc of mcItems) { const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? ""); if (earliestSlot) dates[mc.title] = earliestSlot; + if (mc.location) locations[mc.title] = mc.location; } const regTitles = new Set(regData.map((r) => r.masterClassTitle)); for (const regTitle of regTitles) { @@ -43,6 +46,7 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi } } setMcDates(dates); + setMcLocations(locations); }).catch(() => {}).finally(() => setLoading(false)); }, []); @@ -59,13 +63,13 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi const isArchived = !date || date < today; return { 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, items, isArchived, }; }); - }, [regs, mcDates, today]); + }, [regs, mcDates, mcLocations, today]); if (loading) return ; diff --git a/src/app/admin/bookings/OpenDayBookingsTab.tsx b/src/app/admin/bookings/OpenDayBookingsTab.tsx index a9a63b6..0571aa8 100644 --- a/src/app/admin/bookings/OpenDayBookingsTab.tsx +++ b/src/app/admin/bookings/OpenDayBookingsTab.tsx @@ -17,7 +17,7 @@ interface OpenDayBooking extends BaseBooking { 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([]); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); @@ -47,9 +47,13 @@ export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFi return map; }, [events]); + const filteredBookings = useMemo(() => + hallFilter === "all" ? bookings : bookings.filter((b) => b.classHall === hallFilter), + [bookings, hallFilter]); + const groups = useMemo((): BookingGroup[] => { const map: Record = {}; - for (const b of bookings) { + for (const b of filteredBookings) { 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 }; map[key].items.push(b); @@ -64,25 +68,30 @@ export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFi const isArchived = eventDate ? eventDate < today : false; return { key, - label: g.style, + label: `${g.style} · ${g.hall}`, sublabel: g.time, dateBadge: isArchived && eventDate ? new Date(eventDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined, items: g.items, isArchived, }; }); - }, [bookings, eventDateMap, today]); + }, [filteredBookings, eventDateMap, today]); if (loading) return ; return ( - items={bookings} + items={filteredBookings} endpoint="/api/admin/open-day/bookings" filter={filter} onItemsChange={setBookings} onDataChange={onDataChange} groups={groups} + renderExtra={(b) => ( + <> + {b.classHall && {b.classHall}} + + )} /> ); } diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index 5ddc380..0682c16 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -53,7 +53,7 @@ function ConfirmModal({ existingDate?: string; existingGroup?: string; allClasses: ScheduleClassInfo[]; - onConfirm: (data: { group: string; date: string; comment?: string }) => void; + onConfirm: (data: { group: string; hall?: string; date: string; comment?: string }) => void; onClose: () => void; }) { const [hall, setHall] = useState(""); @@ -144,9 +144,9 @@ function ConfirmModal({ const handleSubmit = useCallback(() => { if (canSubmit) { 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(() => { if (!open) return; @@ -272,7 +272,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD 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; const existing = bookings.find((b) => b.id === confirmingId); const notes = data.comment @@ -280,13 +280,13 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD : existing?.notes; setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, status: "confirmed" as BookingStatus, - confirmedDate: data.date, confirmedGroup: data.group, notes, + confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes, } : b)); 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 } }), + 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", { method: "PUT", @@ -313,6 +313,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD renderExtra={(b) => ( <> {b.groupInfo && {b.groupInfo}} + {b.confirmedHall && {b.confirmedHall}} {(b.confirmedGroup || b.confirmedDate) && ( + {halls.map((hall) => ( + + ))} + + )} + {searchResults ? ( /* #5: Actionable search results — filtered by status */ (() => { @@ -925,7 +965,7 @@ function BookingsPageInner() { {tab === "reminders" && } {tab === "classes" && } {tab === "master-classes" && } - {tab === "open-day" && } + {tab === "open-day" && } )} diff --git a/src/app/admin/bookings/types.ts b/src/app/admin/bookings/types.ts index 85ef10d..a2b4692 100644 --- a/src/app/admin/bookings/types.ts +++ b/src/app/admin/bookings/types.ts @@ -36,7 +36,7 @@ export function countStatuses(items: { status: string }[]): Record(items: T[]): T[] { const order: Record = { new: 0, contacted: 1, confirmed: 2, declined: 3 }; - const UNKNOWN_STATUS_ORDER = 99; + const UNKNOWN_STATUS_ORDER = 4; return [...items].sort((a, b) => (order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER) ); diff --git a/src/app/api/open-day-register/route.ts b/src/app/api/open-day-register/route.ts index 325ff88..2d8702e 100644 --- a/src/app/api/open-day-register/route.ts +++ b/src/app/api/open-day-register/route.ts @@ -4,7 +4,6 @@ import { getPersonOpenDayBookings, getOpenDayEvent, getOpenDayClassById, - getConfirmedOpenDayBookingCount, } from "@/lib/db"; import { checkRateLimit, getClientIp } from "@/lib/rateLimit"; import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation"; @@ -36,11 +35,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 }); } - // Check if class is full (event-level max, confirmed only) — 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 event = getOpenDayEvent(eventId); const maxP = event?.maxParticipants ?? 0; - const confirmedCount = maxP > 0 ? getConfirmedOpenDayBookingCount(classId) : 0; - const isWaiting = maxP > 0 && confirmedCount >= maxP; + const isWaiting = maxP > 0 && cls ? cls.bookingCount >= maxP : false; const id = addOpenDayBooking(classId, eventId, { name: cleanName, diff --git a/src/lib/db.ts b/src/lib/db.ts index f209c9d..ee0bd20 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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) { @@ -689,6 +699,7 @@ interface GroupBookingRow { status: string; confirmed_date: string | null; confirmed_group: string | null; + confirmed_hall: string | null; confirmed_comment: string | null; notes: string | null; created_at: string; @@ -709,6 +720,7 @@ export interface GroupBooking { status: BookingStatus; confirmedDate?: string; confirmedGroup?: string; + confirmedHall?: string; confirmedComment?: string; notes?: string; createdAt: string; @@ -748,6 +760,7 @@ export function getGroupBookings(): GroupBooking[] { status: (r.status || "new") as BookingStatus, confirmedDate: r.confirmed_date ?? undefined, confirmedGroup: r.confirmed_group ?? undefined, + confirmedHall: r.confirmed_hall ?? undefined, confirmedComment: r.confirmed_comment ?? undefined, notes: r.notes ?? undefined, createdAt: r.created_at, @@ -757,7 +770,7 @@ export function getGroupBookings(): GroupBooking[] { export function setGroupBookingStatus( id: number, status: BookingStatus, - confirmation?: { date: string; group: string; comment?: string } + confirmation?: { date: string; group: string; hall?: string; comment?: string } ): void { const db = getDb(); if (status === "confirmed" && confirmation) { @@ -766,8 +779,8 @@ export function setGroupBookingStatus( const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0]; const reminderStatus = (confirmation.date === today || confirmation.date === tomorrow) ? "coming" : null; db.prepare( - "UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_comment = ?, notified_confirm = 1, reminder_status = ? WHERE id = ?" - ).run(status, confirmation.date, confirmation.group, confirmation.comment || null, reminderStatus, 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.hall || null, confirmation.comment || null, reminderStatus, id); } else { db.prepare( "UPDATE group_bookings SET status = ?, confirmed_date = NULL, confirmed_group = NULL, confirmed_comment = NULL, notified_confirm = 1 WHERE id = ?" @@ -920,6 +933,7 @@ export function getUpcomingReminders(): ReminderItem[] { telegram: r.telegram ?? undefined, reminderStatus: r.reminder_status ?? undefined, eventLabel: r.confirmed_group || "Занятие", + eventHall: r.confirmed_hall ?? undefined, eventDate: r.confirmed_date!, }); } @@ -947,7 +961,8 @@ export function getUpcomingReminders(): ReminderItem[] { instagram: r.instagram ?? undefined, telegram: r.telegram ?? 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, }); } @@ -1045,6 +1060,7 @@ interface OpenDayBookingRow { notified_reminder: number; reminder_status: string | null; status: string; + notes: string | null; created_at: string; class_style?: string; class_trainer?: string; @@ -1064,6 +1080,7 @@ export interface OpenDayBooking { notifiedReminder: boolean; reminderStatus?: string; status: string; + notes?: string; createdAt: string; classStyle?: string; classTrainer?: string; @@ -1130,6 +1147,7 @@ function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking { notifiedReminder: !!r.notified_reminder, reminderStatus: r.reminder_status ?? undefined, status: r.status || "new", + notes: r.notes ?? undefined, createdAt: r.created_at, classStyle: r.class_style ?? undefined, classTrainer: r.class_trainer ?? undefined,