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

191 lines
7.0 KiB
TypeScript

import { useEffect, useState } from "react";
import axios from "axios";
import { Card, CardHeader, CardBody, Divider } from "@heroui/react";
import { useLanguageStore } from "@/stores/languageStore";
import { useContestStore, type ContestSummary, type RoundSummary } from "@/stores/contestStore";
import { useTranslation } from "react-i18next";
type RoundDetailProps = {
roundId: number;
};
type RoundDetailData = RoundSummary & {
contest_id: number;
contest?: ContestSummary | null;
description?: string | Record<string, string> | null;
logs_deadline?: string | null;
start_time: string | null;
end_time: string | null;
rule_set_id?: number | null;
rule_set?: { id: number; name: string } | null;
preliminary_evaluation_run_id?: number | null;
official_evaluation_run_id?: number | null;
test_evaluation_run_id?: number | null;
};
function parseRoundDate(value: string | null): Date | null {
if (!value) return null;
const direct = new Date(value);
if (!Number.isNaN(direct.getTime())) return direct;
const normalized = value.includes("T") ? value : value.replace(" ", "T");
const fallback = new Date(normalized);
if (!Number.isNaN(fallback.getTime())) return fallback;
return null;
}
function formatDateTime(value: string | null, locale: string): string {
if (!value) return "—";
const date = parseRoundDate(value);
if (!date) return value;
return date.toLocaleString(locale);
}
function isPastDeadline(value: string | null): boolean {
const date = parseRoundDate(value);
if (!date) return false;
return (new Date() > date);
}
const resolveTranslation = (field: any, locale: string): string => {
if (!field) return "";
if (typeof field === "string") return field;
if (typeof field === "object") {
if (field[locale]) return field[locale];
if (field["en"]) return field["en"];
const first = Object.values(field)[0];
return typeof first === "string" ? first : "";
}
return String(field);
};
export default function RoundDetail({ roundId }: RoundDetailProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
const setSelectedRound = useContestStore((s) => s.setSelectedRound);
const [detail, setDetail] = useState<RoundDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<RoundDetailData>(`/api/rounds/${roundId}`, {
headers: { Accept: "application/json" },
params: { lang: locale },
withCredentials: true,
});
if (!active) return;
setDetail(res.data);
// set store for breadcrumbs/overview
if (res.data.contest) {
setSelectedContest({
id: res.data.contest.id,
name: resolveTranslation((res.data as any).contest.name, locale),
description: resolveTranslation((res.data as any).contest.description ?? null, locale),
is_active: res.data.contest.is_active,
is_mcr: res.data.contest.is_mcr,
is_sixhr: res.data.contest.is_sixhr,
start_time: res.data.contest.start_time ?? null,
duration: res.data.contest.duration ?? 0,
});
}
setSelectedRound({
id: res.data.id,
contest_id: res.data.contest_id,
name: resolveTranslation(res.data.name, locale),
description: resolveTranslation(res.data.description ?? null, locale),
is_active: res.data.is_active,
is_test: res.data.is_test,
is_sixhr: res.data.is_sixhr,
start_time: res.data.start_time,
end_time: res.data.end_time,
logs_deadline: res.data.logs_deadline ?? null,
preliminary_evaluation_run_id: res.data.preliminary_evaluation_run_id ?? null,
official_evaluation_run_id: res.data.official_evaluation_run_id ?? null,
test_evaluation_run_id: res.data.test_evaluation_run_id ?? null,
});
} catch {
if (!active) return;
setError("Nepodařilo se načíst detail kola.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [roundId, locale, setSelectedContest, setSelectedRound]);
return (
<Card>
<CardHeader>
<div className="flex flex-col">
<span className="text-lg font-semibold">
{detail ? resolveTranslation(detail.name, locale) : t("round_name") ?? "Kolo"}
</span>
{detail?.description && (
<span className="text-sm text-foreground-500">
{resolveTranslation(detail.description, locale)}
</span>
)}
</div>
</CardHeader>
<Divider />
<CardBody>
{error && <p className="text-sm text-red-600">{error}</p>}
{loading && <p className="text-sm text-foreground-500">Načítám detail</p>}
{detail && !loading && (
<div className="grid gap-2 text-sm">
<div className="flex gap-2">
<span className="font-semibold">{t("round_schedule") ?? "Termín"}:</span>
<span>{formatDateTime(detail.start_time, locale)} {formatDateTime(detail.end_time, locale)}</span>
</div>
{detail.logs_deadline && (
<div className="flex gap-2">
<span className="font-semibold">{t("round_logs_deadline") ?? "Logy do"}:</span>
<span>{formatDateTime(detail.logs_deadline, locale)}</span>
{detail.logs_deadline && isPastDeadline(detail.logs_deadline) && (
<span>
{t("round_logs_deadline_passed") ??
"Termín pro nahrání logů již vypršel."}
</span>
)}
</div>
)}
<div className="flex gap-2">
<span className="font-semibold">{t("round_active") ?? "Aktivní"}:</span>
<span>{detail.is_active ? (t("yes") ?? "Ano") : (t("no") ?? "Ne")}</span>
</div>
{detail.is_test && (
<div className="flex gap-2">
<span className="font-semibold">{t("round_test") ?? "Test"}:</span>
<span>{t("yes") ?? "Ano"}</span>
</div>
)}
<div className="flex gap-2">
<span className="font-semibold">6h:</span>
<span>{detail.is_sixhr ? (t("yes") ?? "Ano") : (t("no") ?? "Ne")}</span>
</div>
{(detail.rule_set || detail.rule_set_id) && (
<div className="flex gap-2">
<span className="font-semibold">Ruleset:</span>
<span>{detail.rule_set?.name ?? `#${detail.rule_set_id}`}</span>
</div>
)}
</div>
)}
</CardBody>
</Card>
);
}