Double-submit cookie pattern: login sets bh-csrf-token cookie, proxy.ts validates X-CSRF-Token header on POST/PUT/DELETE to /api/admin/*. New adminFetch() helper in src/lib/csrf.ts auto-includes the header. All admin pages migrated from fetch() to adminFetch(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1228 lines
43 KiB
TypeScript
1228 lines
43 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||
import { SectionEditor } from "../_components/SectionEditor";
|
||
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
||
import { Plus, X, Trash2 } from "lucide-react";
|
||
import { adminFetch } from "@/lib/csrf";
|
||
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
||
|
||
interface ScheduleData {
|
||
title: string;
|
||
locations: ScheduleLocation[];
|
||
}
|
||
|
||
const DAYS = [
|
||
{ day: "Понедельник", dayShort: "ПН" },
|
||
{ day: "Вторник", dayShort: "ВТ" },
|
||
{ day: "Среда", dayShort: "СР" },
|
||
{ day: "Четверг", dayShort: "ЧТ" },
|
||
{ day: "Пятница", dayShort: "ПТ" },
|
||
{ day: "Суббота", dayShort: "СБ" },
|
||
{ day: "Воскресенье", dayShort: "ВС" },
|
||
];
|
||
|
||
const DAY_ORDER: Record<string, number> = Object.fromEntries(
|
||
DAYS.map((d, i) => [d.day, i])
|
||
);
|
||
|
||
const LEVELS = [
|
||
{ value: "", label: "Без уровня" },
|
||
{ value: "Начинающий/Без опыта", label: "Начинающий/Без опыта" },
|
||
{ value: "Продвинутый", label: "Продвинутый" },
|
||
];
|
||
|
||
const GROUP_PALETTE = [
|
||
"bg-rose-500/80 border-rose-400",
|
||
"bg-orange-500/80 border-orange-400",
|
||
"bg-amber-500/80 border-amber-400",
|
||
"bg-yellow-400/80 border-yellow-300",
|
||
"bg-lime-500/80 border-lime-400",
|
||
"bg-emerald-500/80 border-emerald-400",
|
||
"bg-teal-500/80 border-teal-400",
|
||
"bg-cyan-500/80 border-cyan-400",
|
||
"bg-sky-500/80 border-sky-400",
|
||
"bg-blue-500/80 border-blue-400",
|
||
"bg-indigo-500/80 border-indigo-400",
|
||
"bg-violet-500/80 border-violet-400",
|
||
"bg-purple-500/80 border-purple-400",
|
||
"bg-fuchsia-500/80 border-fuchsia-400",
|
||
"bg-pink-500/80 border-pink-400",
|
||
"bg-red-500/80 border-red-400",
|
||
];
|
||
|
||
/** Build a unique group key (trainer + type) */
|
||
function groupKey(cls: ScheduleClass): string {
|
||
return `${cls.trainer}|${cls.type}`;
|
||
}
|
||
|
||
/** Assign a color to each unique group across all days */
|
||
function buildGroupColors(days: ScheduleDay[]): Record<string, string> {
|
||
const seen = new Set<string>();
|
||
const keys: string[] = [];
|
||
for (const day of days) {
|
||
for (const cls of day.classes) {
|
||
const k = groupKey(cls);
|
||
if (!seen.has(k)) {
|
||
seen.add(k);
|
||
keys.push(k);
|
||
}
|
||
}
|
||
}
|
||
const result: Record<string, string> = {};
|
||
keys.forEach((k, i) => {
|
||
result[k] = GROUP_PALETTE[i % GROUP_PALETTE.length];
|
||
});
|
||
return result;
|
||
}
|
||
|
||
// Calendar config
|
||
const HOUR_START = 9;
|
||
const HOUR_END = 23;
|
||
const HOUR_HEIGHT = 60; // px per hour
|
||
const TOTAL_HOURS = HOUR_END - HOUR_START;
|
||
const SNAP_MINUTES = 15;
|
||
|
||
function parseTime(timeStr: string): { h: number; m: number } | null {
|
||
const [h, m] = (timeStr || "").split(":").map(Number);
|
||
if (isNaN(h) || isNaN(m)) return null;
|
||
return { h, m };
|
||
}
|
||
|
||
function timeToMinutes(timeStr: string): number {
|
||
const t = parseTime(timeStr);
|
||
if (!t) return 0;
|
||
return t.h * 60 + t.m;
|
||
}
|
||
|
||
function minutesToY(minutes: number): number {
|
||
return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT;
|
||
}
|
||
|
||
function yToMinutes(y: number): number {
|
||
return Math.round((y / HOUR_HEIGHT) * 60 + HOUR_START * 60);
|
||
}
|
||
|
||
function formatMinutes(minutes: number): string {
|
||
const h = Math.floor(minutes / 60);
|
||
const m = minutes % 60;
|
||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||
}
|
||
|
||
function sortDaysByWeekday(days: ScheduleDay[]): ScheduleDay[] {
|
||
return [...days].sort((a, b) => (DAY_ORDER[a.day] ?? 99) - (DAY_ORDER[b.day] ?? 99));
|
||
}
|
||
|
||
/** Check if two time ranges overlap */
|
||
function hasOverlap(a: ScheduleClass, b: ScheduleClass): boolean {
|
||
const [aStart, aEnd] = a.time.split("–").map((s) => timeToMinutes(s.trim()));
|
||
const [bStart, bEnd] = b.time.split("–").map((s) => timeToMinutes(s.trim()));
|
||
if (!aStart || !aEnd || !bStart || !bEnd) return false;
|
||
return aStart < bEnd && bStart < aEnd;
|
||
}
|
||
|
||
/** Get all overlapping indices for a given class in the list */
|
||
function getOverlaps(classes: ScheduleClass[], index: number): boolean {
|
||
const cls = classes[index];
|
||
for (let i = 0; i < classes.length; i++) {
|
||
if (i === index) continue;
|
||
if (hasOverlap(cls, classes[i])) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---------- Drag state ----------
|
||
interface DragState {
|
||
sourceDayIndex: number;
|
||
classIndex: number;
|
||
/** offset from top of block where user grabbed */
|
||
grabOffsetY: number;
|
||
/** duration in minutes (preserved during drag) */
|
||
durationMin: number;
|
||
/** current preview: snapped start minute */
|
||
previewStartMin: number;
|
||
/** current preview: target day index */
|
||
previewDayIndex: number;
|
||
/** did the pointer actually move? (to distinguish click from drag) */
|
||
moved: boolean;
|
||
}
|
||
|
||
// ---------- Class Block on Calendar ----------
|
||
function ClassBlock({
|
||
cls,
|
||
index,
|
||
isOverlapping,
|
||
isDragging,
|
||
groupColors,
|
||
onClick,
|
||
onDragStart,
|
||
}: {
|
||
cls: ScheduleClass;
|
||
index: number;
|
||
isOverlapping: boolean;
|
||
isDragging: boolean;
|
||
groupColors: Record<string, string>;
|
||
onClick: () => void;
|
||
onDragStart: (e: React.MouseEvent) => void;
|
||
}) {
|
||
const parts = cls.time.split("–");
|
||
const startMin = timeToMinutes(parts[0]?.trim() || "");
|
||
const endMin = timeToMinutes(parts[1]?.trim() || "");
|
||
|
||
if (!startMin || !endMin || endMin <= startMin) return null;
|
||
|
||
const top = minutesToY(startMin);
|
||
const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20);
|
||
const colors = groupColors[groupKey(cls)] ?? "bg-neutral-600/80 border-neutral-500";
|
||
|
||
return (
|
||
<div
|
||
data-class-block
|
||
onMouseDown={onDragStart}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onClick();
|
||
}}
|
||
style={{
|
||
top: `${top}px`,
|
||
height: `${height}px`,
|
||
...(isOverlapping
|
||
? { backgroundImage: "repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(239,68,68,0.35) 4px, rgba(239,68,68,0.35) 8px)" }
|
||
: {}),
|
||
}}
|
||
className={`absolute left-1 right-1 rounded-md border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${
|
||
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
|
||
} ${isDragging ? "opacity-30" : "hover:opacity-90"}`}
|
||
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
|
||
>
|
||
<div className="font-semibold truncate leading-tight">
|
||
{parts[0]?.trim()}–{parts[1]?.trim()}
|
||
</div>
|
||
{height > 30 && (
|
||
<div className="truncate text-white/80 leading-tight">{cls.type}</div>
|
||
)}
|
||
{height > 48 && (
|
||
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
|
||
)}
|
||
{isOverlapping && (
|
||
<div className="text-red-200 font-bold leading-tight">⚠ Пересечение</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- Edit Modal ----------
|
||
/** Same group = matching groupId, or fallback to trainer + type for legacy data */
|
||
function isSameGroup(a: ScheduleClass, b: ScheduleClass): boolean {
|
||
if (a.groupId && b.groupId) return a.groupId === b.groupId;
|
||
return a.trainer === b.trainer && a.type === b.type;
|
||
}
|
||
|
||
function generateGroupId(): string {
|
||
return `g_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
}
|
||
|
||
function ClassModal({
|
||
cls,
|
||
trainers,
|
||
classTypes,
|
||
onSave,
|
||
onDelete,
|
||
onClose,
|
||
allDays,
|
||
currentDay,
|
||
}: {
|
||
cls: ScheduleClass;
|
||
trainers: string[];
|
||
classTypes: string[];
|
||
onSave: (cls: ScheduleClass, dayTimes: Record<string, string>) => void;
|
||
onDelete?: () => void;
|
||
onClose: () => void;
|
||
allDays: ScheduleDay[];
|
||
currentDay: string;
|
||
}) {
|
||
const [draft, setDraft] = useState<ScheduleClass>(cls);
|
||
const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
|
||
const typeOptions = classTypes.map((t) => ({ value: t, label: t }));
|
||
const isNew = !onDelete;
|
||
|
||
// For edit mode: build per-day times from existing group entries
|
||
const initialDayTimes = useMemo(() => {
|
||
if (isNew) return { [currentDay]: cls.time };
|
||
const times: Record<string, string> = {};
|
||
for (const day of allDays) {
|
||
const match = day.classes.find((c) => isSameGroup(c, cls));
|
||
if (match) times[day.day] = match.time;
|
||
}
|
||
return times;
|
||
}, [allDays, cls, isNew, currentDay]);
|
||
|
||
const [dayTimes, setDayTimes] = useState<Record<string, string>>(initialDayTimes);
|
||
|
||
// "Same time for all days" — default true if all existing times match
|
||
const allTimesMatch = useMemo(() => {
|
||
const vals = Object.values(initialDayTimes);
|
||
return vals.length <= 1 || vals.every((t) => t === vals[0]);
|
||
}, [initialDayTimes]);
|
||
const [sameTime, setSameTime] = useState(allTimesMatch);
|
||
|
||
const selectedDays = new Set(Object.keys(dayTimes));
|
||
|
||
function toggleDay(day: string) {
|
||
setDayTimes((prev) => {
|
||
// Must keep at least one day
|
||
if (day in prev && Object.keys(prev).length <= 1) return prev;
|
||
if (day in prev) {
|
||
const next = { ...prev };
|
||
delete next[day];
|
||
return next;
|
||
}
|
||
// New day gets time from current day or first existing
|
||
const refTime = sameTime
|
||
? (Object.values(prev)[0] || cls.time)
|
||
: (prev[currentDay] || cls.time);
|
||
return { ...prev, [day]: refTime };
|
||
});
|
||
}
|
||
|
||
function updateDayTime(day: string, time: string) {
|
||
if (sameTime) {
|
||
// Update all days at once
|
||
setDayTimes((prev) => {
|
||
const next: Record<string, string> = {};
|
||
for (const d of Object.keys(prev)) next[d] = time;
|
||
return next;
|
||
});
|
||
} else {
|
||
setDayTimes((prev) => ({ ...prev, [day]: time }));
|
||
}
|
||
}
|
||
|
||
function updateSharedTime(time: string) {
|
||
setDraft({ ...draft, time });
|
||
setDayTimes((prev) => {
|
||
const next: Record<string, string> = {};
|
||
for (const d of Object.keys(prev)) next[d] = time;
|
||
return next;
|
||
});
|
||
}
|
||
|
||
const [touched, setTouched] = useState(false);
|
||
|
||
// Validation
|
||
const errors = useMemo(() => {
|
||
const errs: string[] = [];
|
||
if (!draft.trainer) errs.push("Выберите тренера");
|
||
if (!draft.type) errs.push("Выберите тип занятия");
|
||
const times = Object.values(dayTimes);
|
||
for (const t of times) {
|
||
if (!t || !t.includes("–")) {
|
||
errs.push("Укажите время");
|
||
break;
|
||
}
|
||
const [s, e] = t.split("–").map((p) => timeToMinutes(p.trim()));
|
||
if (!s || !e) {
|
||
errs.push("Неверный формат времени");
|
||
break;
|
||
}
|
||
if (e <= s) {
|
||
errs.push("Время окончания должно быть позже начала");
|
||
break;
|
||
}
|
||
}
|
||
return errs;
|
||
}, [draft.trainer, draft.type, dayTimes]);
|
||
|
||
const isValid = errors.length === 0;
|
||
|
||
// Check for time overlaps on each selected day
|
||
const overlaps = useMemo(() => {
|
||
const result: { day: string; dayShort: string; conflicting: string[] }[] = [];
|
||
for (const [dayName, time] of Object.entries(dayTimes)) {
|
||
const dayData = allDays.find((d) => d.day === dayName);
|
||
if (!dayData || !time) continue;
|
||
const dayShort = DAYS.find((d) => d.day === dayName)?.dayShort || dayName;
|
||
// Build a temporary class to check overlap
|
||
const tempCls: ScheduleClass = { ...draft, time };
|
||
const conflicts: string[] = [];
|
||
for (const existing of dayData.classes) {
|
||
// Skip the class being edited (same group)
|
||
if (!isNew && isSameGroup(existing, cls)) continue;
|
||
if (hasOverlap(tempCls, existing)) {
|
||
conflicts.push(`${existing.time} ${existing.type} (${existing.trainer})`);
|
||
}
|
||
}
|
||
if (conflicts.length > 0) {
|
||
result.push({ day: dayName, dayShort, conflicting: conflicts });
|
||
}
|
||
}
|
||
return result;
|
||
}, [dayTimes, allDays, draft, cls, isNew]);
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||
<div
|
||
className="w-full max-w-md rounded-xl border border-white/10 bg-neutral-900 p-6 shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-bold text-white">
|
||
{isNew ? "Новое занятие" : "Редактировать занятие"}
|
||
</h3>
|
||
<button type="button" onClick={onClose} className="text-neutral-400 hover:text-white">
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{/* Day selector */}
|
||
{allDays.length > 1 && (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-2">Дни</label>
|
||
|
||
{/* Day toggle buttons */}
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{allDays.map((d) => {
|
||
const active = selectedDays.has(d.day);
|
||
return (
|
||
<button
|
||
key={d.day}
|
||
type="button"
|
||
onClick={() => toggleDay(d.day)}
|
||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||
active
|
||
? "bg-gold/20 text-gold border border-gold/40"
|
||
: "border border-white/10 text-neutral-500 hover:text-white hover:border-white/20"
|
||
}`}
|
||
>
|
||
{d.dayShort}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* Same time checkbox + time fields */}
|
||
{selectedDays.size > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const checked = !sameTime;
|
||
setSameTime(checked);
|
||
if (checked) {
|
||
const refTime = dayTimes[currentDay] || Object.values(dayTimes)[0] || cls.time;
|
||
setDayTimes((prev) => {
|
||
const next: Record<string, string> = {};
|
||
for (const d of Object.keys(prev)) next[d] = refTime;
|
||
return next;
|
||
});
|
||
}
|
||
}}
|
||
className="flex items-center gap-2 text-sm text-neutral-300 select-none"
|
||
>
|
||
<span className={`inline-flex items-center justify-center w-4 h-4 rounded border transition-colors ${
|
||
sameTime ? "bg-gold border-gold" : "border-white/20 bg-neutral-800"
|
||
}`}>
|
||
{sameTime && <span className="text-black text-xs font-bold leading-none">✓</span>}
|
||
</span>
|
||
Одинаковое время
|
||
</button>
|
||
)}
|
||
|
||
{sameTime || selectedDays.size <= 1 ? (
|
||
<TimeRangeField
|
||
label="Время"
|
||
value={Object.values(dayTimes)[0] || draft.time}
|
||
onChange={updateSharedTime}
|
||
/>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
<label className="block text-sm text-neutral-400">Время по дням</label>
|
||
{allDays.filter((d) => selectedDays.has(d.day)).map((d) => (
|
||
<div key={d.day} className="flex items-center gap-2">
|
||
<span className="shrink-0 text-xs font-medium text-neutral-400 min-w-[28px]">
|
||
{d.dayShort}
|
||
</span>
|
||
<div className="flex-1">
|
||
<TimeRangeField
|
||
label=""
|
||
value={dayTimes[d.day] || ""}
|
||
onChange={(v) => updateDayTime(d.day, v)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<SelectField
|
||
label="Тренер"
|
||
value={draft.trainer}
|
||
onChange={(v) => setDraft({ ...draft, trainer: v })}
|
||
options={trainerOptions}
|
||
placeholder="Выберите тренера"
|
||
/>
|
||
<SelectField
|
||
label="Тип"
|
||
value={draft.type}
|
||
onChange={(v) => setDraft({ ...draft, type: v })}
|
||
options={typeOptions}
|
||
placeholder="Выберите тип"
|
||
/>
|
||
<SelectField
|
||
label="Уровень"
|
||
value={draft.level || ""}
|
||
onChange={(v) => setDraft({ ...draft, level: v || undefined })}
|
||
options={LEVELS}
|
||
/>
|
||
<div className="flex gap-6">
|
||
<ToggleField
|
||
label="Есть места"
|
||
checked={draft.hasSlots ?? false}
|
||
onChange={(v) => setDraft({ ...draft, hasSlots: v })}
|
||
/>
|
||
<ToggleField
|
||
label="Набор открыт"
|
||
checked={draft.recruiting ?? false}
|
||
onChange={(v) => setDraft({ ...draft, recruiting: v })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Overlap warning */}
|
||
{overlaps.length > 0 && (
|
||
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm">
|
||
<div className="font-medium text-amber-400 mb-1">⚠ Пересечение времени</div>
|
||
{overlaps.map((o) => (
|
||
<div key={o.day} className="text-amber-300/80 text-xs">
|
||
<span className="font-medium">{o.dayShort}:</span>{" "}
|
||
{o.conflicting.join(", ")}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Validation errors */}
|
||
{touched && !isValid && (
|
||
<div className="mt-4 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||
{errors.map((e, i) => <div key={i}>{e}</div>)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-6 flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setTouched(true);
|
||
if (!isValid) return;
|
||
onSave(draft, dayTimes);
|
||
onClose();
|
||
}}
|
||
className={`flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-opacity ${
|
||
touched && !isValid
|
||
? "bg-neutral-700 text-neutral-400 cursor-not-allowed"
|
||
: "bg-gold text-black hover:opacity-90"
|
||
}`}
|
||
>
|
||
Сохранить
|
||
</button>
|
||
{onDelete && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
onDelete();
|
||
onClose();
|
||
}}
|
||
className="rounded-lg border border-red-500/30 px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors flex items-center gap-1.5"
|
||
>
|
||
<Trash2 size={14} />
|
||
Удалить
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- Calendar Grid for one location ----------
|
||
function CalendarGrid({
|
||
location,
|
||
trainers,
|
||
addresses,
|
||
classTypes,
|
||
onChange,
|
||
}: {
|
||
location: ScheduleLocation;
|
||
trainers: string[];
|
||
addresses: string[];
|
||
classTypes: string[];
|
||
onChange: (loc: ScheduleLocation) => void;
|
||
}) {
|
||
const [editingClass, setEditingClass] = useState<{
|
||
dayIndex: number;
|
||
classIndex: number;
|
||
} | null>(null);
|
||
const [newClass, setNewClass] = useState<{
|
||
dayIndex: number;
|
||
cls: ScheduleClass;
|
||
} | null>(null);
|
||
|
||
// Auto-assign groupId to legacy classes that don't have one
|
||
useEffect(() => {
|
||
const needsMigration = location.days.some((d) =>
|
||
d.classes.some((c) => !c.groupId)
|
||
);
|
||
if (!needsMigration) return;
|
||
|
||
// Collect all legacy classes per trainer+type key
|
||
const buckets = new Map<string, { dayIdx: number; clsIdx: number; time: string }[]>();
|
||
location.days.forEach((d, di) => {
|
||
d.classes.forEach((c, ci) => {
|
||
if (c.groupId) return;
|
||
const key = `${c.trainer}|${c.type}`;
|
||
const bucket = buckets.get(key);
|
||
if (bucket) bucket.push({ dayIdx: di, clsIdx: ci, time: c.time });
|
||
else buckets.set(key, [{ dayIdx: di, clsIdx: ci, time: c.time }]);
|
||
});
|
||
});
|
||
|
||
// For each bucket, figure out how many distinct groups there are.
|
||
// If any day has N entries with same trainer+type, there are at least N groups.
|
||
// Assign groups by matching closest times across days.
|
||
const assignedIds = new Map<string, string>(); // "dayIdx:clsIdx" -> groupId
|
||
|
||
for (const entries of buckets.values()) {
|
||
// Count max entries per day
|
||
const perDay = new Map<number, typeof entries>();
|
||
for (const e of entries) {
|
||
const arr = perDay.get(e.dayIdx);
|
||
if (arr) arr.push(e);
|
||
else perDay.set(e.dayIdx, [e]);
|
||
}
|
||
const maxPerDay = Math.max(...[...perDay.values()].map((a) => a.length));
|
||
|
||
if (maxPerDay <= 1) {
|
||
// Simple: one group
|
||
const gid = generateGroupId();
|
||
for (const e of entries) assignedIds.set(`${e.dayIdx}:${e.clsIdx}`, gid);
|
||
} else {
|
||
// Find the day with most entries — those define the seed groups
|
||
const busiestDay = [...perDay.entries()].sort((a, b) => b[1].length - a[1].length)[0];
|
||
const seeds = busiestDay[1].map((e) => ({
|
||
gid: generateGroupId(),
|
||
time: timeToMinutes(e.time.split("–")[0]?.trim() || ""),
|
||
entry: e,
|
||
}));
|
||
// Assign seeds
|
||
for (const s of seeds) assignedIds.set(`${s.entry.dayIdx}:${s.entry.clsIdx}`, s.gid);
|
||
|
||
// Assign remaining entries to closest seed by start time
|
||
for (const e of entries) {
|
||
const k = `${e.dayIdx}:${e.clsIdx}`;
|
||
if (assignedIds.has(k)) continue;
|
||
const eMin = timeToMinutes(e.time.split("–")[0]?.trim() || "");
|
||
let bestSeed = seeds[0];
|
||
let bestDiff = Infinity;
|
||
for (const s of seeds) {
|
||
const diff = Math.abs(eMin - s.time);
|
||
if (diff < bestDiff) { bestDiff = diff; bestSeed = s; }
|
||
}
|
||
assignedIds.set(k, bestSeed.gid);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply groupIds
|
||
const migratedDays = location.days.map((d, di) => ({
|
||
...d,
|
||
classes: d.classes.map((c, ci) => {
|
||
if (c.groupId) return c;
|
||
return { ...c, groupId: assignedIds.get(`${di}:${ci}`) ?? generateGroupId() };
|
||
}),
|
||
}));
|
||
onChange({ ...location, days: migratedDays });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Compute group-based colors for calendar blocks
|
||
const sortedDaysForColors = sortDaysByWeekday(location.days);
|
||
const groupColors = useMemo(
|
||
() => buildGroupColors(sortedDaysForColors),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[JSON.stringify(sortedDaysForColors.map((d) => d.classes.map((c) => groupKey(c))))]
|
||
);
|
||
|
||
// Hover highlight state
|
||
const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null);
|
||
|
||
// Drag state
|
||
const [drag, setDrag] = useState<DragState | null>(null);
|
||
const dragRef = useRef<DragState | null>(null);
|
||
const justDraggedRef = useRef(false);
|
||
const columnRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const sortedDays = sortDaysByWeekday(location.days);
|
||
const usedDays = new Set(location.days.map((d) => d.day));
|
||
const availableDays = DAYS.filter((d) => !usedDays.has(d.day));
|
||
|
||
const hours: number[] = [];
|
||
for (let h = HOUR_START; h <= HOUR_END; h++) {
|
||
hours.push(h);
|
||
}
|
||
|
||
// --- Drag handlers ---
|
||
function startDrag(dayIndex: number, classIndex: number, e: React.MouseEvent) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const col = columnRefs.current[dayIndex];
|
||
if (!col) return;
|
||
|
||
const cls = sortedDays[dayIndex].classes[classIndex];
|
||
const parts = cls.time.split("–");
|
||
const startMin = timeToMinutes(parts[0]?.trim() || "");
|
||
const endMin = timeToMinutes(parts[1]?.trim() || "");
|
||
if (!startMin || !endMin) return;
|
||
|
||
const colRect = col.getBoundingClientRect();
|
||
const blockTop = minutesToY(startMin);
|
||
const grabOffsetY = e.clientY - colRect.top - blockTop;
|
||
|
||
const state: DragState = {
|
||
sourceDayIndex: dayIndex,
|
||
classIndex,
|
||
grabOffsetY,
|
||
durationMin: endMin - startMin,
|
||
previewStartMin: startMin,
|
||
previewDayIndex: dayIndex,
|
||
moved: false,
|
||
};
|
||
dragRef.current = state;
|
||
setDrag(state);
|
||
}
|
||
|
||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||
const d = dragRef.current;
|
||
if (!d) return;
|
||
|
||
// Determine which day column the mouse is over
|
||
let targetDayIndex = d.previewDayIndex;
|
||
for (let i = 0; i < columnRefs.current.length; i++) {
|
||
const col = columnRefs.current[i];
|
||
if (!col) continue;
|
||
const rect = col.getBoundingClientRect();
|
||
if (e.clientX >= rect.left && e.clientX < rect.right) {
|
||
targetDayIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Calculate Y position in the target column
|
||
const col = columnRefs.current[targetDayIndex];
|
||
if (!col) return;
|
||
const colRect = col.getBoundingClientRect();
|
||
const y = e.clientY - colRect.top - d.grabOffsetY;
|
||
const rawMinutes = yToMinutes(y);
|
||
const snapped = Math.round(rawMinutes / SNAP_MINUTES) * SNAP_MINUTES;
|
||
// Clamp to grid bounds
|
||
const clamped = Math.max(
|
||
HOUR_START * 60,
|
||
Math.min(snapped, HOUR_END * 60 - d.durationMin)
|
||
);
|
||
|
||
const hasMoved =
|
||
clamped !== d.previewStartMin || targetDayIndex !== d.previewDayIndex || d.moved;
|
||
|
||
const updated: DragState = {
|
||
...d,
|
||
previewStartMin: clamped,
|
||
previewDayIndex: targetDayIndex,
|
||
moved: hasMoved,
|
||
};
|
||
dragRef.current = updated;
|
||
setDrag(updated);
|
||
}, []);
|
||
|
||
const handleMouseUp = useCallback(() => {
|
||
const d = dragRef.current;
|
||
dragRef.current = null;
|
||
|
||
if (!d) {
|
||
setDrag(null);
|
||
return;
|
||
}
|
||
|
||
if (d.moved) {
|
||
// Suppress the click event that fires right after mouseup
|
||
justDraggedRef.current = true;
|
||
requestAnimationFrame(() => {
|
||
justDraggedRef.current = false;
|
||
});
|
||
|
||
// Commit the move
|
||
const newStart = formatMinutes(d.previewStartMin);
|
||
const newEnd = formatMinutes(d.previewStartMin + d.durationMin);
|
||
const sourceDay = sortedDays[d.sourceDayIndex];
|
||
const cls = sourceDay.classes[d.classIndex];
|
||
const updatedCls: ScheduleClass = { ...cls, time: `${newStart}–${newEnd}` };
|
||
|
||
if (d.previewDayIndex === d.sourceDayIndex) {
|
||
// Same day — just update time
|
||
const classes = [...sourceDay.classes];
|
||
classes[d.classIndex] = updatedCls;
|
||
commitDayUpdate(d.sourceDayIndex, { ...sourceDay, classes });
|
||
} else {
|
||
// Move to different day
|
||
const targetDay = sortedDays[d.previewDayIndex];
|
||
|
||
// Remove from source
|
||
const sourceClasses = sourceDay.classes.filter((_, i) => i !== d.classIndex);
|
||
// Add to target
|
||
const targetClasses = [...targetDay.classes, updatedCls];
|
||
|
||
const days = [...location.days];
|
||
const sourceActual = days.findIndex((dd) => dd.day === sourceDay.day);
|
||
const targetActual = days.findIndex((dd) => dd.day === targetDay.day);
|
||
if (sourceActual !== -1) days[sourceActual] = { ...sourceDay, classes: sourceClasses };
|
||
if (targetActual !== -1) days[targetActual] = { ...targetDay, classes: targetClasses };
|
||
onChange({ ...location, days });
|
||
}
|
||
}
|
||
|
||
setDrag(null);
|
||
}, [sortedDays, location, onChange]);
|
||
|
||
// Attach global mouse listeners while dragging
|
||
useEffect(() => {
|
||
if (!drag) return;
|
||
window.addEventListener("mousemove", handleMouseMove);
|
||
window.addEventListener("mouseup", handleMouseUp);
|
||
return () => {
|
||
window.removeEventListener("mousemove", handleMouseMove);
|
||
window.removeEventListener("mouseup", handleMouseUp);
|
||
};
|
||
}, [drag, handleMouseMove, handleMouseUp]);
|
||
|
||
function handleCellClick(dayIndex: number, e: React.MouseEvent<HTMLDivElement>) {
|
||
if (drag || justDraggedRef.current) return;
|
||
|
||
// Use hover position if available, otherwise calculate from click
|
||
let snapped: number;
|
||
if (hover && hover.dayIndex === dayIndex) {
|
||
snapped = hover.startMin;
|
||
} else {
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const y = e.clientY - rect.top;
|
||
const rawMin = yToMinutes(y);
|
||
snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
|
||
snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60));
|
||
}
|
||
const startTime = formatMinutes(snapped);
|
||
const endTime = formatMinutes(snapped + 60);
|
||
|
||
setHover(null);
|
||
setNewClass({
|
||
dayIndex,
|
||
cls: {
|
||
time: `${startTime}–${endTime}`,
|
||
trainer: "",
|
||
type: "",
|
||
groupId: generateGroupId(),
|
||
},
|
||
});
|
||
}
|
||
|
||
function commitDayUpdate(dayIndex: number, updatedDay: ScheduleDay) {
|
||
const actualDay = sortedDays[dayIndex];
|
||
const actualIndex = location.days.findIndex((d) => d.day === actualDay.day);
|
||
if (actualIndex === -1) return;
|
||
|
||
const days = [...location.days];
|
||
days[actualIndex] = updatedDay;
|
||
onChange({ ...location, days });
|
||
}
|
||
|
||
function updateDay(dayIndex: number, updatedDay: ScheduleDay) {
|
||
commitDayUpdate(dayIndex, updatedDay);
|
||
}
|
||
|
||
function deleteDay(dayIndex: number) {
|
||
const actualDay = sortedDays[dayIndex];
|
||
const days = location.days.filter((d) => d.day !== actualDay.day);
|
||
onChange({ ...location, days });
|
||
}
|
||
|
||
function addDay(dayName: string, dayShort: string) {
|
||
onChange({
|
||
...location,
|
||
days: sortDaysByWeekday([
|
||
...location.days,
|
||
{ day: dayName, dayShort, classes: [] },
|
||
]),
|
||
});
|
||
}
|
||
|
||
// Get the class being edited
|
||
const editingData = editingClass
|
||
? {
|
||
day: sortedDays[editingClass.dayIndex],
|
||
cls: sortedDays[editingClass.dayIndex]?.classes[editingClass.classIndex],
|
||
}
|
||
: null;
|
||
|
||
// Build drag ghost preview
|
||
const dragPreview = drag?.moved ? (() => {
|
||
const sourceDay = sortedDays[drag.sourceDayIndex];
|
||
const cls = sourceDay.classes[drag.classIndex];
|
||
const colors = groupColors[groupKey(cls)] ?? "bg-neutral-600/80 border-neutral-500";
|
||
const top = minutesToY(drag.previewStartMin);
|
||
const height = (drag.durationMin / 60) * HOUR_HEIGHT;
|
||
const newStart = formatMinutes(drag.previewStartMin);
|
||
const newEnd = formatMinutes(drag.previewStartMin + drag.durationMin);
|
||
return { colors, top, height, dayIndex: drag.previewDayIndex, newStart, newEnd, type: cls.type };
|
||
})() : null;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Location name/address */}
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<InputField
|
||
label="Название локации"
|
||
value={location.name}
|
||
onChange={(v) => onChange({ ...location, name: v })}
|
||
/>
|
||
<SelectField
|
||
label="Адрес"
|
||
value={location.address}
|
||
onChange={(v) => onChange({ ...location, address: v })}
|
||
options={addresses.map((a) => ({ value: a, label: a }))}
|
||
placeholder="Выберите адрес"
|
||
/>
|
||
</div>
|
||
|
||
{/* Calendar */}
|
||
{sortedDays.length > 0 && (
|
||
<div className="overflow-x-auto rounded-lg border border-white/10" ref={gridRef}>
|
||
<div className="min-w-[600px]">
|
||
{/* Day headers */}
|
||
<div className="flex border-b border-white/10 bg-neutral-800/50">
|
||
<div className="w-14 shrink-0" />
|
||
{sortedDays.map((day, di) => (
|
||
<div
|
||
key={day.day}
|
||
className="flex-1 border-l border-white/10 px-2 py-2 text-center"
|
||
>
|
||
<div className="flex items-center justify-center gap-1">
|
||
<span className="text-sm font-medium text-white">{day.dayShort}</span>
|
||
<span className="text-xs text-neutral-500">({day.classes.length})</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Time grid */}
|
||
<div className="flex">
|
||
{/* Time labels */}
|
||
<div className="w-14 shrink-0 relative" style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}>
|
||
{hours.slice(0, -1).map((h) => (
|
||
<div
|
||
key={h}
|
||
className="absolute left-0 right-0 text-right pr-2 text-xs text-neutral-500"
|
||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT - 6}px` }}
|
||
>
|
||
{String(h).padStart(2, "0")}:00
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Day columns */}
|
||
{sortedDays.map((day, di) => {
|
||
const showHover = hover && hover.dayIndex === di && !drag && !newClass && !editingClass;
|
||
const hoverTop = showHover ? minutesToY(hover.startMin) : 0;
|
||
const hoverHeight = HOUR_HEIGHT; // 1 hour
|
||
const hoverEndMin = showHover ? hover.startMin + 60 : 0;
|
||
|
||
return (
|
||
<div
|
||
key={day.day}
|
||
ref={(el) => { columnRefs.current[di] = el; }}
|
||
className={`flex-1 border-l border-white/10 relative ${drag ? "cursor-grabbing" : "cursor-pointer"}`}
|
||
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}
|
||
onMouseMove={(e) => {
|
||
if (drag) return;
|
||
// Ignore if hovering over a class block
|
||
if ((e.target as HTMLElement).closest("[data-class-block]")) {
|
||
setHover(null);
|
||
return;
|
||
}
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const y = e.clientY - rect.top;
|
||
const rawMin = yToMinutes(y);
|
||
// Snap to 15-min and offset so the block is centered on cursor
|
||
const snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
|
||
const clamped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60));
|
||
setHover({ dayIndex: di, startMin: clamped });
|
||
}}
|
||
onMouseLeave={() => setHover(null)}
|
||
onClick={(e) => {
|
||
if ((e.target as HTMLElement).closest("[data-class-block]")) return;
|
||
handleCellClick(di, e);
|
||
}}
|
||
>
|
||
{/* Hour lines */}
|
||
{hours.slice(0, -1).map((h) => (
|
||
<div
|
||
key={h}
|
||
className="absolute left-0 right-0 border-t border-white/5"
|
||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }}
|
||
/>
|
||
))}
|
||
{/* Half-hour lines */}
|
||
{hours.slice(0, -1).map((h) => (
|
||
<div
|
||
key={`${h}-30`}
|
||
className="absolute left-0 right-0 border-t border-white/[0.02]"
|
||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }}
|
||
/>
|
||
))}
|
||
|
||
{/* Hover highlight — 1h preview */}
|
||
{showHover && (
|
||
<div
|
||
style={{ top: `${hoverTop}px`, height: `${hoverHeight}px` }}
|
||
className="absolute left-1 right-1 rounded-md border border-dashed border-gold/40 bg-gold/10 px-2 py-1 text-xs text-gold/70 pointer-events-none"
|
||
>
|
||
<div className="font-medium">
|
||
{formatMinutes(hover.startMin)}–{formatMinutes(hoverEndMin)}
|
||
</div>
|
||
<div className="text-gold/50 text-[10px]">Нажмите чтобы добавить</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Class blocks */}
|
||
{day.classes.map((cls, ci) => (
|
||
<ClassBlock
|
||
key={ci}
|
||
cls={cls}
|
||
index={ci}
|
||
isOverlapping={getOverlaps(day.classes, ci)}
|
||
isDragging={
|
||
drag !== null &&
|
||
drag.sourceDayIndex === di &&
|
||
drag.classIndex === ci &&
|
||
drag.moved
|
||
}
|
||
groupColors={groupColors}
|
||
onClick={() => {
|
||
if (justDraggedRef.current) return;
|
||
setEditingClass({ dayIndex: di, classIndex: ci });
|
||
}}
|
||
onDragStart={(e) => startDrag(di, ci, e)}
|
||
/>
|
||
))}
|
||
|
||
{/* Drag preview ghost */}
|
||
{dragPreview && dragPreview.dayIndex === di && (
|
||
<div
|
||
style={{ top: `${dragPreview.top}px`, height: `${dragPreview.height}px` }}
|
||
className={`absolute left-1 right-1 rounded-md border-l-3 border-dashed px-2 py-0.5 text-xs text-white/80 pointer-events-none ${dragPreview.colors} opacity-60`}
|
||
>
|
||
<div className="font-semibold truncate leading-tight">
|
||
{dragPreview.newStart}–{dragPreview.newEnd}
|
||
</div>
|
||
{dragPreview.height > 30 && (
|
||
<div className="truncate text-white/60 leading-tight">{dragPreview.type}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit modal */}
|
||
{editingData?.cls && editingClass && (
|
||
<ClassModal
|
||
cls={editingData.cls}
|
||
trainers={trainers}
|
||
classTypes={classTypes}
|
||
allDays={sortedDays}
|
||
currentDay={sortedDays[editingClass.dayIndex]?.day}
|
||
onSave={(updated, dayTimes) => {
|
||
const original = editingData.cls;
|
||
|
||
const updatedDays = location.days.map((d) => {
|
||
// Remove old matching group entries
|
||
let classes = d.classes.filter((c) => !isSameGroup(c, original));
|
||
// Add updated class with per-day time
|
||
if (d.day in dayTimes) {
|
||
classes = [...classes, { ...updated, time: dayTimes[d.day] }];
|
||
}
|
||
return { ...d, classes };
|
||
});
|
||
onChange({ ...location, days: updatedDays });
|
||
}}
|
||
onDelete={() => {
|
||
// Delete from ALL days that have this group
|
||
const original = editingData.cls;
|
||
const updatedDays = location.days.map((d) => ({
|
||
...d,
|
||
classes: d.classes.filter((c) => !isSameGroup(c, original)),
|
||
}));
|
||
onChange({ ...location, days: updatedDays });
|
||
}}
|
||
onClose={() => setEditingClass(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* New class modal */}
|
||
{newClass && (
|
||
<ClassModal
|
||
cls={newClass.cls}
|
||
trainers={trainers}
|
||
classTypes={classTypes}
|
||
allDays={sortedDays}
|
||
currentDay={sortedDays[newClass.dayIndex]?.day}
|
||
onSave={(created, dayTimes) => {
|
||
const updatedDays = location.days.map((d) => {
|
||
if (d.day in dayTimes) {
|
||
return { ...d, classes: [...d.classes, { ...created, time: dayTimes[d.day] }] };
|
||
}
|
||
return d;
|
||
});
|
||
onChange({ ...location, days: updatedDays });
|
||
}}
|
||
onClose={() => setNewClass(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- Main Page ----------
|
||
export default function ScheduleEditorPage() {
|
||
const [activeLocation, setActiveLocation] = useState(0);
|
||
const [trainers, setTrainers] = useState<string[]>([]);
|
||
const [addresses, setAddresses] = useState<string[]>([]);
|
||
const [classTypes, setClassTypes] = useState<string[]>([]);
|
||
|
||
useEffect(() => {
|
||
adminFetch("/api/admin/team")
|
||
.then((r) => r.json())
|
||
.then((members: { name: string }[]) => {
|
||
setTrainers(members.map((m) => m.name));
|
||
})
|
||
.catch(() => {});
|
||
|
||
adminFetch("/api/admin/sections/contact")
|
||
.then((r) => r.json())
|
||
.then((contact: { addresses?: string[] }) => {
|
||
setAddresses(contact.addresses ?? []);
|
||
})
|
||
.catch(() => {});
|
||
|
||
adminFetch("/api/admin/sections/classes")
|
||
.then((r) => r.json())
|
||
.then((classes: { items?: { name: string }[] }) => {
|
||
setClassTypes((classes.items ?? []).map((c) => c.name));
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
return (
|
||
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
|
||
{(data, update) => {
|
||
const location = data.locations[activeLocation];
|
||
|
||
function updateLocation(updated: ScheduleLocation) {
|
||
const locations = [...data.locations];
|
||
locations[activeLocation] = updated;
|
||
update({ ...data, locations });
|
||
}
|
||
|
||
function deleteLocation(index: number) {
|
||
if (data.locations.length <= 1) return;
|
||
const locations = data.locations.filter((_, i) => i !== index);
|
||
update({ ...data, locations });
|
||
if (activeLocation >= locations.length) {
|
||
setActiveLocation(locations.length - 1);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<InputField
|
||
label="Заголовок секции"
|
||
value={data.title}
|
||
onChange={(v) => update({ ...data, title: v })}
|
||
/>
|
||
|
||
{/* Location tabs */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{data.locations.map((loc, i) => (
|
||
<div
|
||
key={i}
|
||
className={`flex items-center gap-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||
i === activeLocation
|
||
? "bg-gold/10 text-gold border border-gold/30"
|
||
: "border border-white/10 text-neutral-400 hover:text-white"
|
||
}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveLocation(i)}
|
||
className="text-left"
|
||
>
|
||
{loc.name}
|
||
</button>
|
||
{data.locations.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => deleteLocation(i)}
|
||
className="ml-1 rounded p-0.5 text-neutral-500 hover:text-red-400 transition-colors"
|
||
title="Удалить локацию"
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newLocations = [
|
||
...data.locations,
|
||
{ name: "Новая локация", address: "", days: DAYS.map((d) => ({ day: d.day, dayShort: d.dayShort, classes: [] })) },
|
||
];
|
||
update({ ...data, locations: newLocations });
|
||
setActiveLocation(newLocations.length - 1);
|
||
}}
|
||
className="rounded-lg border border-dashed border-white/20 px-4 py-2 text-sm text-neutral-500 hover:text-white transition-colors"
|
||
>
|
||
<Plus size={14} className="inline" /> Локация
|
||
</button>
|
||
</div>
|
||
|
||
{location && (
|
||
<CalendarGrid
|
||
location={location}
|
||
trainers={trainers}
|
||
addresses={addresses}
|
||
classTypes={classTypes}
|
||
onChange={updateLocation}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}}
|
||
</SectionEditor>
|
||
);
|
||
}
|