From 4b63af8da51fdbc88d62dbcf5897453245dab956 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 7 Apr 2026 18:13:22 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20CRUD=20UI=20=E2=80=94=20race=20form,=20?= =?UTF-8?q?detail=20fields,=20edit/delete=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RaceDetailsPage: show all non-null fields (officialUrl, startTime, clusterSchedule, bibPickup) - RaceDetailsPage: add edit link and delete button with confirmation banner - RaceFormPage: universal create/edit form with validation, auto-generated id for new races - Router: add /races/new and /races/:raceId/edit routes - AppLayout: add navigation link to create new race - CSS: buttons (primary/secondary/danger), form fields, confirm banner, responsive layout Made-with: Cursor --- frontend/src/app/layouts/AppLayout.tsx | 8 + frontend/src/app/router.tsx | 3 + frontend/src/pages/RaceDetailsPage.tsx | 110 ++++++- frontend/src/pages/RaceFormPage.tsx | 421 +++++++++++++++++++++++++ frontend/src/styles/global.css | 174 ++++++++++ 5 files changed, 702 insertions(+), 14 deletions(-) create mode 100644 frontend/src/pages/RaceFormPage.tsx diff --git a/frontend/src/app/layouts/AppLayout.tsx b/frontend/src/app/layouts/AppLayout.tsx index de9100a..9f85d3b 100644 --- a/frontend/src/app/layouts/AppLayout.tsx +++ b/frontend/src/app/layouts/AppLayout.tsx @@ -23,6 +23,14 @@ export function AppLayout(): JSX.Element { > Races + + isActive ? "app-shell__link app-shell__link--active" : "app-shell__link" + } + > + + Добавить +
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 7386efe..d802e2e 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -3,6 +3,7 @@ import { AppLayout } from "./layouts/AppLayout"; import { DashboardPage } from "../pages/DashboardPage"; import { RacesPage } from "../pages/RacesPage"; import { RaceDetailsPage } from "../pages/RaceDetailsPage"; +import { RaceFormPage } from "../pages/RaceFormPage"; export const appRouter = createBrowserRouter([ { @@ -11,7 +12,9 @@ export const appRouter = createBrowserRouter([ children: [ { index: true, element: }, { path: "races", element: }, + { path: "races/new", element: }, { path: "races/:raceId", element: }, + { path: "races/:raceId/edit", element: }, ], }, ]); diff --git a/frontend/src/pages/RaceDetailsPage.tsx b/frontend/src/pages/RaceDetailsPage.tsx index 17039d4..0c0492e 100644 --- a/frontend/src/pages/RaceDetailsPage.tsx +++ b/frontend/src/pages/RaceDetailsPage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link, useParams } from "react-router-dom"; -import { ApiError, getRaceById } from "../api"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { ApiError, deleteRace, getRaceById } from "../api"; import { formatDistance, formatRaceDate, @@ -17,11 +17,44 @@ function getErrorMessage(error: unknown): string { return "Не удалось загрузить карточку старта."; } +function DetailItem(props: { label: string; value: string | null | undefined }): JSX.Element | null { + const text = props.value?.trim(); + if (!text) { + return null; + } + return ( +
+
{props.label}
+
{text}
+
+ ); +} + +function DetailLink(props: { label: string; url: string | null | undefined }): JSX.Element | null { + const href = props.url?.trim(); + if (!href) { + return null; + } + return ( +
+
{props.label}
+
+ + {href} + +
+
+ ); +} + export function RaceDetailsPage(): JSX.Element { const { raceId } = useParams<{ raceId: string }>(); + const navigate = useNavigate(); const [race, setRace] = useState(null); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); useEffect(() => { let isMounted = true; @@ -65,6 +98,22 @@ export function RaceDetailsPage(): JSX.Element { return getPaceLabel(race.finishTime, race.distanceKm); }, [race]); + const handleDelete = useCallback(async () => { + if (!raceId) { + return; + } + setIsDeleting(true); + try { + await deleteRace(raceId); + navigate("/races", { replace: true }); + } catch (error) { + setErrorMessage(error instanceof ApiError ? error.message : "Не удалось удалить старт."); + setShowDeleteConfirm(false); + } finally { + setIsDeleting(false); + } + }, [raceId, navigate]); + if (isLoading) { return (
@@ -100,6 +149,43 @@ export function RaceDetailsPage(): JSX.Element { {getRaceStatusLabel(race.status)} +
+ + Редактировать + + +
+ + {showDeleteConfirm ? ( +
+

Удалить «{race.title}»? Это действие необратимо.

+
+ + +
+
+ ) : null} +

Основная информация

@@ -116,11 +202,16 @@ export function RaceDetailsPage(): JSX.Element {
Статус
{getRaceStatusLabel(race.status)}
+ + + + +
-

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} + +
+
+ Основная информация + + + + + + + + +
+ +
+ Организация + + + + + + + + + + +
+ +
+ Результаты + + + + +
+ +
+ Дополнительно + +