Nezobrazovat detail logu anonymnímu uživateli #2 - i v tabulce s deklarovanými výsledky byl lokátor a utíkaly informace závodníkům před uzávěrkou.

This commit is contained in:
Zdeněk Burda
2026-01-10 13:19:44 +01:00
parent 1e484aef47
commit cdc1082ae8
4 changed files with 287 additions and 60 deletions

View File

@@ -58,6 +58,8 @@ type ResultsTablesProps = {
onResultTypeChange?: (label: string | null, className: string, resultType: string | null) => void;
refreshKey?: string | number | null;
evaluationRunId?: number | null;
isFinalPublished?: boolean;
isAdmin?: boolean;
};
@@ -70,6 +72,8 @@ export default function ResultsTables({
onResultTypeChange,
refreshKey = null,
evaluationRunId = null,
isFinalPublished = false,
isAdmin = false,
}: ResultsTablesProps) {
const [items, setItems] = useState<LogResultItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -86,6 +90,8 @@ export default function ResultsTables({
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const showScoreTotal = mode === "final";
const showLocatorOdx = mode === "final" || isAdmin || isFinalPublished;
useEffect(() => {
if (!roundId) return;
@@ -251,12 +257,16 @@ export default function ResultsTables({
const headerColumns = [
<TableColumn key="rank" className="whitespace-nowrap">{t("results_table_rank") ?? "Pořadí"}</TableColumn>,
<TableColumn key="callsign" className="whitespace-nowrap">{t("results_table_callsign") ?? "Značka v závodě"}</TableColumn>,
<TableColumn key="locator" className="whitespace-nowrap">{t("results_table_locator") ?? "Lokátor"}</TableColumn>,
showLocatorOdx
? <TableColumn key="locator" className="whitespace-nowrap">{t("results_table_locator") ?? "Lokátor"}</TableColumn>
: null,
<TableColumn key="category" className="whitespace-nowrap">{t("results_table_category") ?? "Kategorie"}</TableColumn>,
<TableColumn key="band" className="whitespace-nowrap">{t("results_table_band") ?? "Pásmo"}</TableColumn>,
<TableColumn key="power_watt" className="whitespace-nowrap">{t("results_table_power_watt") ?? "Výkon [W]"}</TableColumn>,
<TableColumn key="power_category" className="whitespace-nowrap">{t("results_table_power_category") ?? "Výkonová kat."}</TableColumn>,
<TableColumn key="score_total" className="whitespace-nowrap">{t("results_table_score_total") ?? "Body celkem"}</TableColumn>,
showScoreTotal
? <TableColumn key="score_total" className="whitespace-nowrap">{t("results_table_score_total") ?? "Body celkem"}</TableColumn>
: null,
<TableColumn key="claimed_score" className="whitespace-nowrap">{t("results_table_claimed_score") ?? "Deklarované body"}</TableColumn>,
<TableColumn key="qso_count" className="whitespace-nowrap">{t("results_table_qso_count") ?? "Počet QSO"}</TableColumn>,
showCalculatedColumns
@@ -275,7 +285,9 @@ export default function ResultsTables({
? <TableColumn key="unique_qso" className="whitespace-nowrap">{t("results_table_unique_qso") ?? "Unique QSO"}</TableColumn>
: null,
<TableColumn key="score_per_qso" className="whitespace-nowrap">{t("results_table_score_per_qso") ?? "Body / QSO"}</TableColumn>,
<TableColumn key="odx" className="whitespace-nowrap">{t("results_table_odx") ?? "ODX"}</TableColumn>,
showLocatorOdx
? <TableColumn key="odx" className="whitespace-nowrap">{t("results_table_odx") ?? "ODX"}</TableColumn>
: null,
<TableColumn key="antenna" className="whitespace-nowrap">{t("results_table_antenna") ?? "Anténa"}</TableColumn>,
<TableColumn key="antenna_height" className="whitespace-nowrap">{t("results_table_antenna_height") ?? "Ant. height"}</TableColumn>,
showCalculatedColumns
@@ -293,7 +305,7 @@ export default function ResultsTables({
<span className="text-md font-semibold">{heading}</span>
<button
type="button"
onClick={() => exportCsv(heading, sorted, rankField, mode)}
onClick={() => exportCsv(heading, sorted, rankField, mode, showLocatorOdx, showScoreTotal)}
className="text-xs text-primary-600 hover:text-primary-700 underline"
>
CSV
@@ -326,7 +338,9 @@ export default function ResultsTables({
<TableCell key="callsign" className={callsignClassName}>
{item.log?.pcall ?? "—"}
</TableCell>,
<TableCell key="locator" className="whitespace-nowrap">{item.log?.pwwlo ?? "—"}</TableCell>,
showLocatorOdx
? <TableCell key="locator" className="whitespace-nowrap">{item.log?.pwwlo ?? "—"}</TableCell>
: null,
<TableCell key="category" className="whitespace-nowrap">
{item.category?.name ?? "—"}
</TableCell>,
@@ -339,7 +353,9 @@ export default function ResultsTables({
<TableCell key="power_category" className="whitespace-nowrap">
{item.power_category?.name ?? "—"}
</TableCell>,
<TableCell key="score_total" className="whitespace-nowrap">{formatScore(item, mode)}</TableCell>,
showScoreTotal
? <TableCell key="score_total" className="whitespace-nowrap">{formatScore(item, mode)}</TableCell>
: null,
<TableCell key="claimed_score" className="whitespace-nowrap">{formatNumber(item.claimed_score)}</TableCell>,
<TableCell key="qso_count" className="whitespace-nowrap">{formatQsoCount(item, mode)}</TableCell>,
showCalculatedColumns
@@ -352,7 +368,9 @@ export default function ResultsTables({
? <TableCell key="unique_qso" className="whitespace-nowrap">{formatNumber(item.unique_qso_count)}</TableCell>
: null,
<TableCell key="score_per_qso" className="whitespace-nowrap">{formatScorePerQso(item.score_per_qso)}</TableCell>,
<TableCell key="odx" className="whitespace-nowrap">{item.log?.codxc ?? "—"}</TableCell>,
showLocatorOdx
? <TableCell key="odx" className="whitespace-nowrap">{item.log?.codxc ?? "—"}</TableCell>
: null,
<TableCell key="antenna" className="whitespace-nowrap">{item.log?.sante ?? "—"}</TableCell>,
<TableCell key="antenna_height" className="whitespace-nowrap">{item.log?.santh ?? "—"}</TableCell>,
showCalculatedColumns
@@ -580,22 +598,29 @@ function isPowerA(item: LogResultItem) {
return name === "a";
}
function exportCsv(title: string, rows: LogResultItem[], rankField: RankField, mode: "claimed" | "final") {
function exportCsv(
title: string,
rows: LogResultItem[],
rankField: RankField,
mode: "claimed" | "final",
showLocatorOdx: boolean,
showScoreTotal: boolean
) {
const includeCalculated = mode === "final";
const header = [
"Poradi",
"Značka v závodě",
"Lokátor",
...(showLocatorOdx ? ["Lokátor"] : []),
"Kategorie",
"Pásmo",
"Výkon [W]",
"Výkonová kat.",
"Body celkem",
...(showScoreTotal ? ["Body celkem"] : []),
"Deklarované body",
"Počet QSO",
...(includeCalculated ? ["Vyřazeno QSO", "Vyřazeno bodů", "Unique QSO"] : []),
"Body / QSO",
"ODX",
...(showLocatorOdx ? ["ODX"] : []),
"Anténa",
"Ant. height",
];
@@ -605,31 +630,32 @@ function exportCsv(title: string, rows: LogResultItem[], rankField: RankField, m
const qsoCount = getQsoCount(r, mode);
const ratio = formatScorePerQso(r.score_per_qso);
const discardedQso = formatDiscardedQso(r);
const base = [
r[rankField] ?? "",
r.log?.pcall ?? "",
r.log?.pwwlo ?? "",
r.category?.name ?? "",
r.band?.name ?? "",
r.log?.power_watt ?? "",
r.power_category?.name ?? "",
score ?? "",
r.claimed_score ?? "",
qsoCount ?? "",
ratio === "—" ? "" : ratio,
r.log?.codxc ?? "",
r.log?.sante ?? "",
r.log?.santh ?? "",
];
if (includeCalculated) {
base.splice(
10,
0,
discardedQso === "—" ? "" : discardedQso,
r.discarded_points ?? "",
r.unique_qso_count ?? ""
);
const base: Array<string | number> = [];
base.push(r[rankField] ?? "");
base.push(r.log?.pcall ?? "");
if (showLocatorOdx) {
base.push(r.log?.pwwlo ?? "");
}
base.push(r.category?.name ?? "");
base.push(r.band?.name ?? "");
base.push(r.log?.power_watt ?? "");
base.push(r.power_category?.name ?? "");
if (showScoreTotal) {
base.push(score ?? "");
}
base.push(r.claimed_score ?? "");
base.push(qsoCount ?? "");
if (includeCalculated) {
base.push(discardedQso === "—" ? "" : discardedQso);
base.push(r.discarded_points ?? "");
base.push(r.unique_qso_count ?? "");
}
base.push(ratio === "—" ? "" : ratio);
if (showLocatorOdx) {
base.push(r.log?.codxc ?? "");
}
base.push(r.log?.sante ?? "");
base.push(r.log?.santh ?? "");
return base.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",");
});

View File

@@ -6,7 +6,7 @@ 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 } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useUserStore } from "@/stores/userStore";
import axios from "axios";
@@ -21,7 +21,7 @@ export default function RoundDetailPage() {
const user = useUserStore((s) => s.user);
const [logsRefreshKey, setLogsRefreshKey] = useState(0);
const selectedTab = searchParams.get("tab") ?? "verified";
const [declaredFilter, setDeclaredFilter] = useState<"ALL" | "OK">("ALL");
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>("");
@@ -29,8 +29,10 @@ export default function RoundDetailPage() {
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 = () => {
@@ -78,6 +80,45 @@ export default function RoundDetailPage() {
}
};
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>
@@ -127,16 +168,6 @@ export default function RoundDetailPage() {
</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="ALL"
checked={finalFilter === "ALL"}
onChange={() => setFinalFilter("ALL")}
/>
<span>{filterAllLabel}</span>
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
@@ -147,6 +178,16 @@ export default function RoundDetailPage() {
/>
<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}
@@ -154,6 +195,8 @@ export default function RoundDetailPage() {
filter={finalFilter}
mode="final"
showResultTypeLabel={false}
isFinalPublished={finalPublished}
isAdmin={isAdmin}
onResultTypeChange={(label, className, resultType) => {
setFinalResultLabel(label);
setFinalResultClass(className);
@@ -174,16 +217,6 @@ export default function RoundDetailPage() {
<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="ALL"
checked={declaredFilter === "ALL"}
onChange={() => setDeclaredFilter("ALL")}
/>
<span>{filterAllLabel}</span>
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
@@ -194,6 +227,16 @@ export default function RoundDetailPage() {
/>
<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"
@@ -208,7 +251,13 @@ export default function RoundDetailPage() {
</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} />
<ResultsTables
roundId={rId}
contestId={cId}
filter={declaredFilter}
isFinalPublished={finalPublished}
isAdmin={isAdmin}
/>
</CardBody>
</Tab>
<Tab key="logs" title={t("uploaded_logs_tab") ?? "Nahrané logy"}>