feat: CRUD UI — race form, detail fields, edit/delete actions
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- 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
This commit is contained in:
@@ -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 (
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">{props.label}</dt>
|
||||
<dd className="race-details-meta__value">{text}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailLink(props: { label: string; url: string | null | undefined }): JSX.Element | null {
|
||||
const href = props.url?.trim();
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">{props.label}</dt>
|
||||
<dd className="race-details-meta__value">
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="race-details-meta__link">
|
||||
{href}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RaceDetailsPage(): JSX.Element {
|
||||
const { raceId } = useParams<{ raceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [race, setRace] = useState<Race | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(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 (
|
||||
<section className="page page--race-details" aria-busy="true">
|
||||
@@ -100,6 +149,43 @@ export function RaceDetailsPage(): JSX.Element {
|
||||
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span>
|
||||
</div>
|
||||
|
||||
<div className="race-details-actions">
|
||||
<Link className="btn btn--primary" to={`/races/${race.id}/edit`}>
|
||||
Редактировать
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn--danger"
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
<div className="confirm-banner" role="alertdialog" aria-label="Подтверждение удаления">
|
||||
<p className="confirm-banner__text">Удалить «{race.title}»? Это действие необратимо.</p>
|
||||
<div className="confirm-banner__actions">
|
||||
<button
|
||||
className="btn btn--danger"
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{isDeleting ? "Удаляем…" : "Да, удалить"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--secondary"
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="race-details-grid">
|
||||
<article className="race-details-card">
|
||||
<h2 className="race-details-card__title">Основная информация</h2>
|
||||
@@ -116,11 +202,16 @@ export function RaceDetailsPage(): JSX.Element {
|
||||
<dt className="race-details-meta__key">Статус</dt>
|
||||
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status)}</dd>
|
||||
</div>
|
||||
<DetailLink label="Сайт организатора" url={race.officialUrl} />
|
||||
<DetailItem label="Время старта" value={race.startTime} />
|
||||
<DetailItem label="Расписание кластеров" value={race.clusterSchedule} />
|
||||
<DetailItem label="Выдача номеров" value={race.bibPickup} />
|
||||
<DetailItem label="Стартовый номер" value={race.bibNumber} />
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article className="race-details-card">
|
||||
<h2 className="race-details-card__title">Completed-метрики</h2>
|
||||
<h2 className="race-details-card__title">Результаты</h2>
|
||||
{isCompleted ? (
|
||||
<dl className="race-details-meta">
|
||||
<div className="race-details-meta__item">
|
||||
@@ -131,16 +222,7 @@ export function RaceDetailsPage(): JSX.Element {
|
||||
<dt className="race-details-meta__key">Темп</dt>
|
||||
<dd className="race-details-meta__value">{paceLabel ?? "не удалось вычислить"}</dd>
|
||||
</div>
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Место</dt>
|
||||
<dd className="race-details-meta__value">
|
||||
{race.finishPlace?.trim() ? race.finishPlace : "не указано"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Стартовый номер</dt>
|
||||
<dd className="race-details-meta__value">{race.bibNumber ?? "не указан"}</dd>
|
||||
</div>
|
||||
<DetailItem label="Место" value={race.finishPlace} />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="race-details-card__empty">
|
||||
|
||||
Reference in New Issue
Block a user