Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View File

@@ -0,0 +1,300 @@
import React, { useMemo } from "react";
import {
Button,
Card,
CardBody,
CardHeader,
Divider,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@heroui/react";
import type { LogResult, LogOverride } from "@/components/RoundEvaluationLogOverrides.types";
type GroupedResults = Array<{ key: string; items: LogResult[] }>;
type Props = {
items: LogResult[];
overrides: Record<number, LogOverride>;
onEdit: (logId: number) => void;
};
function PencilIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className={className ?? "h-4 w-4"}
>
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
</svg>
);
}
function groupBy(items: LogResult[], getKey: (r: LogResult) => string): GroupedResults {
const map = new Map<string, LogResult[]>();
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: LogResult): [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";
function isCheckCategory(item: LogResult) {
const name = item.category?.name?.toLowerCase() ?? "";
return name.includes("check");
}
function isPowerA(item: LogResult) {
const name = item.power_category?.name?.toLowerCase() ?? "";
return name === "a";
}
function sortResults(items: LogResult[], rankField: RankField) {
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 = a.official_score ?? 0;
const sb = b.official_score ?? 0;
if (sa !== sb) return sb - sa;
const qa = a.valid_qso_count ?? 0;
const qb = b.valid_qso_count ?? 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 formatRatio(score?: number | null, qso?: number | null) {
if (!score || !qso || qso === 0) return "—";
return (score / qso).toFixed(2);
}
function renderGroup(
group: GroupedResults,
title: string,
rankField: RankField,
includePowerInHeading: boolean,
overrides: Record<number, LogOverride>,
onEdit: (logId: number) => void
) {
if (group.length === 0) return [];
return group.map(({ key, items }) => {
const sample = items[0];
const [bandName, categoryName, powerName] = resolveNames(sample);
const headingParts = [bandName, categoryName, title];
if (includePowerInHeading && !isCheckCategory(sample)) {
headingParts.push(powerName);
}
const heading = headingParts.filter(Boolean).join(" ");
const sorted = sortResults(items, rankField);
return (
<Card key={key} className="mb-3">
<CardHeader className="py-2">
<div className="flex items-center justify-between w-full gap-2">
<span className="text-md font-semibold">{heading}</span>
</div>
</CardHeader>
<Divider />
<CardBody className="py-2">
<Table
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>
<TableColumn className="whitespace-nowrap">Pořadí</TableColumn>
<TableColumn className="whitespace-nowrap">Značka</TableColumn>
<TableColumn className="whitespace-nowrap">Lokátor</TableColumn>
<TableColumn className="whitespace-nowrap">Kategorie</TableColumn>
<TableColumn className="whitespace-nowrap">Pásmo</TableColumn>
<TableColumn className="whitespace-nowrap">Výkon [W]</TableColumn>
<TableColumn className="whitespace-nowrap">Výkonová kategorie</TableColumn>
<TableColumn className="whitespace-nowrap">Body celkem</TableColumn>
<TableColumn className="whitespace-nowrap">Počet QSO</TableColumn>
<TableColumn className="whitespace-nowrap">Body/QSO</TableColumn>
<TableColumn className="whitespace-nowrap">ODX</TableColumn>
<TableColumn className="whitespace-nowrap">Anténa</TableColumn>
<TableColumn className="whitespace-nowrap">Ant. height</TableColumn>
<TableColumn className="whitespace-nowrap">Status</TableColumn>
<TableColumn className="whitespace-nowrap">Komentář rozhodčího</TableColumn>
<TableColumn className="whitespace-nowrap">Akce</TableColumn>
</TableHeader>
<TableBody emptyContent="No rows to display" items={sorted} className="text-[11px] leading-tight">
{(item) => {
const override = overrides[item.log_id];
const rowHighlight =
!!override &&
(override.forced_log_status && override.forced_log_status !== "AUTO" ||
override.forced_band_id ||
override.forced_category_id ||
override.forced_power_category_id ||
override.forced_power_w !== null && override.forced_power_w !== undefined ||
override.forced_sixhr_category !== null && override.forced_sixhr_category !== undefined);
return (
<TableRow key={item.id} className={rowHighlight ? "bg-warning-100 hover:bg-warning-300" : "hover:bg-default-200"}>
<TableCell className="whitespace-nowrap">{item[rankField] ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.log?.pcall ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">
{item.log?.pwwlo ?? item.log?.locator ?? "—"}
</TableCell>
<TableCell className="whitespace-nowrap">{item.category?.name ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.band?.name ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">
{formatNumber(item.log?.power_watt)}
</TableCell>
<TableCell className="whitespace-nowrap">
{item.powerCategory?.name ?? item.power_category?.name ?? "—"}
</TableCell>
<TableCell className="whitespace-nowrap">{formatNumber(item.official_score)}</TableCell>
<TableCell className="whitespace-nowrap">{item.valid_qso_count ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">
{formatRatio(item.official_score, item.valid_qso_count)}
</TableCell>
<TableCell className="whitespace-nowrap">{item.log?.codxc ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.log?.sante ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.log?.santh ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.status ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{override?.reason ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">
<Button
isIconOnly
size="sm"
variant="light"
className="min-w-0 w-4 h-4 p-0"
onPress={() => onEdit(item.log_id)}
aria-label="Upravit override"
>
<PencilIcon className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</CardBody>
</Card>
);
});
}
function renderOverallWithPowers(
overalls: GroupedResults,
powers: GroupedResults,
title: string,
overrides: Record<number, LogOverride>,
onEdit: (logId: number) => void
) {
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, "rank_overall", false, overrides, onEdit),
...renderGroup(powerGroups, "", "rank_in_category", true, overrides, onEdit),
];
});
}
export default React.memo(function RoundEvaluationLogOverridesTable({
items,
overrides,
onEdit,
}: Props) {
const grouped = useMemo(() => {
const isSixhr = (r: LogResult) => (r.log?.sixhr_category ?? false) === true;
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 && r.rank_in_category !== null
);
const groupOverall = (list: LogResult[]) =>
groupBy(list, (r) => `${r.band?.id ?? "null"}|${r.category?.id ?? "null"}`);
const groupPower = (list: LogResult[]) =>
groupBy(
list,
(r) =>
`${r.band?.id ?? "null"}|${r.category?.id ?? "null"}|${r.power_category?.id ?? "null"}`
);
return {
sixhOverall: groupOverall(sixh),
standardOverall: groupOverall(standard),
standardPower: groupPower(powerEligible),
};
}, [items]);
return (
<>
{renderOverallWithPowers(grouped.standardOverall, grouped.standardPower, "", overrides, onEdit)}
{renderOverallWithPowers(grouped.sixhOverall, [], "6H", overrides, onEdit)}
</>
);
});