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 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 14:14:33 +03:00
parent 9d0b4b0fba
commit 21f3887bc9
2 changed files with 95 additions and 21 deletions

View File

@@ -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 (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
@@ -147,7 +162,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
<input
type="time"
value={start}
onChange={(e) => 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
<input
type="time"
value={end}
onChange={(e) => 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"
/>

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
@@ -349,20 +396,6 @@ function ClassModal({
})}
</div>
{!isNew && (addedDays.length > 0 || removedDays.length > 0) && (
<div className="mt-1.5 flex flex-wrap gap-x-3 text-[11px]">
{addedDays.length > 0 && (
<span className="text-emerald-400">
+ {addedDays.map((d) => allDays.find((ad) => ad.day === d)?.dayShort).join(", ")}
</span>
)}
{removedDays.length > 0 && (
<span className="text-red-400">
{removedDays.map((d) => allDays.find((ad) => ad.day === d)?.dayShort).join(", ")}
</span>
)}
</div>
)}
</div>
)}
@@ -451,14 +484,40 @@ function ClassModal({
</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-3">
<button
type="button"
onClick={() => {
setTouched(true);
if (!isValid) return;
onSave(draft, dayTimes);
onClose();
}}
className="flex-1 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
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>