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:
711
src/app/admin/open-day/page.tsx
Normal file
711
src/app/admin/open-day/page.tsx
Normal file
@@ -0,0 +1,711 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, ChevronDown, ChevronUp,
|
||||
Phone, Instagram, Send,
|
||||
} from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { NotifyToggle } from "../_components/NotifyToggle";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface OpenDayEvent {
|
||||
id: number;
|
||||
date: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
pricePerClass: number;
|
||||
discountPrice: number;
|
||||
discountThreshold: number;
|
||||
minBookings: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface OpenDayClass {
|
||||
id: number;
|
||||
eventId: number;
|
||||
hall: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
trainer: string;
|
||||
style: string;
|
||||
cancelled: boolean;
|
||||
sortOrder: number;
|
||||
bookingCount: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function generateTimeSlots(startHour: number, endHour: number): string[] {
|
||||
const slots: string[] = [];
|
||||
for (let h = startHour; h < endHour; h++) {
|
||||
slots.push(`${h.toString().padStart(2, "0")}:00`);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
function addHour(time: string): string {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return `${(h + 1).toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// --- Event Settings ---
|
||||
|
||||
function EventSettings({
|
||||
event,
|
||||
onChange,
|
||||
}: {
|
||||
event: OpenDayEvent;
|
||||
onChange: (patch: Partial<OpenDayEvent>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Calendar size={18} className="text-gold" />
|
||||
Настройки мероприятия
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={event.title}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
|
||||
<input
|
||||
type="date"
|
||||
value={event.date}
|
||||
onChange={(e) => onChange({ date: e.target.value })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
|
||||
<textarea
|
||||
value={event.description || ""}
|
||||
onChange={(e) => onChange({ description: e.target.value || undefined })}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
|
||||
placeholder="Описание мероприятия..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.pricePerClass}
|
||||
onChange={(e) => onChange({ pricePerClass: parseInt(e.target.value) || 0 })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Скидка (BYN)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.discountPrice}
|
||||
onChange={(e) => onChange({ discountPrice: parseInt(e.target.value) || 0 })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.discountThreshold}
|
||||
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Мин. записей</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.minBookings}
|
||||
onChange={(e) => onChange({ minBookings: parseInt(e.target.value) || 1 })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ active: !event.active })}
|
||||
className={`relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||
event.active
|
||||
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10"
|
||||
}`}
|
||||
>
|
||||
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
|
||||
{event.active ? "Опубликовано" : "Черновик"}
|
||||
</button>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{event.pricePerClass} BYN / занятие, от {event.discountThreshold} — {event.discountPrice} BYN
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Class Grid Cell ---
|
||||
|
||||
function ClassCell({
|
||||
cls,
|
||||
minBookings,
|
||||
trainers,
|
||||
styles,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}: {
|
||||
cls: OpenDayClass;
|
||||
minBookings: number;
|
||||
trainers: string[];
|
||||
styles: string[];
|
||||
onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onCancel: (id: number) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [trainer, setTrainer] = useState(cls.trainer);
|
||||
const [style, setStyle] = useState(cls.style);
|
||||
|
||||
const atRisk = cls.bookingCount < minBookings && !cls.cancelled;
|
||||
|
||||
function save() {
|
||||
if (trainer.trim() && style.trim()) {
|
||||
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="p-2 space-y-1.5">
|
||||
<select
|
||||
value={trainer}
|
||||
onChange={(e) => setTrainer(e.target.value)}
|
||||
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
|
||||
>
|
||||
<option value="">Тренер...</option>
|
||||
{trainers.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={style}
|
||||
onChange={(e) => setStyle(e.target.value)}
|
||||
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
|
||||
>
|
||||
<option value="">Стиль...</option>
|
||||
{styles.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
|
||||
Отмена
|
||||
</button>
|
||||
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
|
||||
cls.cancelled
|
||||
? "bg-neutral-800/30 opacity-50"
|
||||
: atRisk
|
||||
? "bg-red-500/5 border border-red-500/20"
|
||||
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
|
||||
}`}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<div className="text-xs font-medium text-white truncate">{cls.style}</div>
|
||||
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className={`text-[10px] font-medium ${
|
||||
cls.cancelled
|
||||
? "text-neutral-500 line-through"
|
||||
: atRisk
|
||||
? "text-red-400"
|
||||
: "text-emerald-400"
|
||||
}`}>
|
||||
{cls.bookingCount} чел.
|
||||
</span>
|
||||
{atRisk && !cls.cancelled && (
|
||||
<span className="text-[9px] text-red-400">мин. {minBookings}</span>
|
||||
)}
|
||||
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
|
||||
className="rounded p-0.5 text-neutral-500 hover:text-yellow-400"
|
||||
title={cls.cancelled ? "Восстановить" : "Отменить"}
|
||||
>
|
||||
<Ban size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
|
||||
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Schedule Grid ---
|
||||
|
||||
function ScheduleGrid({
|
||||
eventId,
|
||||
minBookings,
|
||||
halls,
|
||||
classes,
|
||||
trainers,
|
||||
styles,
|
||||
onClassesChange,
|
||||
}: {
|
||||
eventId: number;
|
||||
minBookings: number;
|
||||
halls: string[];
|
||||
classes: OpenDayClass[];
|
||||
trainers: string[];
|
||||
styles: string[];
|
||||
onClassesChange: () => void;
|
||||
}) {
|
||||
const timeSlots = generateTimeSlots(10, 22);
|
||||
|
||||
// Build lookup: hall -> time -> class
|
||||
const grid = useMemo(() => {
|
||||
const map: Record<string, Record<string, OpenDayClass>> = {};
|
||||
for (const hall of halls) map[hall] = {};
|
||||
for (const cls of classes) {
|
||||
if (!map[cls.hall]) map[cls.hall] = {};
|
||||
map[cls.hall][cls.startTime] = cls;
|
||||
}
|
||||
return map;
|
||||
}, [classes, halls]);
|
||||
|
||||
async function addClass(hall: string, startTime: string) {
|
||||
const endTime = addHour(startTime);
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId, hall, startTime, endTime, trainer: "—", style: "—" }),
|
||||
});
|
||||
onClassesChange();
|
||||
}
|
||||
|
||||
async function updateClass(id: number, data: Partial<OpenDayClass>) {
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...data }),
|
||||
});
|
||||
onClassesChange();
|
||||
}
|
||||
|
||||
async function deleteClass(id: number) {
|
||||
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||
onClassesChange();
|
||||
}
|
||||
|
||||
async function cancelClass(id: number) {
|
||||
const cls = classes.find((c) => c.id === id);
|
||||
if (!cls) return;
|
||||
await updateClass(id, { cancelled: !cls.cancelled });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
|
||||
<h2 className="text-lg font-bold">Расписание</h2>
|
||||
|
||||
{halls.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">Нет залов в расписании. Добавьте локации в разделе «Расписание».</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[500px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-xs text-neutral-500 font-medium pb-2 w-16">Время</th>
|
||||
{halls.map((hall) => (
|
||||
<th key={hall} className="text-left text-xs text-neutral-400 font-medium pb-2 px-1">
|
||||
{hall}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timeSlots.map((time) => (
|
||||
<tr key={time} className="border-t border-white/5">
|
||||
<td className="text-xs text-neutral-500 py-1 pr-2 align-top pt-2">{time}</td>
|
||||
{halls.map((hall) => {
|
||||
const cls = grid[hall]?.[time];
|
||||
return (
|
||||
<td key={hall} className="py-1 px-1 align-top">
|
||||
{cls ? (
|
||||
<ClassCell
|
||||
cls={cls}
|
||||
minBookings={minBookings}
|
||||
trainers={trainers}
|
||||
styles={styles}
|
||||
onUpdate={updateClass}
|
||||
onDelete={deleteClass}
|
||||
onCancel={cancelClass}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => addClass(hall, time)}
|
||||
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
|
||||
>
|
||||
<Plus size={12} className="mx-auto" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bookings Table ---
|
||||
|
||||
function BookingsSection({
|
||||
eventId,
|
||||
eventDate,
|
||||
}: {
|
||||
eventId: number;
|
||||
eventDate: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const reminderUrgent = useMemo(() => {
|
||||
if (!eventDate) return false;
|
||||
const now = Date.now();
|
||||
const twoDays = 2 * 24 * 60 * 60 * 1000;
|
||||
const eventTime = new Date(eventDate + "T10:00").getTime();
|
||||
const diff = eventTime - now;
|
||||
return diff >= 0 && diff <= twoDays;
|
||||
}, [eventDate]);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
adminFetch(`/api/admin/open-day/bookings?eventId=${eventId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: OpenDayBooking[]) => setBookings(data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!open) load();
|
||||
setOpen(!open);
|
||||
}
|
||||
|
||||
async function handleToggle(id: number, field: "notified_confirm" | "notified_reminder") {
|
||||
const b = bookings.find((x) => x.id === id);
|
||||
if (!b) return;
|
||||
const key = field === "notified_confirm" ? "notifiedConfirm" : "notifiedReminder";
|
||||
const newValue = !b[key];
|
||||
setBookings((prev) => prev.map((x) => x.id === id ? { ...x, [key]: newValue } : x));
|
||||
await adminFetch("/api/admin/open-day/bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "toggle-notify", id, field, value: newValue }),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
|
||||
setBookings((prev) => prev.filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
const newCount = bookings.filter((b) => !b.notifiedConfirm).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-2 text-lg font-bold hover:text-gold transition-colors"
|
||||
>
|
||||
{open ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
Записи
|
||||
{bookings.length > 0 && (
|
||||
<span className="text-sm font-normal text-neutral-400">({bookings.length})</span>
|
||||
)}
|
||||
{newCount > 0 && (
|
||||
<span className="rounded-full bg-red-500/20 text-red-400 px-2 py-0.5 text-[10px] font-medium">
|
||||
{newCount} новых
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-neutral-500 text-sm py-4 justify-center">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && bookings.length === 0 && (
|
||||
<p className="text-sm text-neutral-500 text-center py-4">Пока нет записей</p>
|
||||
)}
|
||||
|
||||
{bookings.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className={`rounded-lg p-3 space-y-1.5 ${
|
||||
!b.notifiedConfirm ? "bg-gold/[0.03] border border-gold/20" : "bg-neutral-800/50 border border-white/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||
<span className="font-medium text-white">{b.name}</span>
|
||||
<a
|
||||
href={`tel:${b.phone}`}
|
||||
className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs"
|
||||
>
|
||||
<Phone size={10} />
|
||||
{b.phone}
|
||||
</a>
|
||||
{b.instagram && (
|
||||
<a
|
||||
href={`https://ig.me/m/${b.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} />
|
||||
{b.instagram}
|
||||
</a>
|
||||
)}
|
||||
{b.telegram && (
|
||||
<a
|
||||
href={`https://t.me/${b.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} />
|
||||
{b.telegram}
|
||||
</a>
|
||||
)}
|
||||
<span className="text-[10px] text-neutral-500 ml-auto">
|
||||
{b.classHall} {b.classTime} · {b.classStyle}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(b.id)}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<NotifyToggle
|
||||
confirmed={b.notifiedConfirm}
|
||||
reminded={b.notifiedReminder}
|
||||
reminderUrgent={reminderUrgent && !b.notifiedReminder}
|
||||
onToggleConfirm={() => handleToggle(b.id, "notified_confirm")}
|
||||
onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Page ---
|
||||
|
||||
export default function OpenDayAdminPage() {
|
||||
const [event, setEvent] = useState<OpenDayEvent | null>(null);
|
||||
const [classes, setClasses] = useState<OpenDayClass[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [trainers, setTrainers] = useState<string[]>([]);
|
||||
const [styles, setStyles] = useState<string[]>([]);
|
||||
const [halls, setHalls] = useState<string[]>([]);
|
||||
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null };
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
adminFetch("/api/admin/open-day").then((r) => r.json()),
|
||||
adminFetch("/api/admin/team").then((r) => r.json()),
|
||||
adminFetch("/api/admin/sections/classes").then((r) => r.json()),
|
||||
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
|
||||
])
|
||||
.then(([events, members, classesData, scheduleData]: [OpenDayEvent[], { name: string }[], { items: { name: string }[] }, { locations: { name: string }[] }]) => {
|
||||
if (events.length > 0) {
|
||||
setEvent(events[0]);
|
||||
loadClasses(events[0].id);
|
||||
}
|
||||
setTrainers(members.map((m) => m.name));
|
||||
setStyles(classesData.items.map((c) => c.name));
|
||||
setHalls(scheduleData.locations.map((l) => l.name));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function loadClasses(eventId: number) {
|
||||
adminFetch(`/api/admin/open-day/classes?eventId=${eventId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: OpenDayClass[]) => setClasses(data))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Auto-save event changes
|
||||
const saveEvent = useCallback(
|
||||
(updated: OpenDayEvent) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
await adminFetch("/api/admin/open-day", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
setSaving(false);
|
||||
}, 800);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
function handleEventChange(patch: Partial<OpenDayEvent>) {
|
||||
if (!event) return;
|
||||
const updated = { ...event, ...patch };
|
||||
setEvent(updated);
|
||||
saveEvent(updated);
|
||||
}
|
||||
|
||||
async function createEvent() {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const res = await adminFetch("/api/admin/open-day", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ date: today }),
|
||||
});
|
||||
const { id } = await res.json();
|
||||
setEvent({
|
||||
id,
|
||||
date: today,
|
||||
title: "День открытых дверей",
|
||||
pricePerClass: 30,
|
||||
discountPrice: 20,
|
||||
discountThreshold: 3,
|
||||
minBookings: 4,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (!event) return;
|
||||
await adminFetch(`/api/admin/open-day?id=${event.id}`, { method: "DELETE" });
|
||||
setEvent(null);
|
||||
setClasses([]);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-12 text-neutral-500 justify-center">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h1 className="text-2xl font-bold">День открытых дверей</h1>
|
||||
<p className="mt-2 text-neutral-400">Создайте мероприятие, чтобы начать</p>
|
||||
<button
|
||||
onClick={createEvent}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-gold px-6 py-3 text-sm font-semibold text-black hover:bg-gold-light transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Создать мероприятие
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">День открытых дверей</h1>
|
||||
{saving && <span className="text-xs text-neutral-500">Сохранение...</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={deleteEvent}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EventSettings event={event} onChange={handleEventChange} />
|
||||
|
||||
<ScheduleGrid
|
||||
eventId={event.id}
|
||||
minBookings={event.minBookings}
|
||||
halls={halls}
|
||||
classes={classes}
|
||||
trainers={trainers}
|
||||
styles={styles}
|
||||
onClassesChange={() => loadClasses(event.id)}
|
||||
/>
|
||||
|
||||
<BookingsSection eventId={event.id} eventDate={event.date} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user