Files
vkv/resources/js/components/ResultsTables.tsx

667 lines
26 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Card, CardBody, CardHeader, Divider, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, Spinner, Tooltip } from "@heroui/react";
import { saveAs } from "file-saver";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
type LogResultItem = {
id: number;
log_id: number;
band_id?: number | null;
category_id?: number | null;
power_category_id?: number | null;
sixhr_category?: boolean | null;
claimed_qso_count?: number | null;
claimed_score?: number | null;
total_qso_count?: number | null;
discarded_qso_count?: number | null;
discarded_qso_percent?: number | null;
discarded_points?: number | null;
unique_qso_count?: number | null;
official_score?: number | null;
valid_qso_count?: number | null;
score_per_qso?: number | null;
rank_overall?: number | null;
rank_in_category?: number | null;
rank_overall_ok?: number | null;
rank_in_category_ok?: number | null;
status?: string | null;
status_reason?: string | null;
log?: {
id: number;
pcall?: string | null;
sixhr_category?: boolean | null;
pwwlo?: string | null;
power_watt?: number | null;
codxc?: string | null;
sante?: string | null;
santh?: string | null;
} | null;
band?: { id: number; name?: string | null; order?: number | null } | null;
category?: { id: number; name?: string | null; order?: number | null } | null;
power_category?: { id: number; name?: string | null; order?: number | null } | null;
evaluation_run?: { id: number; result_type?: string | null; rule_set?: { sixhr_ranking_mode?: string | null } | null } | null;
};
type ApiResponse<T> = {
data: T[];
};
type ResultsTablesProps = {
roundId: number | null;
contestId?: number | null;
filter?: "ALL" | "OK";
mode?: "claimed" | "final";
showResultTypeLabel?: boolean;
onResultTypeChange?: (label: string | null, className: string, resultType: string | null) => void;
refreshKey?: string | number | null;
evaluationRunId?: number | null;
isFinalPublished?: boolean;
isAdmin?: boolean;
};
export default function ResultsTables({
roundId,
contestId = null,
filter = "ALL",
mode = "claimed",
showResultTypeLabel = true,
onResultTypeChange,
refreshKey = null,
evaluationRunId = null,
isFinalPublished = false,
isAdmin = false,
}: ResultsTablesProps) {
const [items, setItems] = useState<LogResultItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [overrideReasons, setOverrideReasons] = useState<Record<number, string | null>>({});
const [overrideFlags, setOverrideFlags] = useState<Record<number, {
forced_log_status?: string | null;
forced_band_id?: number | null;
forced_category_id?: number | null;
forced_power_category_id?: number | null;
forced_power_w?: number | null;
forced_sixhr_category?: boolean | null;
}>>({});
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const showScoreTotal = mode === "final";
const showLocatorOdx = mode === "final" || isAdmin || isFinalPublished;
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const params: Record<string, unknown> = {
per_page: 5000,
only_ok: filter === "OK",
};
if (evaluationRunId) {
params.evaluation_run_id = evaluationRunId;
}
if (mode === "claimed") {
params.round_id = roundId;
params.status = "CLAIMED";
} else if (!evaluationRunId) {
params.round_id = roundId;
params.result_type = "AUTO";
}
const res = await axios.get<ApiResponse<LogResultItem>>("/api/log-results", {
params,
headers: { Accept: "application/json" },
});
if (!active) return;
setItems(res.data.data ?? []);
} catch (e: any) {
if (!active) return;
const msg =
e?.response?.data?.message ||
(mode === "final"
? "Nepodařilo se načíst finální výsledky."
: "Nepodařilo se načíst deklarované výsledky.");
setError(msg);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [roundId, filter, mode, refreshKey, evaluationRunId]);
useEffect(() => {
if (mode !== "final") {
setOverrideReasons({});
return;
}
const evaluationRunId = items[0]?.evaluation_run?.id ?? null;
if (!evaluationRunId) {
setOverrideReasons({});
return;
}
let active = true;
(async () => {
try {
const res = await axios.get<ApiResponse<{
log_id: number;
reason?: string | null;
forced_log_status?: string | null;
forced_band_id?: number | null;
forced_category_id?: number | null;
forced_power_category_id?: number | null;
forced_power_w?: number | null;
forced_sixhr_category?: boolean | null;
}>>("/api/log-overrides", {
params: {
evaluation_run_id: evaluationRunId,
per_page: 5000,
},
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
const map: Record<number, string | null> = {};
const flagMap: Record<number, {
forced_log_status?: string | null;
forced_band_id?: number | null;
forced_category_id?: number | null;
forced_power_category_id?: number | null;
forced_power_w?: number | null;
forced_sixhr_category?: boolean | null;
}> = {};
(res.data.data ?? []).forEach((item) => {
map[item.log_id] = item.reason ?? null;
flagMap[item.log_id] = {
forced_log_status: item.forced_log_status ?? null,
forced_band_id: item.forced_band_id ?? null,
forced_category_id: item.forced_category_id ?? null,
forced_power_category_id: item.forced_power_category_id ?? null,
forced_power_w: item.forced_power_w ?? null,
forced_sixhr_category: item.forced_sixhr_category ?? null,
};
});
setOverrideReasons(map);
setOverrideFlags(flagMap);
} catch {
if (!active) return;
setOverrideReasons({});
setOverrideFlags({});
}
})();
return () => {
active = false;
};
}, [mode, items]);
const grouped = useMemo(() => {
const isSixhr = (r: LogResultItem) => (r.sixhr_category ?? r.log?.sixhr_category) === true;
const sixhrRankingMode =
mode === "final"
? (items[0]?.evaluation_run?.rule_set?.sixhr_ranking_mode ?? "IARU")
: "CRK";
const sixh = items.filter((r) => isSixhr(r));
const standard = items.filter((r) => !isSixhr(r));
const powerEligible = standard.filter(
(r) => !isCheckCategory(r) && !isPowerA(r) && r.power_category_id && r.rank_in_category !== null
);
const groupOverall = (list: LogResultItem[]) =>
groupBy(list, (r) => `${r.band_id ?? "null"}|${r.category_id ?? "null"}`);
const groupSixhOverall = (list: LogResultItem[]) =>
groupBy(list, (r) =>
sixhrRankingMode === "IARU"
? `${r.band_id ?? "null"}|ALL`
: `${r.band_id ?? "null"}|${r.category_id ?? "null"}`
);
const groupPower = (list: LogResultItem[]) =>
groupBy(list, (r) => `${r.band_id ?? "null"}|${r.category_id ?? "null"}|${r.power_category_id ?? "null"}`);
return {
sixhOverall: groupSixhOverall(sixh),
standardOverall: groupOverall(standard),
standardPower: groupPower(powerEligible),
sixhrRankingMode,
};
}, [items, mode]);
const overrideVersion = useMemo(() => Object.keys(overrideReasons).sort().join(","), [overrideReasons]);
const showCalculatedColumns = mode === "final";
const renderGroup = (
group: GroupedResults,
title: string,
rankField: RankField,
includePowerInHeading = true,
includeCategoryInHeading = true
) => {
if (group.length === 0) return [];
return group.map(({ key, items }) => {
const sample = items[0];
const [bandName, categoryName, powerName] = resolveNames(sample);
const headingParts = [bandName, includeCategoryInHeading ? categoryName : null, title];
if (includePowerInHeading && !isCheckCategory(sample)) {
headingParts.push(powerName);
}
const heading = headingParts.filter(Boolean).join(" ");
const sorted = sortResults(items, rankField, mode);
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>,
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>,
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
? (
<TableColumn key="discarded_qso" className="whitespace-nowrap">
<Tooltip content={t("results_table_discarded_qso_help") ?? "Počet QSO s is_valid=false."}>
<span className="cursor-help">{t("results_table_discarded_qso") ?? "Vyřazeno QSO"}</span>
</Tooltip>
</TableColumn>
)
: null,
showCalculatedColumns
? <TableColumn key="discarded_points" className="whitespace-nowrap">{t("results_table_discarded_points") ?? "Vyřazeno bodů"}</TableColumn>
: null,
showCalculatedColumns
? <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>,
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
? <TableColumn key="status" className="whitespace-nowrap">{t("results_table_status") ?? "Status"}</TableColumn>
: null,
showCalculatedColumns
? <TableColumn key="override_reason" className="whitespace-nowrap">{t("results_table_override_reason") ?? "Komentář rozhodčího"}</TableColumn>
: null,
].filter(Boolean);
return (
<Card key={key} className="mb-4">
<CardHeader>
<div className="flex items-center justify-between w-full gap-2">
<span className="text-md font-semibold">{heading}</span>
<button
type="button"
onClick={() => exportCsv(heading, sorted, rankField, mode, showLocatorOdx, showScoreTotal)}
className="text-xs text-primary-600 hover:text-primary-700 underline"
>
CSV
</button>
</div>
</CardHeader>
<Divider />
<CardBody>
<Table
key={`${key}-${overrideVersion}`}
radius="sm"
isCompact
aria-label={heading}
removeWrapper
fullWidth
classNames={{
th: "py-1 px-1 text-[11px] leading-tight",
td: "py-0.5 px-1 text-[11px] leading-tight bg-transparent",
}}
>
<TableHeader>{headerColumns}</TableHeader>
<TableBody emptyContent="No rows to display" items={sorted} className="text-[11px] leading-tight">
{(item) => {
const logId = item.log_id ?? item.log?.id;
const hasOverride =
logId != null && Object.prototype.hasOwnProperty.call(overrideReasons, logId);
const callsignClassName = "whitespace-nowrap";
const rowCells = [
<TableCell key="rank" className="whitespace-nowrap">{item[rankField] ?? "—"}</TableCell>,
<TableCell key="callsign" className={callsignClassName}>
{item.log?.pcall ?? "—"}
</TableCell>,
showLocatorOdx
? <TableCell key="locator" className="whitespace-nowrap">{item.log?.pwwlo ?? "—"}</TableCell>
: null,
<TableCell key="category" className="whitespace-nowrap">
{item.category?.name ?? "—"}
</TableCell>,
<TableCell key="band" className="whitespace-nowrap">
{item.band?.name ?? "—"}
</TableCell>,
<TableCell key="power_watt" className="whitespace-nowrap">
{formatNumber(item.log?.power_watt)}
</TableCell>,
<TableCell key="power_category" className="whitespace-nowrap">
{item.power_category?.name ?? "—"}
</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
? <TableCell key="discarded_qso" className="whitespace-nowrap">{formatDiscardedQso(item)}</TableCell>
: null,
showCalculatedColumns
? <TableCell key="discarded_points" className="whitespace-nowrap">{formatNumber(item.discarded_points)}</TableCell>
: null,
showCalculatedColumns
? <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>,
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
? <TableCell key="status" className="whitespace-nowrap">{item.status ?? "—"}</TableCell>
: null,
showCalculatedColumns
? (
<TableCell key="override_reason" className="whitespace-nowrap">
{(() => {
const overrideReason = hasOverride && logId != null ? overrideReasons[logId] : null;
const statusReason = item.status_reason ?? null;
if (overrideReason && statusReason) {
return `${overrideReason}; ${statusReason}`;
}
return overrideReason || statusReason || "—";
})()}
</TableCell>
)
: null,
].filter(Boolean);
return (
<TableRow
key={item.id}
onClick={() => {
if (contestId && roundId) {
navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.log_id}`, {
state: { from: `${location.pathname}${location.search}` },
});
}
}}
className={contestId && roundId ? "cursor-pointer hover:bg-default-100" : undefined}
>
{rowCells}
</TableRow>
);
}}
</TableBody>
</Table>
</CardBody>
</Card>
);
});
};
const rankOverallField: RankField = filter === "OK" ? "rank_overall_ok" : "rank_overall";
const rankInCategoryField: RankField = filter === "OK" ? "rank_in_category_ok" : "rank_in_category";
const renderOverallWithPowers = (
overalls: GroupedResults,
powers: GroupedResults,
title: string,
includeCategoryInHeading = true
) => {
if (overalls.length === 0) return null;
const powerIndex = powers.reduce<Record<string, GroupedResults>>((acc, group) => {
const [bandId, categoryId] = group.key.split("|");
const key = `${bandId}|${categoryId}`;
acc[key] = acc[key] || [];
acc[key].push(group);
return acc;
}, {});
return sortGroups(overalls).flatMap(({ key, items }) => {
const [bandId, categoryId] = key.split("|");
const powerGroups = sortGroups(powerIndex[`${bandId}|${categoryId}`] ?? []);
return [
...renderGroup([{ key, items }], title, rankOverallField, false, includeCategoryInHeading),
...renderGroup(powerGroups, "", rankInCategoryField, true),
];
});
};
if (!roundId) return null;
const resultType = items[0]?.evaluation_run?.result_type ?? null;
const resultTypeLabel =
resultType === "FINAL"
? (t("results_type_final") as string) || "Finální výsledky"
: resultType === "PRELIMINARY"
? (t("results_type_preliminary") as string) || "Předběžné výsledky"
: resultType === "TEST"
? (t("results_type_test") as string) || "Testovací výsledky"
: null;
const resultTypeClass =
resultType === "FINAL"
? "bg-success-200 text-success-900"
: resultType === "PRELIMINARY"
? "bg-success-100 text-success-900"
: resultType === "TEST"
? "bg-warning-200 text-warning-900"
: "";
useEffect(() => {
if (!onResultTypeChange) return;
onResultTypeChange(resultTypeLabel, resultTypeClass, resultType);
}, [onResultTypeChange, resultTypeLabel, resultTypeClass, resultType]);
if (loading) {
const label = mode === "final" ? "Načítám finální výsledky…" : "Načítám deklarované výsledky…";
return <div className="flex items-center gap-2 text-sm text-foreground-500"><Spinner size="sm" /> {label}</div>;
}
if (error) return <div className="text-sm text-red-600">{error}</div>;
return (
<div className="space-y-4">
{mode === "final" && showResultTypeLabel && resultTypeLabel && (
<div
className={[
"inline-flex items-center rounded px-2 py-1 text-xs font-semibold",
resultTypeClass,
]
.filter(Boolean)
.join(" ")}
>
{resultTypeLabel}
</div>
)}
{renderOverallWithPowers(grouped.standardOverall, grouped.standardPower, "")}
{renderOverallWithPowers(grouped.sixhOverall, [], "6H", grouped.sixhrRankingMode !== "IARU")}
</div>
);
}
type GroupedResults = Array<{ key: string; items: LogResultItem[] }>;
function groupBy(items: LogResultItem[], getKey: (r: LogResultItem) => string): GroupedResults {
const map = new Map<string, LogResultItem[]>();
items.forEach((item) => {
const key = getKey(item);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
});
return Array.from(map.entries()).map(([key, list]) => ({ key, items: list }));
}
function sortGroups(groups: GroupedResults): GroupedResults {
return [...groups].sort((a, b) => {
const aItem = a.items[0];
const bItem = b.items[0];
const bandOrder = (aItem?.band?.order ?? Number.MAX_SAFE_INTEGER) - (bItem?.band?.order ?? Number.MAX_SAFE_INTEGER);
if (bandOrder !== 0) return bandOrder;
const categoryOrder = (aItem?.category?.order ?? Number.MAX_SAFE_INTEGER) - (bItem?.category?.order ?? Number.MAX_SAFE_INTEGER);
if (categoryOrder !== 0) return categoryOrder;
const powerOrder =
(aItem?.power_category?.order ?? Number.MAX_SAFE_INTEGER) -
(bItem?.power_category?.order ?? Number.MAX_SAFE_INTEGER);
if (powerOrder !== 0) return powerOrder;
const bandName = aItem?.band?.name ?? "";
const bandNameB = bItem?.band?.name ?? "";
if (bandName !== bandNameB) return bandName.localeCompare(bandNameB);
const categoryName = aItem?.category?.name ?? "";
const categoryNameB = bItem?.category?.name ?? "";
if (categoryName !== categoryNameB) return categoryName.localeCompare(categoryNameB);
const powerName = aItem?.power_category?.name ?? "";
const powerNameB = bItem?.power_category?.name ?? "";
return powerName.localeCompare(powerNameB);
});
}
function resolveNames(item: LogResultItem): [string | null, string | null, string | null] {
const band = item.band?.name ?? null;
const category = item.category?.name ?? null;
const power = item.power_category?.name ?? null;
return [band, category, power];
}
type RankField = "rank_overall" | "rank_in_category" | "rank_overall_ok" | "rank_in_category_ok";
function sortResults(items: LogResultItem[], rankField: RankField, mode: "claimed" | "final") {
return [...items].sort((a, b) => {
const ra = a[rankField] ?? Number.MAX_SAFE_INTEGER;
const rb = b[rankField] ?? Number.MAX_SAFE_INTEGER;
if (ra !== rb) return ra - rb;
const sa = getScore(a, mode) ?? 0;
const sb = getScore(b, mode) ?? 0;
if (sa !== sb) return sb - sa;
const qa = getQsoCount(a, mode) ?? 0;
const qb = getQsoCount(b, mode) ?? 0;
return qb - qa;
});
}
function formatNumber(value: number | null | undefined) {
if (value === null || value === undefined) return "—";
if (Number.isInteger(value)) return value.toString();
return value.toFixed(1);
}
function formatDiscardedQso(item: LogResultItem) {
const count = item.discarded_qso_count;
if (count === null || count === undefined) return "—";
const percent = item.discarded_qso_percent;
if (percent === null || percent === undefined) return `${count}`;
return `${count} (${percent.toFixed(2)}%)`;
}
function formatScorePerQso(value?: number | null) {
if (value === null || value === undefined) return "—";
return value.toFixed(2);
}
function getScore(item: LogResultItem, mode: "claimed" | "final") {
return mode === "final" ? item.official_score ?? null : item.claimed_score ?? null;
}
function getQsoCount(item: LogResultItem, mode: "claimed" | "final") {
return mode === "final" ? item.valid_qso_count ?? null : item.claimed_qso_count ?? null;
}
function formatScore(item: LogResultItem, mode: "claimed" | "final") {
return getScore(item, mode) ?? "—";
}
function formatQsoCount(item: LogResultItem, mode: "claimed" | "final") {
return getQsoCount(item, mode) ?? "—";
}
function isCheckCategory(item: LogResultItem) {
const name = item.category?.name?.toLowerCase() ?? "";
return name.includes("check");
}
function isPowerA(item: LogResultItem) {
const name = item.power_category?.name?.toLowerCase() ?? "";
return name === "a";
}
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ě",
...(showLocatorOdx ? ["Lokátor"] : []),
"Kategorie",
"Pásmo",
"Výkon [W]",
"Výkonová kat.",
...(showScoreTotal ? ["Body celkem"] : []),
"Deklarované body",
"Počet QSO",
...(includeCalculated ? ["Vyřazeno QSO", "Vyřazeno bodů", "Unique QSO"] : []),
"Body / QSO",
...(showLocatorOdx ? ["ODX"] : []),
"Anténa",
"Ant. height",
];
const lines = rows.map((r) => {
const score = getScore(r, mode);
const qsoCount = getQsoCount(r, mode);
const ratio = formatScorePerQso(r.score_per_qso);
const discardedQso = formatDiscardedQso(r);
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(",");
});
const csv = [header.join(","), ...lines].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const safeTitle = title && title.trim() !== "" ? title : "results";
saveAs(blob, `${safeTitle.replace(/\s+/g, "_")}.csv`);
}