From 4e766d69576d68d92fd55a6c45588648ef7691d5 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Thu, 19 Mar 2026 13:07:00 +0300 Subject: [PATCH] feat: add reminders tab with status tracking (coming/pending/cancelled) Auto-surfaces bookings for today and tomorrow. Admin sets status per person: coming, no answer, or cancelled. Summary stats per day. DB migration 8 adds reminder_status column to all booking tables. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/admin/bookings/page.tsx | 196 ++++++++++++++++++++++++++- src/app/api/admin/reminders/route.ts | 36 +++++ src/lib/db.ts | 125 +++++++++++++++++ 3 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 src/app/api/admin/reminders/route.ts diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index 46d5504..801ba65 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight } from "lucide-react"; +import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; import { NotifyToggle } from "../_components/NotifyToggle"; @@ -56,7 +56,7 @@ interface MasterClassItem { slots: MasterClassSlot[]; } -type Tab = "classes" | "master-classes" | "open-day"; +type Tab = "reminders" | "classes" | "master-classes" | "open-day"; type NotifyFilter = "all" | "new" | "no-reminder"; // --- Filter Chips --- @@ -512,6 +512,194 @@ function OpenDayBookingsTab() { ); } +// --- Reminders Tab --- + +interface ReminderItem { + id: number; + type: "class" | "master-class" | "open-day"; + table: "mc_registrations" | "group_bookings" | "open_day_bookings"; + name: string; + phone?: string; + instagram?: string; + telegram?: string; + reminderStatus?: string; + eventLabel: string; + eventDate: string; +} + +type ReminderStatus = "pending" | "coming" | "cancelled"; + +const STATUS_CONFIG: Record = { + pending: { label: "Нет ответа", icon: Clock, color: "text-neutral-400", bg: "bg-neutral-500/10", border: "border-neutral-500/20" }, + coming: { label: "Придёт", icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" }, + cancelled: { label: "Не придёт", icon: XCircle, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" }, +}; + +const TYPE_CONFIG = { + "master-class": { label: "МК", icon: Star, color: "text-purple-400" }, + "open-day": { label: "Open Day", icon: DoorOpen, color: "text-gold" }, + "class": { label: "Занятие", icon: Calendar, color: "text-blue-400" }, +}; + +function RemindersTab() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + adminFetch("/api/admin/reminders") + .then((r) => r.json()) + .then((data: ReminderItem[]) => setItems(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + async function setStatus(item: ReminderItem, status: ReminderStatus | null) { + setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: status ?? undefined } : i)); + await adminFetch("/api/admin/reminders", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ table: item.table, id: item.id, status }), + }); + } + + if (loading) return ; + + const today = new Date().toISOString().split("T")[0]; + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + + const todayItems = items.filter((i) => i.eventDate === today); + const tomorrowItems = items.filter((i) => i.eventDate === tomorrow); + + // Stats + function countByStatus(list: ReminderItem[]) { + const coming = list.filter((i) => i.reminderStatus === "coming").length; + const cancelled = list.filter((i) => i.reminderStatus === "cancelled").length; + const pending = list.filter((i) => i.reminderStatus === "pending").length; + const notAsked = list.filter((i) => !i.reminderStatus).length; + return { coming, cancelled, pending, notAsked, total: list.length }; + } + + if (items.length === 0) { + return ( +
+ +

Нет напоминаний — все на контроле

+

Здесь появятся записи на сегодня и завтра

+
+ ); + } + + return ( +
+ {[ + { label: "Сегодня", date: today, items: todayItems }, + { label: "Завтра", date: tomorrow, items: tomorrowItems }, + ] + .filter((group) => group.items.length > 0) + .map((group) => { + const stats = countByStatus(group.items); + return ( +
+
+

{group.label}

+ + {new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })} + +
+ {stats.coming > 0 && {stats.coming} придёт} + {stats.cancelled > 0 && {stats.cancelled} не придёт} + {stats.pending > 0 && {stats.pending} нет ответа} + {stats.notAsked > 0 && {stats.notAsked} не спрошены} +
+
+ +
+ {group.items.map((item) => { + const typeConf = TYPE_CONFIG[item.type]; + const TypeIcon = typeConf.icon; + const currentStatus = item.reminderStatus as ReminderStatus | undefined; + + return ( +
+
+ {item.name} + {item.phone && ( + + {item.phone} + + )} + {item.instagram && ( + + {item.instagram} + + )} + {item.telegram && ( + + {item.telegram} + + )} +
+ +
+ + + {item.eventLabel} + + +
+ {(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => { + const conf = STATUS_CONFIG[st]; + const Icon = conf.icon; + const active = currentStatus === st; + return ( + + ); + })} +
+
+
+ ); + })} +
+
+ ); + })} +
+ ); +} + // --- Shared helpers --- function LoadingSpinner() { @@ -551,13 +739,14 @@ function fmtDate(iso: string): string { // --- Main Page --- const TABS: { key: Tab; label: string }[] = [ + { key: "reminders", label: "Напоминания" }, { key: "classes", label: "Занятия" }, { key: "master-classes", label: "Мастер-классы" }, { key: "open-day", label: "День открытых дверей" }, ]; export default function BookingsPage() { - const [tab, setTab] = useState("classes"); + const [tab, setTab] = useState("reminders"); return (
@@ -588,6 +777,7 @@ export default function BookingsPage() { {/* Tab content */}
+ {tab === "reminders" && } {tab === "classes" && } {tab === "master-classes" && } {tab === "open-day" && } diff --git a/src/app/api/admin/reminders/route.ts b/src/app/api/admin/reminders/route.ts new file mode 100644 index 0000000..7437a6c --- /dev/null +++ b/src/app/api/admin/reminders/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUpcomingReminders, setReminderStatus } from "@/lib/db"; +import type { ReminderStatus } from "@/lib/db"; + +export async function GET() { + return NextResponse.json(getUpcomingReminders()); +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { table, id, status } = body; + + const validTables = ["mc_registrations", "group_bookings", "open_day_bookings"]; + const validStatuses = ["pending", "coming", "cancelled", null]; + + if (!validTables.includes(table)) { + return NextResponse.json({ error: "Invalid table" }, { status: 400 }); + } + if (!id || typeof id !== "number") { + return NextResponse.json({ error: "id is required" }, { status: 400 }); + } + if (!validStatuses.includes(status)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + } + + setReminderStatus( + table as "mc_registrations" | "group_bookings" | "open_day_bookings", + id, + status as ReminderStatus | null + ); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 6c603c3..5547f44 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -171,6 +171,19 @@ const migrations: Migration[] = [ if (!gbColNames.has("telegram")) db.exec("ALTER TABLE group_bookings ADD COLUMN telegram TEXT"); }, }, + { + version: 8, + name: "add_reminder_status", + up: (db) => { + // reminder_status: null = not reminded, 'pending' = reminded but no answer, 'coming' = confirmed, 'cancelled' = not coming + for (const table of ["mc_registrations", "group_bookings", "open_day_bookings"]) { + const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + if (!cols.some((c) => c.name === "reminder_status")) { + db.exec(`ALTER TABLE ${table} ADD COLUMN reminder_status TEXT`); + } + } + }, + }, ]; function runMigrations(db: Database.Database) { @@ -446,6 +459,7 @@ interface McRegistrationRow { created_at: string; notified_confirm: number; notified_reminder: number; + reminder_status: string | null; } export interface McRegistration { @@ -458,6 +472,7 @@ export interface McRegistration { createdAt: string; notifiedConfirm: boolean; notifiedReminder: boolean; + reminderStatus?: string; } export function addMcRegistration( @@ -506,6 +521,7 @@ function mapMcRow(r: McRegistrationRow): McRegistration { createdAt: r.created_at, notifiedConfirm: !!r.notified_confirm, notifiedReminder: !!r.notified_reminder, + reminderStatus: r.reminder_status ?? undefined, }; } @@ -548,6 +564,7 @@ interface GroupBookingRow { telegram: string | null; notified_confirm: number; notified_reminder: number; + reminder_status: string | null; created_at: string; } @@ -560,6 +577,7 @@ export interface GroupBooking { telegram?: string; notifiedConfirm: boolean; notifiedReminder: boolean; + reminderStatus?: string; createdAt: string; } @@ -593,6 +611,7 @@ export function getGroupBookings(): GroupBooking[] { telegram: r.telegram ?? undefined, notifiedConfirm: !!r.notified_confirm, notifiedReminder: !!r.notified_reminder, + reminderStatus: r.reminder_status ?? undefined, createdAt: r.created_at, })); } @@ -635,6 +654,109 @@ export interface UnreadCounts { total: number; } +// --- Reminder status --- + +export type ReminderStatus = "pending" | "coming" | "cancelled"; + +export function setReminderStatus( + table: "mc_registrations" | "group_bookings" | "open_day_bookings", + id: number, + status: ReminderStatus | null +): void { + const db = getDb(); + db.prepare(`UPDATE ${table} SET reminder_status = ? WHERE id = ?`).run(status, id); +} + +export interface ReminderItem { + id: number; + type: "class" | "master-class" | "open-day"; + table: "mc_registrations" | "group_bookings" | "open_day_bookings"; + name: string; + phone?: string; + instagram?: string; + telegram?: string; + reminderStatus?: string; + eventLabel: string; + eventDate: string; +} + +export function getUpcomingReminders(): ReminderItem[] { + const db = getDb(); + const items: ReminderItem[] = []; + + // Tomorrow and today dates + const now = new Date(); + const today = now.toISOString().split("T")[0]; + const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + + // MC registrations — match slots from sections JSON + try { + const mcSection = db.prepare("SELECT data FROM sections WHERE key = 'masterClasses'").get() as { data: string } | undefined; + if (mcSection) { + const mcData = JSON.parse(mcSection.data) as { items: { title: string; slots: { date: string; startTime?: string }[] }[] }; + // Find MC titles with slots today or tomorrow + const upcomingTitles: { title: string; date: string; time?: string }[] = []; + for (const mc of mcData.items || []) { + for (const slot of mc.slots || []) { + if (slot.date === today || slot.date === tomorrow) { + upcomingTitles.push({ title: mc.title, date: slot.date, time: slot.startTime }); + } + } + } + for (const { title, date, time } of upcomingTitles) { + const rows = db.prepare( + "SELECT * FROM mc_registrations WHERE master_class_title = ?" + ).all(title) as McRegistrationRow[]; + for (const r of rows) { + items.push({ + id: r.id, + type: "master-class", + table: "mc_registrations", + name: r.name, + phone: r.phone ?? undefined, + instagram: r.instagram ?? undefined, + telegram: r.telegram ?? undefined, + reminderStatus: r.reminder_status ?? undefined, + eventLabel: `${title}${time ? ` · ${time}` : ""}`, + eventDate: date, + }); + } + } + } + } catch { /* ignore */ } + + // Open Day bookings — check event date + try { + const events = db.prepare( + "SELECT id, date, title FROM open_day_events WHERE date IN (?, ?) AND active = 1" + ).all(today, tomorrow) as { id: number; date: string; title: string }[]; + for (const ev of events) { + const rows = db.prepare( + `SELECT b.*, c.style as class_style, c.trainer as class_trainer, c.start_time as class_time, c.hall as class_hall + FROM open_day_bookings b + JOIN open_day_classes c ON c.id = b.class_id + WHERE b.event_id = ? AND c.cancelled = 0` + ).all(ev.id) as OpenDayBookingRow[]; + for (const r of rows) { + items.push({ + id: r.id, + type: "open-day", + table: "open_day_bookings", + name: r.name, + phone: r.phone ?? undefined, + 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})`, + eventDate: ev.date, + }); + } + } + } catch { /* ignore */ } + + return items; +} + export function getUnreadBookingCounts(): UnreadCounts { const db = getDb(); const gb = (db.prepare("SELECT COUNT(*) as c FROM group_bookings WHERE notified_confirm = 0").get() as { c: number }).c; @@ -713,6 +835,7 @@ interface OpenDayBookingRow { telegram: string | null; notified_confirm: number; notified_reminder: number; + reminder_status: string | null; created_at: string; class_style?: string; class_trainer?: string; @@ -730,6 +853,7 @@ export interface OpenDayBooking { telegram?: string; notifiedConfirm: boolean; notifiedReminder: boolean; + reminderStatus?: string; createdAt: string; classStyle?: string; classTrainer?: string; @@ -777,6 +901,7 @@ function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking { telegram: r.telegram ?? undefined, notifiedConfirm: !!r.notified_confirm, notifiedReminder: !!r.notified_reminder, + reminderStatus: r.reminder_status ?? undefined, createdAt: r.created_at, classStyle: r.class_style ?? undefined, classTrainer: r.class_trainer ?? undefined,