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:
@@ -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 />}
|
||||
|
||||
36
src/app/api/admin/reminders/route.ts
Normal file
36
src/app/api/admin/reminders/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
125
src/lib/db.ts
125
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,
|
||||
|
||||
Reference in New Issue
Block a user