import { useCallback, useEffect, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ApiError, createRace, getRaceById, updateRace } from "../api"; import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api"; import { DatePickerField, StartTimeSelects } from "../components"; import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib"; function slugify(text: string): string { return text .toLowerCase() .replace(/[«»"]/g, "") .replace(/[^a-zа-яё0-9]+/gi, "-") .replace(/(^-|-$)/g, "") .substring(0, 60); } function generateId(date: string, title: string): string { return `${date}-${slugify(title)}`; } const STATUS_OPTIONS: { value: string; label: string }[] = [ { value: "", label: "Не указан" }, { value: "planned", label: "Планирую" }, { value: "registered", label: "Зарегистрирован" }, { value: "completed", label: "Пробежал" }, ]; interface FormData { date: string; title: string; distanceKm: string; status: string; officialUrl: string; coverImageUrl: string; startTime: string; clusterSchedule: string; bibPickup: string; bibNumber: string; finishTime: string; finishPlace: string; notes: string; } const EMPTY_FORM: FormData = { date: "", title: "", distanceKm: "", status: "planned", officialUrl: "", coverImageUrl: "", startTime: "", clusterSchedule: "", bibPickup: "", bibNumber: "", finishTime: "", finishPlace: "", notes: "", }; function raceToFormData(race: Race): FormData { const dateValue = race.date.length >= 10 ? race.date.slice(0, 10) : race.date; return { date: dateValue, title: race.title, distanceKm: String(race.distanceKm), status: race.status ?? "", officialUrl: race.officialUrl ?? "", coverImageUrl: race.coverImageUrl ?? "", startTime: race.startTime ?? "", clusterSchedule: race.clusterSchedule ?? "", bibPickup: race.bibPickup ?? "", bibNumber: race.bibNumber ?? "", finishTime: race.finishTime ?? "", finishPlace: race.finishPlace ?? "", notes: race.notes ?? "", }; } function emptyToNull(value: string): string | null { const trimmed = value.trim(); return trimmed === "" ? null : trimmed; } function validateForm(form: FormData): string[] { const errors: string[] = []; if (!form.date.trim()) { errors.push("Дата обязательна."); } if (!form.title.trim()) { errors.push("Название обязательно."); } const km = parseFloat(form.distanceKm); if (Number.isNaN(km) || km <= 0) { errors.push("Дистанция должна быть положительным числом."); } return errors; } function isRaceDateTodayOrPast(date: string): boolean { if (!date.trim()) { return false; } const today = new Date(); const y = today.getFullYear(); const m = String(today.getMonth() + 1).padStart(2, "0"); const d = String(today.getDate()).padStart(2, "0"); return isRaceDateInPast(date) || date.slice(0, 10) === `${y}-${m}-${d}`; } export function RaceFormPage(): JSX.Element { const { raceId } = useParams<{ raceId: string }>(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const isEditMode = Boolean(raceId); const [form, setForm] = useState(EMPTY_FORM); const [isLoading, setIsLoading] = useState(isEditMode); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [validationErrors, setValidationErrors] = useState([]); useEffect(() => { if (!raceId) { return; } let isMounted = true; async function loadRace(): Promise { try { const race = await getRaceById(raceId!); if (!isMounted) { return; } setForm(raceToFormData(race)); setErrorMessage(null); } catch (error) { if (!isMounted) { return; } setErrorMessage(error instanceof ApiError ? error.message : "Не удалось загрузить данные старта."); } finally { if (isMounted) { setIsLoading(false); } } } void loadRace(); return () => { isMounted = false; }; }, [raceId]); useEffect(() => { if (isEditMode) { return; } const d = searchParams.get("date"); if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) { setForm((prev) => (prev.date === d ? prev : { ...prev, date: d })); } }, [isEditMode, searchParams]); const handleChange = useCallback( (event: React.ChangeEvent) => { const { name, value } = event.target; setForm((prev) => { const next = { ...prev, [name]: value }; if (name === "finishTime") { const trimmed = value.trim(); if (trimmed !== "" && parseFinishTimeToSeconds(trimmed) !== null) { return { ...next, status: "completed" }; } } return next; }); }, [], ); const handleSubmit = useCallback( async (event: React.FormEvent) => { event.preventDefault(); setErrorMessage(null); const errors = validateForm(form); setValidationErrors(errors); if (errors.length > 0) { return; } setIsSaving(true); try { const finishTrimmed = form.finishTime.trim(); const hasParsedFinish = finishTrimmed !== "" && parseFinishTimeToSeconds(finishTrimmed) !== null; let statusValue: RaceStatus | null = form.status === "planned" || form.status === "registered" || form.status === "completed" ? form.status : null; if (hasParsedFinish) { statusValue = "completed"; } if (isEditMode && raceId) { const payload: UpdateRacePayload = { date: form.date.trim(), title: form.title.trim(), distanceKm: parseFloat(form.distanceKm), status: statusValue, officialUrl: emptyToNull(form.officialUrl), coverImageUrl: emptyToNull(form.coverImageUrl), startTime: emptyToNull(form.startTime), clusterSchedule: emptyToNull(form.clusterSchedule), bibPickup: emptyToNull(form.bibPickup), bibNumber: emptyToNull(form.bibNumber), finishTime: emptyToNull(form.finishTime), finishPlace: emptyToNull(form.finishPlace), notes: emptyToNull(form.notes), }; await updateRace(raceId, payload); navigate(`/races/${raceId}`); } else { const id = generateId(form.date.trim(), form.title.trim()); const payload: CreateRacePayload = { id, date: form.date.trim(), title: form.title.trim(), distanceKm: parseFloat(form.distanceKm), status: statusValue, officialUrl: emptyToNull(form.officialUrl), coverImageUrl: emptyToNull(form.coverImageUrl), startTime: emptyToNull(form.startTime), clusterSchedule: emptyToNull(form.clusterSchedule), bibPickup: emptyToNull(form.bibPickup), bibNumber: emptyToNull(form.bibNumber), finishTime: emptyToNull(form.finishTime), finishPlace: emptyToNull(form.finishPlace), notes: emptyToNull(form.notes), }; const created = await createRace(payload); navigate(`/races/${created.id}`); } } catch (error) { if (error instanceof ApiError) { setErrorMessage(error.details.length > 0 ? error.details.join("; ") : error.message); } else { setErrorMessage("Произошла ошибка при сохранении."); } } finally { setIsSaving(false); } }, [form, isEditMode, raceId, navigate], ); const hideOrgScheduleFields = isEditMode && isRaceDateInPast(form.date); const showResultFields = isRaceDateTodayOrPast(form.date); const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт"; if (isLoading) { return (

{pageTitle}

Загружаем данные...

); } return (

{pageTitle}

{errorMessage ? (

{errorMessage}

) : null} {validationErrors.length > 0 ? (
    {validationErrors.map((msg) => (
  • {msg}
  • ))}
) : null}
Основная информация
Дата * { setForm((prev) => ({ ...prev, date: next })); }} required />
Организация {hideOrgScheduleFields ? null : ( )} {hideOrgScheduleFields ? null : (
Время старта { setForm((prev) => ({ ...prev, startTime: next })); }} />
)} {hideOrgScheduleFields ? null : ( )} {hideOrgScheduleFields ? null : ( )}
{showResultFields ? (
Результаты
) : null}
Дополнительно