- Completed-метрики
+ Результаты
{isCompleted ? (
@@ -131,16 +222,7 @@ export function RaceDetailsPage(): JSX.Element {
- Темп
- {paceLabel ?? "не удалось вычислить"}
-
-
- Место
- -
- {race.finishPlace?.trim() ? race.finishPlace : "не указано"}
-
-
-
-
- Стартовый номер
- - {race.bibNumber ?? "не указан"}
-
+
) : (
diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx
new file mode 100644
index 0000000..02d91bd
--- /dev/null
+++ b/frontend/src/pages/RaceFormPage.tsx
@@ -0,0 +1,421 @@
+import { useCallback, useEffect, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { ApiError, createRace, getRaceById, updateRace } from "../api";
+import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
+
+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;
+ startTime: string;
+ clusterSchedule: string;
+ bibPickup: string;
+ bibNumber: string;
+ finishTime: string;
+ finishPlace: string;
+ notes: string;
+}
+
+const EMPTY_FORM: FormData = {
+ date: "",
+ title: "",
+ distanceKm: "",
+ status: "planned",
+ officialUrl: "",
+ startTime: "",
+ clusterSchedule: "",
+ bibPickup: "",
+ bibNumber: "",
+ finishTime: "",
+ finishPlace: "",
+ notes: "",
+};
+
+function raceToFormData(race: Race): FormData {
+ return {
+ date: race.date,
+ title: race.title,
+ distanceKm: String(race.distanceKm),
+ status: race.status ?? "",
+ officialUrl: race.officialUrl ?? "",
+ 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;
+}
+
+export function RaceFormPage(): JSX.Element {
+ const { raceId } = useParams<{ raceId: string }>();
+ const navigate = useNavigate();
+ 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]);
+
+ const handleChange = useCallback(
+ (event: React.ChangeEvent) => {
+ const { name, value } = event.target;
+ setForm((prev) => ({ ...prev, [name]: value }));
+ },
+ [],
+ );
+
+ 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 statusValue: RaceStatus | null =
+ form.status === "planned" || form.status === "registered" || form.status === "completed"
+ ? form.status
+ : null;
+
+ if (isEditMode && raceId) {
+ const payload: UpdateRacePayload = {
+ date: form.date.trim(),
+ title: form.title.trim(),
+ distanceKm: parseFloat(form.distanceKm),
+ status: statusValue,
+ officialUrl: emptyToNull(form.officialUrl),
+ 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),
+ 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 pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
+
+ if (isLoading) {
+ return (
+
+ {pageTitle}
+ Загружаем данные...
+
+ );
+ }
+
+ return (
+
+ {pageTitle}
+
+ {errorMessage ? (
+ {errorMessage}
+ ) : null}
+
+ {validationErrors.length > 0 ? (
+
+ {validationErrors.map((msg) => (
+ - {msg}
+ ))}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 7438f2f..ed9bd1f 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -408,6 +408,175 @@ a {
color: var(--color-text);
}
+/* ─── Buttons ──────────────────────────────────────────── */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-2) var(--space-4);
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ font: inherit;
+ font-weight: 600;
+ font-size: var(--font-size-caption);
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ transition: background 0.15s, color 0.15s;
+}
+
+.btn:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.btn--primary {
+ background: var(--color-accent);
+ color: var(--color-surface);
+}
+
+.btn--primary:hover:not(:disabled) {
+ background: #1766be;
+}
+
+.btn--secondary {
+ background: var(--color-surface);
+ color: var(--color-text);
+ border-color: var(--color-border);
+}
+
+.btn--secondary:hover:not(:disabled) {
+ background: #eef2f6;
+}
+
+.btn--danger {
+ background: var(--color-error);
+ color: var(--color-surface);
+}
+
+.btn--danger:hover:not(:disabled) {
+ background: #a82e2e;
+}
+
+/* ─── Confirm banner ───────────────────────────────────── */
+
+.confirm-banner {
+ margin-top: var(--space-4);
+ padding: var(--space-4);
+ border: 1px solid var(--color-error);
+ border-radius: var(--radius-md);
+ background: #fef2f2;
+}
+
+.confirm-banner__text {
+ margin: 0 0 var(--space-3);
+ font-weight: 600;
+ color: var(--color-error);
+}
+
+.confirm-banner__actions {
+ display: flex;
+ gap: var(--space-3);
+}
+
+/* ─── Race details actions ─────────────────────────────── */
+
+.race-details-actions {
+ margin-top: var(--space-4);
+ display: flex;
+ gap: var(--space-3);
+}
+
+.race-details-meta__link {
+ color: var(--color-accent);
+ word-break: break-all;
+}
+
+.race-details-meta__link:hover {
+ text-decoration: underline;
+}
+
+/* ─── Race form ────────────────────────────────────────── */
+
+.race-form {
+ margin-top: var(--space-6);
+}
+
+.race-form__group {
+ margin: 0 0 var(--space-6);
+ padding: var(--space-5);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: #fcfdff;
+}
+
+.race-form__legend {
+ font-size: var(--font-size-body);
+ font-weight: 700;
+ color: var(--color-text);
+ padding: 0 var(--space-2);
+}
+
+.race-form__field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ margin-top: var(--space-4);
+}
+
+.race-form__field:first-of-type {
+ margin-top: var(--space-3);
+}
+
+.race-form__label {
+ font-size: var(--font-size-caption);
+ font-weight: 600;
+ color: var(--color-text-muted);
+}
+
+.race-form__input {
+ font: inherit;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ color: var(--color-text);
+}
+
+.race-form__input:focus {
+ outline: 2px solid var(--color-accent);
+ outline-offset: -1px;
+ border-color: var(--color-accent);
+}
+
+.race-form__input--textarea {
+ resize: vertical;
+ min-height: 5rem;
+}
+
+.race-form__actions {
+ display: flex;
+ gap: var(--space-3);
+ align-items: center;
+}
+
+.form-errors {
+ margin: var(--space-3) 0 0;
+ padding: var(--space-3) var(--space-5);
+ border: 1px solid var(--color-error);
+ border-radius: var(--radius-sm);
+ background: #fef2f2;
+ color: var(--color-error);
+ font-size: var(--font-size-caption);
+}
+
+.form-errors__item {
+ margin: var(--space-1) 0;
+}
+
+/* ─── Responsive ───────────────────────────────────────── */
+
@media (max-width: 900px) {
.dashboard-grid,
.race-lists,
@@ -418,4 +587,9 @@ a {
.race-details-header {
flex-direction: column;
}
+
+ .race-form__actions {
+ flex-direction: column;
+ align-items: stretch;
+ }
}