Files
vkv/resources/js/components/RoundsTable.tsx
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

450 lines
15 KiB
TypeScript

import { useEffect, useState } from "react";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
import { useNavigate } from "react-router-dom";
import { useContestStore, type RoundSummary } from "@/stores/contestStore";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
Pagination,
type Selection,
} from "@heroui/react";
type TranslatedField = string | Record<string, string>;
export type Round = {
id: number;
contest_id: number;
name: TranslatedField;
description?: TranslatedField | null;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
is_active: boolean;
is_test: boolean;
is_sixhr: boolean;
is_mcr?: boolean;
start_time: string | null;
end_time: string | null;
logs_deadline: string | null;
preliminary_evaluation_run_id?: number | null;
official_evaluation_run_id?: number | null;
test_evaluation_run_id?: number | null;
contest?: {
id: number;
name: TranslatedField;
is_active?: boolean;
} | null;
};
type PaginatedResponse<T> = {
data: T[];
current_page?: number;
last_page?: number;
total?: number;
per_page?: number;
};
type RoundsTableProps = {
onSelectRound?: (round: Round) => void;
/** Show/hide the edit (pencil) actions column. Default: true */
enableEdit?: boolean;
/** Filter the list to only active rounds (is_active === true). Default: false */
onlyActive?: boolean;
/** Show/hide the is_active column. Default: true */
showActiveColumn?: boolean;
/** Show/hide contest column. Default: true */
showContestColumn?: boolean;
/** Filter rounds by contest id (round.contest_id). If undefined/null, no contest filter is applied. */
contestId?: number | null;
/** Zahrnout testovací kola. Default: false */
showTests?: boolean;
/** Skrýt neaktivní kola (nebo neaktivní závody) pro nepřihlášené. */
hideInactiveForGuests?: boolean;
/** Indikuje nepřihlášeného uživatele. */
isGuest?: boolean;
/** Kliknutí na řádek přenaviguje na detail kola. Default: false */
enableRowNavigation?: boolean;
/** Volitelný builder URL pro navigaci na detail kola. */
roundLinkBuilder?: (round: Round) => string;
/** Pokud máš data kol už ve store, můžeš je sem předat místo fetchování. */
roundsFromStore?: RoundSummary[] | null;
/** Volitelný nadpis tabulky. */
title?: string;
};
function resolveTranslation(
field: TranslatedField | null | undefined,
locale: string
): string {
if (!field) return "";
if (typeof field === "string") {
return field;
}
if (field[locale]) return field[locale];
if (field["en"]) return field["en"];
const first = Object.values(field)[0];
return first ?? "";
}
function formatDateTime(value: string | null, locale: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(locale);
}
type ResultBadge = {
label: string;
className: string;
};
function getResultsBadge(round: Round, t: (key: string) => string): ResultBadge | null {
if (round.preliminary_evaluation_run_id) {
return {
label: t("results_type_preliminary") ?? "Předběžné výsledky",
className: "bg-emerald-100 text-emerald-800",
};
}
if (round.official_evaluation_run_id) {
return {
label: t("results_type_final") ?? "Finální výsledky",
className: "bg-emerald-600 text-white",
};
}
const now = Date.now();
const startTime = round.start_time ? Date.parse(round.start_time) : null;
const logsDeadline = round.logs_deadline ? Date.parse(round.logs_deadline) : null;
if (startTime && !Number.isNaN(startTime) && now < startTime) {
return {
label: t("results_type_not_started") ?? "Závod ještě nezačal",
className: "bg-gray-100 text-gray-700",
};
}
if (
logsDeadline &&
!Number.isNaN(logsDeadline) &&
now <= logsDeadline
) {
return {
label: t("results_type_log_collection_open") ?? "Otevřeno pro sběr logů",
className: "bg-sky-100 text-sky-800",
};
}
return {
label: t("results_type_declared") ?? "Deklarované výsledky",
className: "bg-amber-100 text-amber-800",
};
}
export default function RoundsTable({
onSelectRound,
enableEdit = true,
onlyActive = false,
showActiveColumn = true,
showContestColumn = true,
contestId = null,
showTests = false,
hideInactiveForGuests = false,
isGuest = false,
enableRowNavigation = false,
roundLinkBuilder,
roundsFromStore = null,
title,
}: RoundsTableProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const navigate = useNavigate();
const storeRounds = useContestStore((s) => s.selectedContestRounds);
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
const [items, setItems] = useState<Round[]>(roundsFromStore ?? []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [useLocalPaging, setUseLocalPaging] = useState(false);
const perPage = 20;
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
const filterByTests = (list: Round[]): Round[] =>
showTests ? list : list.filter((r) => !r.is_test);
const filterForGuests = (list: Round[]): Round[] => {
if (!hideInactiveForGuests || !isGuest) return list;
return list.filter((r) => r.is_active && (r.contest?.is_active ?? true));
};
useEffect(() => {
const providedRounds = roundsFromStore ?? (storeRounds.length > 0 ? storeRounds : null);
if (providedRounds && refreshKey === 0) {
const filtered = filterForGuests(filterByTests(providedRounds as Round[]));
setUseLocalPaging(true);
setItems(filtered);
setLastPage(Math.max(1, Math.ceil(filtered.length / perPage)));
setLoading(false);
setError(null);
return;
}
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
setUseLocalPaging(false);
const res = await axios.get<PaginatedResponse<Round> | Round[]>(
"/api/rounds",
{
withCredentials: true,
headers: { Accept: "application/json" },
params: {
lang: locale,
contest_id: contestId ?? undefined,
only_active: onlyActive ? 1 : 0,
include_tests: showTests ? 1 : 0,
per_page: perPage,
page,
},
}
);
if (!active) return;
const data = Array.isArray(res.data)
? res.data
: (res.data as PaginatedResponse<Round>).data;
const filtered = filterForGuests(filterByTests(data));
setItems(filtered);
if (!Array.isArray(res.data)) {
const meta = res.data as PaginatedResponse<Round>;
const nextLastPage = meta.last_page ?? 1;
setLastPage(nextLastPage > 0 ? nextLastPage : 1);
} else {
setLastPage(1);
}
} catch {
if (!active) return;
setError(t("unable_to_load_rounds") ?? "Nepodařilo se načíst seznam kol.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [locale, t, roundsFromStore, storeRounds, contestId, onlyActive, showTests, refreshKey, page, perPage]);
useEffect(() => {
if (page > lastPage) {
setPage(lastPage);
}
}, [page, lastPage]);
const canEdit = Boolean(enableEdit && onSelectRound);
const visibleItems = useLocalPaging
? filterForGuests(items).slice((page - 1) * perPage, page * perPage)
: filterForGuests(items);
if (loading) return <div>{t("rounds_loading") ?? "Načítám kola…"}</div>;
if (error) return <div className="text-sm text-red-600">{error}</div>;
if (visibleItems.length === 0) {
return <div>{t("rounds_empty") ?? "Žádná kola nejsou k dispozici."}</div>;
}
const columns = [
{ key: "name", label: t("round_name") ?? "Kolo" },
...(showContestColumn
? [{ key: "contest", label: t("round_contest") ?? "Závod" }]
: []),
{ key: "schedule", label: t("round_schedule") ?? "Termín" },
...(showActiveColumn
? [{ key: "is_active", label: t("round_active") ?? "Aktivní" }]
: []),
...(canEdit ? [{ key: "actions", label: "" }] : []),
];
return (
<div className="space-y-2">
{title && <h3 className="text-md font-semibold">{title}</h3>}
<Table
aria-label="Rounds table"
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader columns={columns}>
{(column) => (
<TableColumn key={column.key} className="text-left">
{column.label}
</TableColumn>
)}
</TableHeader>
<TableBody items={visibleItems}>
{(item) => (
<TableRow
key={item.id}
onClick={() => {
if (!enableRowNavigation) return;
const target = roundLinkBuilder
? roundLinkBuilder(item)
: `/contests/${item.contest_id}/rounds/${item.id}`;
navigate(target);
}}
className={enableRowNavigation ? "cursor-pointer hover:bg-default-100" : undefined}
>
{(columnKey) => {
switch (columnKey) {
case "name": {
const name = resolveTranslation(item.name, locale);
const description = resolveTranslation(item.description ?? null, locale);
const resultsBadge = getResultsBadge(item, t);
return (
<TableCell>
<div className="flex flex-col gap-1">
<span className="font-medium">{name}</span>
{description && (
<span className="text-xs text-foreground-500 line-clamp-2">
{description}
</span>
)}
<div className="flex flex-wrap gap-2 text-[10px] font-semibold uppercase tracking-wide">
{resultsBadge && (
<span className={`px-1.5 py-0.5 rounded ${resultsBadge.className}`}>
{resultsBadge.label}
</span>
)}
{item.is_test && (
<span className="px-1.5 py-0.5 rounded bg-amber-100 text-amber-800">
{t("round_test") ?? "Test"}
</span>
)}
{item.is_sixhr && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-800">
6h
</span>
)}
{item.is_mcr && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-800">
MČR
</span>
)}
</div>
</div>
</TableCell>
);
}
case "contest": {
const contestName = resolveTranslation(
item.contest?.name ?? null,
locale
);
return (
<TableCell>
<span className="text-sm">
{contestName || "—"}
</span>
</TableCell>
);
}
case "schedule":
return (
<TableCell>
<div className="flex flex-col text-xs text-foreground-600">
<span>{formatDateTime(item.start_time, locale)}</span>
<span>{formatDateTime(item.end_time, locale)}</span>
{item.logs_deadline && (
<span className="text-foreground-500">
{(t("round_logs_deadline") ?? "Logy do") + ": "}
{formatDateTime(item.logs_deadline, locale)}
</span>
)}
</div>
</TableCell>
);
case "is_active":
return (
<TableCell>
{item.is_active
? t("yes") ?? "Ano"
: t("no") ?? "Ne"}
</TableCell>
);
case "actions":
return (
<TableCell>
{canEdit && (
<div className="flex items-center gap-1">
{onSelectRound && (
<button
type="button"
onClick={() => onSelectRound?.(item)}
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
aria-label={t("edit_round") ?? "Edit round"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
</svg>
</button>
)}
</div>
)}
</TableCell>
);
default:
return <TableCell />;
}
}}
</TableRow>
)}
</TableBody>
</Table>
{lastPage > 1 && (
<div className="flex justify-end">
<Pagination total={lastPage} page={page} onChange={setPage} showShadow />
</div>
)}
</div>
);
}