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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:07:00 +03:00
parent b94ee69033
commit 4e766d6957
3 changed files with 354 additions and 3 deletions

View File

@@ -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<ReminderStatus, { label: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }> = {
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<ReminderItem[]>([]);
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 <LoadingSpinner />;
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 (
<div className="py-12 text-center">
<Bell size={32} className="mx-auto text-neutral-600 mb-3" />
<p className="text-neutral-400">Нет напоминаний все на контроле</p>
<p className="text-xs text-neutral-600 mt-1">Здесь появятся записи на сегодня и завтра</p>
</div>
);
}
return (
<div className="space-y-6">
{[
{ label: "Сегодня", date: today, items: todayItems },
{ label: "Завтра", date: tomorrow, items: tomorrowItems },
]
.filter((group) => group.items.length > 0)
.map((group) => {
const stats = countByStatus(group.items);
return (
<div key={group.date}>
<div className="flex items-center gap-3 mb-3">
<h3 className="text-sm font-bold text-white">{group.label}</h3>
<span className="text-[10px] text-neutral-500">
{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
</span>
<div className="flex gap-2 ml-auto text-[10px]">
{stats.coming > 0 && <span className="text-emerald-400">{stats.coming} придёт</span>}
{stats.cancelled > 0 && <span className="text-red-400">{stats.cancelled} не придёт</span>}
{stats.pending > 0 && <span className="text-neutral-400">{stats.pending} нет ответа</span>}
{stats.notAsked > 0 && <span className="text-gold">{stats.notAsked} не спрошены</span>}
</div>
</div>
<div className="space-y-1.5">
{group.items.map((item) => {
const typeConf = TYPE_CONFIG[item.type];
const TypeIcon = typeConf.icon;
const currentStatus = item.reminderStatus as ReminderStatus | undefined;
return (
<div
key={`${item.table}-${item.id}`}
className={`rounded-xl border p-3 transition-colors ${
!currentStatus
? "border-gold/20 bg-gold/[0.03]"
: currentStatus === "coming"
? "border-emerald-500/15 bg-emerald-500/[0.02]"
: currentStatus === "cancelled"
? "border-red-500/15 bg-red-500/[0.02] opacity-60"
: "border-white/10 bg-neutral-900"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{item.name}</span>
{item.phone && (
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{item.phone}
</a>
)}
{item.instagram && (
<a
href={`https://ig.me/m/${item.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} />{item.instagram}
</a>
)}
{item.telegram && (
<a
href={`https://t.me/${item.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} />{item.telegram}
</a>
)}
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span className={`inline-flex items-center gap-1 text-[10px] ${typeConf.color}`}>
<TypeIcon size={10} />
{item.eventLabel}
</span>
<div className="flex gap-1 ml-auto">
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
const conf = STATUS_CONFIG[st];
const Icon = conf.icon;
const active = currentStatus === st;
return (
<button
key={st}
onClick={() => setStatus(item, active ? null : st)}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
active
? `${conf.bg} ${conf.color} border ${conf.border}`
: "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300"
}`}
>
<Icon size={10} />
{conf.label}
</button>
);
})}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// --- 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<Tab>("classes");
const [tab, setTab] = useState<Tab>("reminders");
return (
<div>
@@ -588,6 +777,7 @@ export default function BookingsPage() {
{/* Tab content */}
<div className="mt-4">
{tab === "reminders" && <RemindersTab />}
{tab === "classes" && <GroupBookingsTab />}
{tab === "master-classes" && <McRegistrationsTab />}
{tab === "open-day" && <OpenDayBookingsTab />}

View File

@@ -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 });
}
}

View File

@@ -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,