From 21f3887bc91a24032db391cfc146d2ce7e6f7ae4 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Thu, 12 Mar 2026 14:14:33 +0300 Subject: [PATCH] feat: schedule modal validation + time range guard - Validate trainer, type, and time before saving - Show overlap warnings for conflicting classes - Reset end time when start time exceeds it - Block setting end time earlier than start - Remove day change hints from modal Co-Authored-By: Claude Opus 4.6 --- src/app/admin/_components/FormField.tsx | 19 ++++- src/app/admin/schedule/page.tsx | 97 ++++++++++++++++++++----- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/src/app/admin/_components/FormField.tsx b/src/app/admin/_components/FormField.tsx index 5c67cf8..a5ab1fa 100644 --- a/src/app/admin/_components/FormField.tsx +++ b/src/app/admin/_components/FormField.tsx @@ -140,6 +140,21 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel } } + function handleStartChange(newStart: string) { + // Reset end if start >= end + if (newStart && end && newStart >= end) { + update(newStart, ""); + } else { + update(newStart, end); + } + } + + function handleEndChange(newEnd: string) { + // Ignore if end <= start + if (start && newEnd && newEnd <= start) return; + update(start, newEnd); + } + return (
@@ -147,7 +162,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel update(e.target.value, end)} + onChange={(e) => handleStartChange(e.target.value)} onBlur={onBlur} className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors" /> @@ -155,7 +170,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel update(start, e.target.value)} + onChange={(e) => handleEndChange(e.target.value)} onBlur={onBlur} className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors" /> diff --git a/src/app/admin/schedule/page.tsx b/src/app/admin/schedule/page.tsx index 8a3a4df..f814125 100644 --- a/src/app/admin/schedule/page.tsx +++ b/src/app/admin/schedule/page.tsx @@ -302,10 +302,57 @@ function ClassModal({ }); } - // Compute what changed for the hint (edit mode only) - const originalDays = new Set(Object.keys(initialDayTimes)); - const addedDays = [...selectedDays].filter((d) => !originalDays.has(d)); - const removedDays = [...originalDays].filter((d) => !selectedDays.has(d)); + 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 (
@@ -349,20 +396,6 @@ function ClassModal({ })}
- {!isNew && (addedDays.length > 0 || removedDays.length > 0) && ( -
- {addedDays.length > 0 && ( - - + {addedDays.map((d) => allDays.find((ad) => ad.day === d)?.dayShort).join(", ")} - - )} - {removedDays.length > 0 && ( - - − {removedDays.map((d) => allDays.find((ad) => ad.day === d)?.dayShort).join(", ")} - - )} -
- )}
)} @@ -451,14 +484,40 @@ function ClassModal({ + {/* Overlap warning */} + {overlaps.length > 0 && ( +
+
⚠ Пересечение времени
+ {overlaps.map((o) => ( +
+ {o.dayShort}:{" "} + {o.conflicting.join(", ")} +
+ ))} +
+ )} + + {/* Validation errors */} + {touched && !isValid && ( +
+ {errors.map((e, i) =>
{e}
)} +
+ )} +