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 = { 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([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [overrideReasons, setOverrideReasons] = useState>({}); const [overrideFlags, setOverrideFlags] = useState>({}); 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 = { 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>("/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>("/api/log-overrides", { params: { evaluation_run_id: evaluationRunId, per_page: 5000, }, headers: { Accept: "application/json" }, withCredentials: true, }); if (!active) return; const map: Record = {}; const flagMap: Record = {}; (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 = [ {t("results_table_rank") ?? "Pořadí"}, {t("results_table_callsign") ?? "Značka v závodě"}, showLocatorOdx ? {t("results_table_locator") ?? "Lokátor"} : null, {t("results_table_category") ?? "Kategorie"}, {t("results_table_band") ?? "Pásmo"}, {t("results_table_power_watt") ?? "Výkon [W]"}, {t("results_table_power_category") ?? "Výkonová kat."}, showScoreTotal ? {t("results_table_score_total") ?? "Body celkem"} : null, {t("results_table_claimed_score") ?? "Deklarované body"}, {t("results_table_qso_count") ?? "Počet QSO"}, showCalculatedColumns ? ( {t("results_table_discarded_qso") ?? "Vyřazeno QSO"} ) : null, showCalculatedColumns ? {t("results_table_discarded_points") ?? "Vyřazeno bodů"} : null, showCalculatedColumns ? {t("results_table_unique_qso") ?? "Unique QSO"} : null, {t("results_table_score_per_qso") ?? "Body / QSO"}, showLocatorOdx ? {t("results_table_odx") ?? "ODX"} : null, {t("results_table_antenna") ?? "Anténa"}, {t("results_table_antenna_height") ?? "Ant. height"}, showCalculatedColumns ? {t("results_table_status") ?? "Status"} : null, showCalculatedColumns ? {t("results_table_override_reason") ?? "Komentář rozhodčího"} : null, ].filter(Boolean); return (
{heading}
{headerColumns} {(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 = [ {item[rankField] ?? "—"}, {item.log?.pcall ?? "—"} , showLocatorOdx ? {item.log?.pwwlo ?? "—"} : null, {item.category?.name ?? "—"} , {item.band?.name ?? "—"} , {formatNumber(item.log?.power_watt)} , {item.power_category?.name ?? "—"} , showScoreTotal ? {formatScore(item, mode)} : null, {formatNumber(item.claimed_score)}, {formatQsoCount(item, mode)}, showCalculatedColumns ? {formatDiscardedQso(item)} : null, showCalculatedColumns ? {formatNumber(item.discarded_points)} : null, showCalculatedColumns ? {formatNumber(item.unique_qso_count)} : null, {formatScorePerQso(item.score_per_qso)}, showLocatorOdx ? {item.log?.codxc ?? "—"} : null, {item.log?.sante ?? "—"}, {item.log?.santh ?? "—"}, showCalculatedColumns ? {item.status ?? "—"} : null, showCalculatedColumns ? ( {(() => { const overrideReason = hasOverride && logId != null ? overrideReasons[logId] : null; const statusReason = item.status_reason ?? null; if (overrideReason && statusReason) { return `${overrideReason}; ${statusReason}`; } return overrideReason || statusReason || "—"; })()} ) : null, ].filter(Boolean); return ( { 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} ); }}
); }); }; 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>((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
{label}
; } if (error) return
{error}
; return (
{mode === "final" && showResultTypeLabel && resultTypeLabel && (
{resultTypeLabel}
)} {renderOverallWithPowers(grouped.standardOverall, grouped.standardPower, "")} {renderOverallWithPowers(grouped.sixhOverall, [], "6H", grouped.sixhrRankingMode !== "IARU")}
); } type GroupedResults = Array<{ key: string; items: LogResultItem[] }>; function groupBy(items: LogResultItem[], getKey: (r: LogResultItem) => string): GroupedResults { const map = new Map(); 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 = []; 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`); }