642 lines
22 KiB
TypeScript
642 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||
import {
|
||
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
|
||
} from "lucide-react";
|
||
import { adminFetch } from "@/lib/csrf";
|
||
import { ParticipantLimits, SelectField } from "../_components/FormField";
|
||
|
||
// --- Types ---
|
||
|
||
interface OpenDayEvent {
|
||
id: number;
|
||
date: string;
|
||
title: string;
|
||
description?: string;
|
||
pricePerClass: number;
|
||
discountPrice: number;
|
||
discountThreshold: number;
|
||
minBookings: number;
|
||
maxParticipants: 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;
|
||
maxParticipants: number;
|
||
}
|
||
|
||
|
||
// --- 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>
|
||
<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 sm:max-w-xs"
|
||
/>
|
||
</div>
|
||
|
||
{/* Discount toggle + fields */}
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (event.discountPrice > 0) onChange({ discountPrice: 0, discountThreshold: 0 });
|
||
else onChange({ discountPrice: event.pricePerClass - 5, discountThreshold: 3 });
|
||
}}
|
||
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||
event.discountPrice > 0
|
||
? "bg-gold/15 text-gold border border-gold/30"
|
||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||
}`}
|
||
>
|
||
<Sparkles size={14} />
|
||
{event.discountPrice > 0 ? "Скидка включена" : "Добавить скидку"}
|
||
</button>
|
||
{event.discountPrice > 0 && (
|
||
<div className="grid gap-4 sm:grid-cols-2 mt-3">
|
||
<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>
|
||
)}
|
||
</div>
|
||
|
||
<ParticipantLimits
|
||
min={event.minBookings}
|
||
max={event.maxParticipants ?? 0}
|
||
onMinChange={(v) => onChange({ minBookings: v })}
|
||
onMaxChange={(v) => onChange({ maxParticipants: v })}
|
||
/>
|
||
|
||
<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.discountPrice > 0 && event.discountThreshold > 0 && `, от ${event.discountThreshold} — ${event.discountPrice} BYN`}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- New Class Form (create only on save) ---
|
||
|
||
function NewClassForm({
|
||
startTime,
|
||
trainers,
|
||
styles,
|
||
onSave,
|
||
onCancel,
|
||
}: {
|
||
startTime: string;
|
||
trainers: string[];
|
||
styles: string[];
|
||
onSave: (data: { trainer: string; style: string; endTime: string }) => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
const [style, setStyle] = useState("");
|
||
const [trainer, setTrainer] = useState("");
|
||
const endTime = addHour(startTime);
|
||
const formRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
}, []);
|
||
|
||
// Auto-save on click outside
|
||
useEffect(() => {
|
||
function handleClickOutside(e: MouseEvent) {
|
||
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||
if (style && trainer) onSave({ trainer, style, endTime });
|
||
else onCancel();
|
||
}
|
||
}
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [style, trainer, endTime, onSave, onCancel]);
|
||
|
||
const canSave = style && trainer;
|
||
|
||
return (
|
||
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
|
||
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||
<div className="flex gap-1 justify-end">
|
||
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
|
||
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
|
||
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button>
|
||
</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 rounded-lg">
|
||
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||
<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="flex items-center gap-1.5">
|
||
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
|
||
<span className="text-[10px] text-neutral-500">{cls.startTime}–{cls.endTime}</span>
|
||
</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 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`}
|
||
title={cls.cancelled ? "Восстановить" : "Отменить"}
|
||
>
|
||
{cls.cancelled ? <RotateCcw size={10} /> : <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 [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
|
||
const timeSlots = generateTimeSlots(10, 22);
|
||
|
||
// Build lookup: time -> class for selected hall
|
||
const hallClasses = useMemo(() => {
|
||
const map: Record<string, OpenDayClass> = {};
|
||
for (const cls of classes) {
|
||
if (cls.hall === selectedHall) map[cls.startTime] = cls;
|
||
}
|
||
return map;
|
||
}, [classes, selectedHall]);
|
||
|
||
// Count classes per hall for the tab badges
|
||
const hallCounts = useMemo(() => {
|
||
const counts: Record<string, number> = {};
|
||
for (const hall of halls) counts[hall] = 0;
|
||
for (const cls of classes) counts[cls.hall] = (counts[cls.hall] || 0) + 1;
|
||
return counts;
|
||
}, [classes, halls]);
|
||
|
||
const [creatingTime, setCreatingTime] = useState<string | null>(null);
|
||
|
||
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
|
||
await adminFetch("/api/admin/open-day/classes", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||
});
|
||
setCreatingTime(null);
|
||
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>
|
||
) : (
|
||
<>
|
||
{/* Hall selector */}
|
||
<div className="flex gap-2 flex-wrap">
|
||
{halls.map((hall) => (
|
||
<button
|
||
key={hall}
|
||
onClick={() => setSelectedHall(hall)}
|
||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||
selectedHall === hall
|
||
? "bg-gold/20 text-gold border border-gold/40"
|
||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||
}`}
|
||
>
|
||
{hall}
|
||
{hallCounts[hall] > 0 && (
|
||
<span className={`ml-1.5 ${selectedHall === hall ? "text-gold/60" : "text-neutral-600"}`}>
|
||
{hallCounts[hall]}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Time slots for selected hall */}
|
||
<div className="space-y-1">
|
||
{timeSlots.map((time) => {
|
||
const cls = hallClasses[time];
|
||
return (
|
||
<div key={time} className="flex items-start gap-3 border-t border-white/5 py-1.5">
|
||
<span className="text-xs text-neutral-500 w-12 pt-1.5 shrink-0">{time}</span>
|
||
<div className="flex-1">
|
||
{cls ? (
|
||
<ClassCell
|
||
cls={cls}
|
||
minBookings={minBookings}
|
||
trainers={trainers}
|
||
styles={styles}
|
||
onUpdate={updateClass}
|
||
onDelete={deleteClass}
|
||
onCancel={cancelClass}
|
||
/>
|
||
) : creatingTime === time ? (
|
||
<NewClassForm
|
||
startTime={time}
|
||
trainers={trainers}
|
||
styles={styles}
|
||
onSave={(data) => confirmCreate(time, data)}
|
||
onCancel={() => setCreatingTime(null)}
|
||
/>
|
||
) : (
|
||
<button
|
||
onClick={() => setCreatingTime(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>
|
||
)}
|
||
</div>
|
||
</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 [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
|
||
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);
|
||
try {
|
||
const res = await adminFetch("/api/admin/open-day", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(updated),
|
||
});
|
||
setSaveStatus(res.ok ? "saved" : "error");
|
||
} catch {
|
||
setSaveStatus("error");
|
||
}
|
||
setSaving(false);
|
||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||
}, 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,
|
||
maxParticipants: 0,
|
||
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">
|
||
{saveStatus !== "idle" && (
|
||
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||
saveStatus === "saved" ? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200" : "bg-red-950/90 border-red-500/30 text-red-200"
|
||
}`}>
|
||
{saveStatus === "saved" ? "Сохранено" : "Ошибка сохранения"}
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold">День открытых дверей</h1>
|
||
</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)}
|
||
/>
|
||
|
||
</div>
|
||
);
|
||
}
|