Files
blackheart-website/src/app/admin/schedule/page.tsx
diana.dolgolyova 6cbdba2197 feat: add CSRF protection for admin API routes
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>
2026-03-17 17:53:02 +03:00

1228 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}