273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
import { useParams, useSearchParams } from "react-router-dom";
|
|
import RoundDetail from "@/components/RoundDetail";
|
|
import RoundFileUpload from "@/components/RoundFileUpload";
|
|
import LogsTable from "@/components/LogsTable";
|
|
import ResultsTables from "@/components/ResultsTables";
|
|
import RoundEvaluationPanel from "@/components/RoundEvaluationPanel";
|
|
import { Button, Card, CardHeader, CardBody, Divider, Tabs, Tab } from "@heroui/react";
|
|
import { useContestStore } from "@/stores/contestStore";
|
|
import { useState, useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useUserStore } from "@/stores/userStore";
|
|
import axios from "axios";
|
|
import useRoundEvaluationRun from "@/hooks/useRoundEvaluationRun";
|
|
|
|
export default function RoundDetailPage() {
|
|
const { contestId, roundId } = useParams();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const cId = contestId ? Number(contestId) : null;
|
|
const rId = roundId ? Number(roundId) : null;
|
|
const selectedRound = useContestStore((s) => s.selectedRound);
|
|
const user = useUserStore((s) => s.user);
|
|
const [logsRefreshKey, setLogsRefreshKey] = useState(0);
|
|
const selectedTab = searchParams.get("tab") ?? "verified";
|
|
const [declaredFilter, setDeclaredFilter] = useState<"ALL" | "OK">("OK");
|
|
const [finalFilter, setFinalFilter] = useState<"ALL" | "OK">("ALL");
|
|
const [finalResultLabel, setFinalResultLabel] = useState<string | null>(null);
|
|
const [finalResultClass, setFinalResultClass] = useState<string>("");
|
|
const [finalResultType, setFinalResultType] = useState<string | null>(null);
|
|
const [recalcLoading, setRecalcLoading] = useState(false);
|
|
const [recalcMessage, setRecalcMessage] = useState<string | null>(null);
|
|
const [recalcError, setRecalcError] = useState<string | null>(null);
|
|
const [finalPublished, setFinalPublished] = useState(false);
|
|
const { run } = useRoundEvaluationRun(rId);
|
|
const { t } = useTranslation("common");
|
|
const isAdmin = !!user?.is_admin;
|
|
const roundDeadline =
|
|
selectedRound?.id === rId ? selectedRound?.logs_deadline ?? null : null;
|
|
const anonymousUploadClosed = () => {
|
|
if (user) return false;
|
|
if (!roundDeadline) return false;
|
|
const deadline = new Date(roundDeadline);
|
|
if (Number.isNaN(deadline.getTime())) return false;
|
|
return new Date() > deadline;
|
|
};
|
|
const publicResultType = finalResultType ?? run?.result_type ?? null;
|
|
const shouldShowEvaluatingMessage =
|
|
!user &&
|
|
((run && run.status !== "SUCCEEDED") || publicResultType === "TEST");
|
|
const evaluatingLabel = t("results_evaluating") ?? "Vyhodnocuje se";
|
|
const filterAllLabel = t("results_filter_all") ?? "Všechny výsledky";
|
|
const filterOkLabel = t("results_filter_ok_ol") ?? "OK/OL závodníci";
|
|
|
|
const handleTabChange = (key: any) => {
|
|
const params = new URLSearchParams(searchParams);
|
|
params.set("tab", String(key));
|
|
setSearchParams(params, { replace: true });
|
|
};
|
|
|
|
const handleRecalculate = async () => {
|
|
if (!rId) return;
|
|
try {
|
|
setRecalcLoading(true);
|
|
setRecalcMessage(null);
|
|
setRecalcError(null);
|
|
await axios.post(`/api/rounds/${rId}/recalculate-claimed`, null, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
});
|
|
setRecalcMessage(
|
|
(t("declared_recalculate_started") as string) || "Přepočet byl spuštěn."
|
|
);
|
|
} catch (e: any) {
|
|
const fallback =
|
|
(t("declared_recalculate_failed") as string) ||
|
|
"Nepodařilo se spustit přepočet.";
|
|
const msg = e?.response?.data?.message || fallback;
|
|
setRecalcError(msg);
|
|
} finally {
|
|
setRecalcLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isAdmin) {
|
|
setFinalPublished(true);
|
|
return;
|
|
}
|
|
if (!rId) {
|
|
setFinalPublished(false);
|
|
return;
|
|
}
|
|
const officialRunId =
|
|
selectedRound?.id === rId ? selectedRound?.official_evaluation_run_id ?? null : null;
|
|
if (!officialRunId) {
|
|
setFinalPublished(false);
|
|
return;
|
|
}
|
|
let active = true;
|
|
(async () => {
|
|
try {
|
|
const res = await axios.get<{ status?: string | null; result_type?: string | null }>(
|
|
`/api/evaluation-runs/${officialRunId}`,
|
|
{
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
}
|
|
);
|
|
if (!active) return;
|
|
const isFinal =
|
|
res.data?.status === "SUCCEEDED" && res.data?.result_type === "FINAL";
|
|
setFinalPublished(isFinal);
|
|
} catch {
|
|
if (!active) return;
|
|
setFinalPublished(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [rId, selectedRound?.official_evaluation_run_id, isAdmin]);
|
|
|
|
return (
|
|
<>
|
|
<Card>
|
|
<CardHeader>
|
|
<span className="text-md font-semibold">
|
|
{t("round_detail_title") ?? "Detail kola"}
|
|
</span>
|
|
</CardHeader>
|
|
<Divider />
|
|
<CardBody>
|
|
<RoundDetail roundId={rId} />
|
|
</CardBody>
|
|
</Card>
|
|
{user && <RoundEvaluationPanel roundId={rId} />}
|
|
|
|
{anonymousUploadClosed && (
|
|
<RoundFileUpload
|
|
roundId={rId}
|
|
startTime={selectedRound?.start_time ?? null}
|
|
logsDeadline={roundDeadline}
|
|
onUploaded={() => setLogsRefreshKey((k) => k + 1)}
|
|
/>
|
|
)}
|
|
|
|
<Card className="mt-4">
|
|
<Tabs
|
|
aria-label={t("round_detail_tabs_aria") ?? "Round detail tabs"}
|
|
selectedKey={selectedTab}
|
|
onSelectionChange={handleTabChange}
|
|
>
|
|
<Tab key="verified" title={t("results_tab") ?? "Výsledky"}>
|
|
<CardBody>
|
|
{shouldShowEvaluatingMessage ? (
|
|
<div className="text-sm text-foreground-600">{evaluatingLabel}</div>
|
|
) : (
|
|
<>
|
|
{finalResultLabel && (
|
|
<div
|
|
className={[
|
|
"mb-2 inline-flex items-center rounded px-2 py-1 text-xs font-semibold",
|
|
finalResultClass,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
>
|
|
{finalResultLabel}
|
|
</div>
|
|
)}
|
|
<div className="mb-3 flex items-center gap-3 text-sm">
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="finalFilter"
|
|
value="OK"
|
|
checked={finalFilter === "OK"}
|
|
onChange={() => setFinalFilter("OK")}
|
|
/>
|
|
<span>{filterOkLabel}</span>
|
|
</label>
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="finalFilter"
|
|
value="ALL"
|
|
checked={finalFilter === "ALL"}
|
|
onChange={() => setFinalFilter("ALL")}
|
|
/>
|
|
<span>{filterAllLabel}</span>
|
|
</label>
|
|
</div>
|
|
<ResultsTables
|
|
roundId={rId}
|
|
contestId={cId}
|
|
filter={finalFilter}
|
|
mode="final"
|
|
showResultTypeLabel={false}
|
|
isFinalPublished={finalPublished}
|
|
isAdmin={isAdmin}
|
|
onResultTypeChange={(label, className, resultType) => {
|
|
setFinalResultLabel(label);
|
|
setFinalResultClass(className);
|
|
setFinalResultType(resultType);
|
|
}}
|
|
refreshKey={run?.result_type ?? ""}
|
|
evaluationRunId={run?.id ?? null}
|
|
/>
|
|
</>
|
|
)}
|
|
</CardBody>
|
|
</Tab>
|
|
<Tab key="declared" title={t("declared_results_tab") ?? "Deklarované výsledky"}>
|
|
<CardBody>
|
|
<div className="mb-3 text-sm text-foreground-600 space-y-1">
|
|
<p>{t("declared_note_line1") ?? "Deklarované výsledky jsou předběžné výsledky OK a OL stanic."}</p>
|
|
<p>{t("declared_note_line2") ?? "Zobrazené mezinárodní výsledky nejsou oficiální výsledky a slouží pouze pro porovnání."}</p>
|
|
<p>{t("declared_note_line3") ?? "Deklarované výsledky jsou uspořádány na základě údajů v hlavičce EDI souborů v řádce CQSOP=. Další sloupce rovněž zobrazují data z deníku, které jsou kontrolovány pouze na správnost formátu zápisu."}</p>
|
|
</div>
|
|
<div className="mb-3 flex items-center gap-3 text-sm">
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="declaredFilter"
|
|
value="OK"
|
|
checked={declaredFilter === "OK"}
|
|
onChange={() => setDeclaredFilter("OK")}
|
|
/>
|
|
<span>{filterOkLabel}</span>
|
|
</label>
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="declaredFilter"
|
|
value="ALL"
|
|
checked={declaredFilter === "ALL"}
|
|
onChange={() => setDeclaredFilter("ALL")}
|
|
/>
|
|
<span>{filterAllLabel}</span>
|
|
</label>
|
|
{user && (
|
|
<Button
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={handleRecalculate}
|
|
isLoading={recalcLoading}
|
|
isDisabled={!rId}
|
|
>
|
|
{t("declared_recalculate") ?? "Přepočítat"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{recalcMessage && <div className="mb-2 text-sm text-green-600">{recalcMessage}</div>}
|
|
{recalcError && <div className="mb-2 text-sm text-red-600">{recalcError}</div>}
|
|
<ResultsTables
|
|
roundId={rId}
|
|
contestId={cId}
|
|
filter={declaredFilter}
|
|
isFinalPublished={finalPublished}
|
|
isAdmin={isAdmin}
|
|
/>
|
|
</CardBody>
|
|
</Tab>
|
|
<Tab key="logs" title={t("uploaded_logs_tab") ?? "Nahrané logy"}>
|
|
<CardBody>
|
|
<LogsTable roundId={rId} contestId={cId} refreshKey={logsRefreshKey} />
|
|
</CardBody>
|
|
</Tab>
|
|
</Tabs>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|