"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 = 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 { const seen = new Set(); 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 = {}; 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; 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 (
{ 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})` : ""}`} >
{parts[0]?.trim()}–{parts[1]?.trim()}
{height > 30 && (
{cls.type}
)} {height > 48 && (
{cls.trainer}
)} {isOverlapping && (
⚠ Пересечение
)}
); } // ---------- 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) => void; onDelete?: () => void; onClose: () => void; allDays: ScheduleDay[]; currentDay: string; }) { const [draft, setDraft] = useState(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 = {}; 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>(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 = {}; 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 = {}; 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 (
e.stopPropagation()} >

{isNew ? "Новое занятие" : "Редактировать занятие"}

{/* Day selector */} {allDays.length > 1 && (
{/* Day toggle buttons */}
{allDays.map((d) => { const active = selectedDays.has(d.day); return ( ); })}
)} {/* Same time checkbox + time fields */} {selectedDays.size > 1 && ( )} {sameTime || selectedDays.size <= 1 ? ( ) : (
{allDays.filter((d) => selectedDays.has(d.day)).map((d) => (
{d.dayShort}
updateDayTime(d.day, v)} />
))}
)} setDraft({ ...draft, trainer: v })} options={trainerOptions} placeholder="Выберите тренера" /> setDraft({ ...draft, type: v })} options={typeOptions} placeholder="Выберите тип" /> setDraft({ ...draft, level: v || undefined })} options={LEVELS} />
setDraft({ ...draft, hasSlots: v })} /> setDraft({ ...draft, recruiting: v })} />
{/* Overlap warning */} {overlaps.length > 0 && (
⚠ Пересечение времени
{overlaps.map((o) => (
{o.dayShort}:{" "} {o.conflicting.join(", ")}
))}
)} {/* Validation errors */} {touched && !isValid && (
{errors.map((e, i) =>
{e}
)}
)}
{onDelete && ( )}
); } // ---------- 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(); 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(); // "dayIdx:clsIdx" -> groupId for (const entries of buckets.values()) { // Count max entries per day const perDay = new Map(); 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(null); const dragRef = useRef(null); const justDraggedRef = useRef(false); const columnRefs = useRef<(HTMLDivElement | null)[]>([]); const gridRef = useRef(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) { 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 (
{/* Location name/address */}
onChange({ ...location, name: v })} /> onChange({ ...location, address: v })} options={addresses.map((a) => ({ value: a, label: a }))} placeholder="Выберите адрес" />
{/* Calendar */} {sortedDays.length > 0 && (
{/* Day headers */}
{sortedDays.map((day, di) => (
{day.dayShort} ({day.classes.length})
))}
{/* Time grid */}
{/* Time labels */}
{hours.slice(0, -1).map((h) => (
{String(h).padStart(2, "0")}:00
))}
{/* 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 (
{ 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) => (
))} {/* Half-hour lines */} {hours.slice(0, -1).map((h) => (
))} {/* Hover highlight — 1h preview */} {showHover && (
{formatMinutes(hover.startMin)}–{formatMinutes(hoverEndMin)}
Нажмите чтобы добавить
)} {/* Class blocks */} {day.classes.map((cls, ci) => ( { if (justDraggedRef.current) return; setEditingClass({ dayIndex: di, classIndex: ci }); }} onDragStart={(e) => startDrag(di, ci, e)} /> ))} {/* Drag preview ghost */} {dragPreview && dragPreview.dayIndex === di && (
{dragPreview.newStart}–{dragPreview.newEnd}
{dragPreview.height > 30 && (
{dragPreview.type}
)}
)}
); })}
)} {/* Edit modal */} {editingData?.cls && editingClass && ( { 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 && ( { 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)} /> )}
); } // ---------- Main Page ---------- export default function ScheduleEditorPage() { const [activeLocation, setActiveLocation] = useState(0); const [trainers, setTrainers] = useState([]); const [addresses, setAddresses] = useState([]); const [classTypes, setClassTypes] = useState([]); 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 ( 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 ( <> update({ ...data, title: v })} /> {/* Location tabs */}
{data.locations.map((loc, i) => (
{data.locations.length > 1 && ( )}
))}
{location && ( )} ); }} ); }