191 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|