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:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user