feat: add booking management, Open Day, unified signup modal

- MC registrations: notification toggles (confirm/remind) with urgency
- Group bookings: save to DB from BookingModal, admin CRUD at /admin/bookings
- Open Day: full event system with schedule grid (halls × time), per-class
  booking, discount pricing (30 BYN / 20 BYN from 3+), auto-cancel threshold
- Unified SignupModal replaces 3 separate forms — consistent fields
  (name, phone, instagram, telegram), Instagram DM fallback on network error
- Centralized /admin/bookings page with 3 tabs (classes, MC, Open Day),
  collapsible sections, notification toggles, filter chips
- Unread booking badge on sidebar + dashboard widget with per-type breakdown
- Pricing: contact hint (Instagram/Telegram/phone) on price & rental tabs,
  admin toggle to show/hide
- DB migrations 5-7: group_bookings table, open_day tables, unified fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:58:04 +03:00
parent 7497ede2fd
commit b94ee69033
31 changed files with 3198 additions and 407 deletions

View File

@@ -80,6 +80,97 @@ const migrations: Migration[] = [
`);
},
},
{
version: 4,
name: "add_mc_notification_tracking",
up: (db) => {
const cols = db.prepare("PRAGMA table_info(mc_registrations)").all() as { name: string }[];
const colNames = new Set(cols.map((c) => c.name));
if (!colNames.has("notified_confirm"))
db.exec("ALTER TABLE mc_registrations ADD COLUMN notified_confirm INTEGER NOT NULL DEFAULT 0");
if (!colNames.has("notified_reminder"))
db.exec("ALTER TABLE mc_registrations ADD COLUMN notified_reminder INTEGER NOT NULL DEFAULT 0");
},
},
{
version: 5,
name: "create_group_bookings",
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS group_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT NOT NULL,
group_info TEXT,
notified_confirm INTEGER NOT NULL DEFAULT 0,
notified_reminder INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
`);
},
},
{
version: 6,
name: "create_open_day_tables",
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS open_day_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'День открытых дверей',
description TEXT,
price_per_class INTEGER NOT NULL DEFAULT 30,
discount_price INTEGER NOT NULL DEFAULT 20,
discount_threshold INTEGER NOT NULL DEFAULT 3,
min_bookings INTEGER NOT NULL DEFAULT 4,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS open_day_classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES open_day_events(id) ON DELETE CASCADE,
hall TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
trainer TEXT NOT NULL,
style TEXT NOT NULL,
cancelled INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE(event_id, hall, start_time)
);
CREATE TABLE IF NOT EXISTS open_day_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES open_day_classes(id) ON DELETE CASCADE,
event_id INTEGER NOT NULL REFERENCES open_day_events(id) ON DELETE CASCADE,
name TEXT NOT NULL,
phone TEXT NOT NULL,
instagram TEXT,
telegram TEXT,
notified_confirm INTEGER NOT NULL DEFAULT 0,
notified_reminder INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(class_id, phone)
);
`);
},
},
{
version: 7,
name: "unify_booking_fields",
up: (db) => {
// Add phone to mc_registrations
const mcCols = db.prepare("PRAGMA table_info(mc_registrations)").all() as { name: string }[];
const mcColNames = new Set(mcCols.map((c) => c.name));
if (!mcColNames.has("phone")) db.exec("ALTER TABLE mc_registrations ADD COLUMN phone TEXT");
// Add instagram/telegram to group_bookings
const gbCols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
const gbColNames = new Set(gbCols.map((c) => c.name));
if (!gbColNames.has("instagram")) db.exec("ALTER TABLE group_bookings ADD COLUMN instagram TEXT");
if (!gbColNames.has("telegram")) db.exec("ALTER TABLE group_bookings ADD COLUMN telegram TEXT");
},
},
];
function runMigrations(db: Database.Database) {
@@ -350,8 +441,11 @@ interface McRegistrationRow {
master_class_title: string;
name: string;
instagram: string;
phone: string | null;
telegram: string | null;
created_at: string;
notified_confirm: number;
notified_reminder: number;
}
export interface McRegistration {
@@ -359,23 +453,27 @@ export interface McRegistration {
masterClassTitle: string;
name: string;
instagram: string;
phone?: string;
telegram?: string;
createdAt: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
}
export function addMcRegistration(
masterClassTitle: string,
name: string,
instagram: string,
telegram?: string
telegram?: string,
phone?: string
): number {
const db = getDb();
const result = db
.prepare(
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram)
VALUES (?, ?, ?, ?)`
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone)
VALUES (?, ?, ?, ?, ?)`
)
.run(masterClassTitle, name, instagram, telegram || null);
.run(masterClassTitle, name, instagram, telegram || null, phone || null);
return result.lastInsertRowid as number;
}
@@ -386,14 +484,29 @@ export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
"SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC"
)
.all(masterClassTitle) as McRegistrationRow[];
return rows.map((r) => ({
return rows.map(mapMcRow);
}
export function getAllMcRegistrations(): McRegistration[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM mc_registrations ORDER BY created_at DESC")
.all() as McRegistrationRow[];
return rows.map(mapMcRow);
}
function mapMcRow(r: McRegistrationRow): McRegistration {
return {
id: r.id,
masterClassTitle: r.master_class_title,
name: r.name,
instagram: r.instagram,
phone: r.phone ?? undefined,
telegram: r.telegram ?? undefined,
createdAt: r.created_at,
}));
notifiedConfirm: !!r.notified_confirm,
notifiedReminder: !!r.notified_reminder,
};
}
export function updateMcRegistration(
@@ -408,9 +521,484 @@ export function updateMcRegistration(
).run(name, instagram, telegram || null, id);
}
export function toggleMcNotification(
id: number,
field: "notified_confirm" | "notified_reminder",
value: boolean
): void {
const db = getDb();
db.prepare(
`UPDATE mc_registrations SET ${field} = ? WHERE id = ?`
).run(value ? 1 : 0, id);
}
export function deleteMcRegistration(id: number): void {
const db = getDb();
db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id);
}
// --- Group Bookings ---
interface GroupBookingRow {
id: number;
name: string;
phone: string;
group_info: string | null;
instagram: string | null;
telegram: string | null;
notified_confirm: number;
notified_reminder: number;
created_at: string;
}
export interface GroupBooking {
id: number;
name: string;
phone: string;
groupInfo?: string;
instagram?: string;
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
createdAt: string;
}
export function addGroupBooking(
name: string,
phone: string,
groupInfo?: string,
instagram?: string,
telegram?: string
): number {
const db = getDb();
const result = db
.prepare(
"INSERT INTO group_bookings (name, phone, group_info, instagram, telegram) VALUES (?, ?, ?, ?, ?)"
)
.run(name, phone, groupInfo || null, instagram || null, telegram || null);
return result.lastInsertRowid as number;
}
export function getGroupBookings(): GroupBooking[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM group_bookings ORDER BY created_at DESC")
.all() as GroupBookingRow[];
return rows.map((r) => ({
id: r.id,
name: r.name,
phone: r.phone,
groupInfo: r.group_info ?? undefined,
instagram: r.instagram ?? undefined,
telegram: r.telegram ?? undefined,
notifiedConfirm: !!r.notified_confirm,
notifiedReminder: !!r.notified_reminder,
createdAt: r.created_at,
}));
}
export function updateGroupBooking(
id: number,
name: string,
phone: string,
groupInfo?: string
): void {
const db = getDb();
db.prepare(
"UPDATE group_bookings SET name = ?, phone = ?, group_info = ? WHERE id = ?"
).run(name, phone, groupInfo || null, id);
}
export function toggleGroupBookingNotification(
id: number,
field: "notified_confirm" | "notified_reminder",
value: boolean
): void {
const db = getDb();
db.prepare(`UPDATE group_bookings SET ${field} = ? WHERE id = ?`).run(
value ? 1 : 0,
id
);
}
export function deleteGroupBooking(id: number): void {
const db = getDb();
db.prepare("DELETE FROM group_bookings WHERE id = ?").run(id);
}
// --- Unread booking counts (lightweight) ---
export interface UnreadCounts {
groupBookings: number;
mcRegistrations: number;
openDayBookings: number;
total: number;
}
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;
const mc = (db.prepare("SELECT COUNT(*) as c FROM mc_registrations WHERE notified_confirm = 0").get() as { c: number }).c;
let od = 0;
// Check if open_day_bookings table exists (might not if no migration yet)
try {
od = (db.prepare("SELECT COUNT(*) as c FROM open_day_bookings WHERE notified_confirm = 0").get() as { c: number }).c;
} catch { /* table doesn't exist yet */ }
return { groupBookings: gb, mcRegistrations: mc, openDayBookings: od, total: gb + mc + od };
}
// --- Open Day Events ---
interface OpenDayEventRow {
id: number;
date: string;
title: string;
description: string | null;
price_per_class: number;
discount_price: number;
discount_threshold: number;
min_bookings: number;
active: number;
created_at: string;
updated_at: string;
}
export interface OpenDayEvent {
id: number;
date: string;
title: string;
description?: string;
pricePerClass: number;
discountPrice: number;
discountThreshold: number;
minBookings: number;
active: boolean;
}
interface OpenDayClassRow {
id: number;
event_id: number;
hall: string;
start_time: string;
end_time: string;
trainer: string;
style: string;
cancelled: number;
sort_order: number;
booking_count?: number;
}
export interface OpenDayClass {
id: number;
eventId: number;
hall: string;
startTime: string;
endTime: string;
trainer: string;
style: string;
cancelled: boolean;
sortOrder: number;
bookingCount: number;
}
interface OpenDayBookingRow {
id: number;
class_id: number;
event_id: number;
name: string;
phone: string;
instagram: string | null;
telegram: string | null;
notified_confirm: number;
notified_reminder: number;
created_at: string;
class_style?: string;
class_trainer?: string;
class_time?: string;
class_hall?: string;
}
export interface OpenDayBooking {
id: number;
classId: number;
eventId: number;
name: string;
phone: string;
instagram?: string;
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
createdAt: string;
classStyle?: string;
classTrainer?: string;
classTime?: string;
classHall?: string;
}
function mapEventRow(r: OpenDayEventRow): OpenDayEvent {
return {
id: r.id,
date: r.date,
title: r.title,
description: r.description ?? undefined,
pricePerClass: r.price_per_class,
discountPrice: r.discount_price,
discountThreshold: r.discount_threshold,
minBookings: r.min_bookings,
active: !!r.active,
};
}
function mapClassRow(r: OpenDayClassRow): OpenDayClass {
return {
id: r.id,
eventId: r.event_id,
hall: r.hall,
startTime: r.start_time,
endTime: r.end_time,
trainer: r.trainer,
style: r.style,
cancelled: !!r.cancelled,
sortOrder: r.sort_order,
bookingCount: r.booking_count ?? 0,
};
}
function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking {
return {
id: r.id,
classId: r.class_id,
eventId: r.event_id,
name: r.name,
phone: r.phone,
instagram: r.instagram ?? undefined,
telegram: r.telegram ?? undefined,
notifiedConfirm: !!r.notified_confirm,
notifiedReminder: !!r.notified_reminder,
createdAt: r.created_at,
classStyle: r.class_style ?? undefined,
classTrainer: r.class_trainer ?? undefined,
classTime: r.class_time ?? undefined,
classHall: r.class_hall ?? undefined,
};
}
export function createOpenDayEvent(data: {
date: string;
title?: string;
description?: string;
pricePerClass?: number;
discountPrice?: number;
discountThreshold?: number;
minBookings?: number;
}): number {
const db = getDb();
const result = db
.prepare(
`INSERT INTO open_day_events (date, title, description, price_per_class, discount_price, discount_threshold, min_bookings)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
data.date,
data.title || "День открытых дверей",
data.description || null,
data.pricePerClass ?? 30,
data.discountPrice ?? 20,
data.discountThreshold ?? 3,
data.minBookings ?? 4
);
return result.lastInsertRowid as number;
}
export function getOpenDayEvents(): OpenDayEvent[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM open_day_events ORDER BY date DESC")
.all() as OpenDayEventRow[];
return rows.map(mapEventRow);
}
export function getOpenDayEvent(id: number): OpenDayEvent | null {
const db = getDb();
const row = db
.prepare("SELECT * FROM open_day_events WHERE id = ?")
.get(id) as OpenDayEventRow | undefined;
return row ? mapEventRow(row) : null;
}
export function getActiveOpenDayEvent(): OpenDayEvent | null {
const db = getDb();
const row = db
.prepare(
"SELECT * FROM open_day_events WHERE active = 1 AND date >= date('now') ORDER BY date ASC LIMIT 1"
)
.get() as OpenDayEventRow | undefined;
return row ? mapEventRow(row) : null;
}
export function updateOpenDayEvent(
id: number,
data: Partial<{
date: string;
title: string;
description: string;
pricePerClass: number;
discountPrice: number;
discountThreshold: number;
minBookings: number;
active: boolean;
}>
): void {
const db = getDb();
const sets: string[] = [];
const vals: unknown[] = [];
if (data.date !== undefined) { sets.push("date = ?"); vals.push(data.date); }
if (data.title !== undefined) { sets.push("title = ?"); vals.push(data.title); }
if (data.description !== undefined) { sets.push("description = ?"); vals.push(data.description || null); }
if (data.pricePerClass !== undefined) { sets.push("price_per_class = ?"); vals.push(data.pricePerClass); }
if (data.discountPrice !== undefined) { sets.push("discount_price = ?"); vals.push(data.discountPrice); }
if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); }
if (data.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); }
if (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); }
if (sets.length === 0) return;
sets.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE open_day_events SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
}
export function deleteOpenDayEvent(id: number): void {
const db = getDb();
db.prepare("DELETE FROM open_day_events WHERE id = ?").run(id);
}
// --- Open Day Classes ---
export function addOpenDayClass(
eventId: number,
data: { hall: string; startTime: string; endTime: string; trainer: string; style: string }
): number {
const db = getDb();
const maxOrder = (
db.prepare("SELECT MAX(sort_order) as m FROM open_day_classes WHERE event_id = ?").get(eventId) as { m: number | null }
).m ?? -1;
const result = db
.prepare(
`INSERT INTO open_day_classes (event_id, hall, start_time, end_time, trainer, style, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(eventId, data.hall, data.startTime, data.endTime, data.trainer, data.style, maxOrder + 1);
return result.lastInsertRowid as number;
}
export function getOpenDayClasses(eventId: number): OpenDayClass[] {
const db = getDb();
const rows = db
.prepare(
`SELECT c.*, COALESCE(b.cnt, 0) as booking_count
FROM open_day_classes c
LEFT JOIN (SELECT class_id, COUNT(*) as cnt FROM open_day_bookings GROUP BY class_id) b ON b.class_id = c.id
WHERE c.event_id = ?
ORDER BY c.hall, c.start_time`
)
.all(eventId) as OpenDayClassRow[];
return rows.map(mapClassRow);
}
export function updateOpenDayClass(
id: number,
data: Partial<{ hall: string; startTime: string; endTime: string; trainer: string; style: string; cancelled: boolean; sortOrder: number }>
): void {
const db = getDb();
const sets: string[] = [];
const vals: unknown[] = [];
if (data.hall !== undefined) { sets.push("hall = ?"); vals.push(data.hall); }
if (data.startTime !== undefined) { sets.push("start_time = ?"); vals.push(data.startTime); }
if (data.endTime !== undefined) { sets.push("end_time = ?"); vals.push(data.endTime); }
if (data.trainer !== undefined) { sets.push("trainer = ?"); vals.push(data.trainer); }
if (data.style !== undefined) { sets.push("style = ?"); vals.push(data.style); }
if (data.cancelled !== undefined) { sets.push("cancelled = ?"); vals.push(data.cancelled ? 1 : 0); }
if (data.sortOrder !== undefined) { sets.push("sort_order = ?"); vals.push(data.sortOrder); }
if (sets.length === 0) return;
vals.push(id);
db.prepare(`UPDATE open_day_classes SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
}
export function deleteOpenDayClass(id: number): void {
const db = getDb();
db.prepare("DELETE FROM open_day_classes WHERE id = ?").run(id);
}
// --- Open Day Bookings ---
export function addOpenDayBooking(
classId: number,
eventId: number,
data: { name: string; phone: string; instagram?: string; telegram?: string }
): number {
const db = getDb();
const result = db
.prepare(
`INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null);
return result.lastInsertRowid as number;
}
export function getOpenDayBookings(eventId: number): OpenDayBooking[] {
const db = getDb();
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 = ?
ORDER BY b.created_at DESC`
)
.all(eventId) as OpenDayBookingRow[];
return rows.map(mapBookingRow);
}
export function getOpenDayBookingCountsByClass(eventId: number): Record<number, number> {
const db = getDb();
const rows = db
.prepare("SELECT class_id, COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? GROUP BY class_id")
.all(eventId) as { class_id: number; cnt: number }[];
const result: Record<number, number> = {};
for (const r of rows) result[r.class_id] = r.cnt;
return result;
}
export function getPersonOpenDayBookings(eventId: number, phone: string): number {
const db = getDb();
const row = db
.prepare("SELECT COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? AND phone = ?")
.get(eventId, phone) as { cnt: number };
return row.cnt;
}
export function toggleOpenDayNotification(
id: number,
field: "notified_confirm" | "notified_reminder",
value: boolean
): void {
const db = getDb();
db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id);
}
export function deleteOpenDayBooking(id: number): void {
const db = getDb();
db.prepare("DELETE FROM open_day_bookings WHERE id = ?").run(id);
}
export function isOpenDayClassBookedByPhone(classId: number, phone: string): boolean {
const db = getDb();
const row = db
.prepare("SELECT id FROM open_day_bookings WHERE class_id = ? AND phone = ? LIMIT 1")
.get(classId, phone) as { id: number } | undefined;
return !!row;
}
export { SECTION_KEYS };