Files
vkv/resources/js/pages/RoundDetailPage.tsx

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>
</>
);
}