Files
vkv/resources/js/components/LogQsoTable.tsx
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

292 lines
9.7 KiB
TypeScript

import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
Tooltip,
} from "@heroui/react";
import { saveAs } from "file-saver";
type LogQsoItem = {
id: number;
qso_index?: number | null;
time_on?: string | null;
dx_call?: string | null;
my_rst?: string | null;
my_serial?: string | null;
dx_rst?: string | null;
dx_serial?: string | null;
rx_wwl?: string | null;
rx_exchange?: string | null;
mode_code?: string | null;
new_exchange?: boolean | null;
new_wwl?: boolean | null;
new_dxcc?: boolean | null;
duplicate_qso?: boolean | null;
points?: number | null;
remarks?: string | null;
};
type QsoResultItem = {
log_qso_id: number;
penalty_points?: number | null;
error_code?: string | null;
error_side?: string | null;
match_confidence?: string | null;
match_type?: string | null;
error_flags?: string[] | null;
};
type QsoOverrideInfo = {
reason?: string | null;
forced_status?: string | null;
forced_matched_log_qso_id?: number | null;
forced_points?: number | null;
forced_penalty?: number | null;
};
type LogQsoTableProps = {
qsos: LogQsoItem[];
locale: string;
formatDateTime: (value: string | null | undefined, locale: string) => string;
officialQsoResults: Record<number, QsoResultItem>;
qsoOverrides: Record<number, QsoOverrideInfo>;
emptyLabel: string;
callsign?: string | null;
};
export default function LogQsoTable({
qsos,
locale,
formatDateTime,
officialQsoResults,
qsoOverrides,
emptyLabel,
callsign,
}: LogQsoTableProps) {
if (!qsos || qsos.length === 0) {
return <div>{emptyLabel}</div>;
}
const getOverrideLabel = (override?: QsoOverrideInfo) => {
if (!override) return "—";
if (override.forced_status && override.forced_status !== "AUTO") {
return `STATUS: ${override.forced_status}`;
}
if (override.forced_matched_log_qso_id) {
return `MATCH: ${override.forced_matched_log_qso_id}`;
}
if (
override.forced_points !== null && override.forced_points !== undefined ||
override.forced_penalty !== null && override.forced_penalty !== undefined
) {
return "BODY: override";
}
return "OVERRIDE";
};
const toCsvValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "—") return "";
return String(value).replace(/"/g, '""');
};
const handleCsvExport = () => {
const header = [
"#",
"Time",
"Callsign",
"Mode",
"My RST",
"My Serial",
"DX RST",
"DX Serial",
"WWL",
"Exchange",
"Points",
"Penalty",
"New WWL",
"New DXCC",
"Dupe",
"Error",
"Side",
"Match",
"Override",
"Note",
];
const lines = qsos.map((qso) => {
const override =
qsoOverrides[qso.id] ??
qsoOverrides[String(qso.id) as unknown as number];
const matchResult = officialQsoResults[qso.id];
const note = override
? override.reason ?? "—"
: qso.remarks || "—";
const row = [
qso.qso_index ?? "",
formatDateTime(qso.time_on ?? null, locale),
qso.dx_call || "—",
qso.mode_code || "—",
qso.my_rst || "—",
qso.my_serial || "—",
qso.dx_rst || "—",
qso.dx_serial || "—",
qso.rx_wwl || "—",
qso.rx_exchange || "—",
qso.points ?? "—",
typeof matchResult?.penalty_points === "number"
? matchResult?.penalty_points
: "—",
qso.new_wwl ? "N" : "—",
qso.new_dxcc ? "N" : "—",
qso.duplicate_qso ? "D" : "—",
matchResult?.error_code ?? "—",
matchResult?.error_side && matchResult?.error_side !== "NONE"
? matchResult?.error_side
: "—",
matchResult?.match_confidence ?? "—",
getOverrideLabel(override),
note,
];
return row.map((value) => `"${toCsvValue(value)}"`).join(",");
});
const csv = [header.join(","), ...lines].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const safeCallsign = callsign?.trim() || "qso_table";
const fileName = safeCallsign.replace(/\\s+/g, "_");
saveAs(blob, `${fileName}.csv`);
};
return (
<div className="space-y-2">
<div className="flex justify-end">
<a
href="#"
className="text-xs text-foreground-500 hover:underline"
onClick={(event) => {
event.preventDefault();
handleCsvExport();
}}
>
CSV
</a>
</div>
<div className="overflow-x-auto">
<Table
aria-label="QSO table"
isCompact
radius="sm"
removeWrapper
className="min-w-max"
classNames={{
th: "py-1 px-1 text-[11px] leading-tight",
td: "py-0.5 px-1 text-[11px] leading-tight",
}}
>
<TableHeader>
<TableColumn>#</TableColumn>
<TableColumn>Čas</TableColumn>
<TableColumn>Volací znak</TableColumn>
<TableColumn>Mode</TableColumn>
<TableColumn>RST odeslané</TableColumn>
<TableColumn>Číslo odeslané</TableColumn>
<TableColumn>RST přijaté</TableColumn>
<TableColumn>Číslo přijaté</TableColumn>
<TableColumn>WWL</TableColumn>
<TableColumn>Exchange</TableColumn>
<TableColumn>Body</TableColumn>
<TableColumn>Penalizace</TableColumn>
<TableColumn>Nové WWL</TableColumn>
<TableColumn>Nové DXCC</TableColumn>
<TableColumn>Dupl.</TableColumn>
<TableColumn>Chyba</TableColumn>
<TableColumn>Strana</TableColumn>
<TableColumn>Match</TableColumn>
<TableColumn>Zásah</TableColumn>
<TableColumn>Poznámka</TableColumn>
</TableHeader>
<TableBody items={qsos} className="text-[11px] leading-tight">
{(qso) => {
const override =
qsoOverrides[qso.id] ??
qsoOverrides[String(qso.id) as unknown as number];
const matchResult = officialQsoResults[qso.id];
const matchConfidence = matchResult?.match_confidence ?? "—";
const matchTooltipLines: string[] = [];
if (matchResult?.match_type) {
matchTooltipLines.push(`Type: ${matchResult.match_type}`);
}
if (Array.isArray(matchResult?.error_flags) && matchResult.error_flags.length > 0) {
matchTooltipLines.push(`Flags: ${matchResult.error_flags.join(", ")}`);
}
const cellStyle = override ? { backgroundColor: "#FEF3C7" } : undefined;
const note = override
? override.reason ?? "—"
: qso.remarks || "—";
const overrideLabel = getOverrideLabel(override);
return (
<TableRow key={qso.id}>
<TableCell style={cellStyle}>{qso.qso_index ?? "—"}</TableCell>
<TableCell style={cellStyle}>
{formatDateTime(qso.time_on ?? null, locale)}
</TableCell>
<TableCell style={cellStyle}>{qso.dx_call || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.mode_code || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.my_rst || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.my_serial || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.dx_rst || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.dx_serial || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.rx_wwl || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.rx_exchange || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.points ?? "—"}</TableCell>
<TableCell style={cellStyle}>
{typeof officialQsoResults[qso.id]?.penalty_points === "number"
? officialQsoResults[qso.id]?.penalty_points
: "—"}
</TableCell>
<TableCell style={cellStyle}>{qso.new_wwl ? "N" : "—"}</TableCell>
<TableCell style={cellStyle}>{qso.new_dxcc ? "N" : "—"}</TableCell>
<TableCell style={cellStyle}>{qso.duplicate_qso ? "D" : "—"}</TableCell>
<TableCell style={cellStyle}>
{officialQsoResults[qso.id]?.error_code ?? "—"}
</TableCell>
<TableCell style={cellStyle}>
{officialQsoResults[qso.id]?.error_side &&
officialQsoResults[qso.id]?.error_side !== "NONE"
? officialQsoResults[qso.id]?.error_side
: "—"}
</TableCell>
<TableCell style={cellStyle}>
{matchConfidence === "PARTIAL" && matchTooltipLines.length > 0 ? (
<Tooltip
content={
<div className="text-xs leading-snug">
{matchTooltipLines.map((line, index) => (
<div key={index}>{line}</div>
))}
</div>
}
>
<span className="cursor-help">{matchConfidence}</span>
</Tooltip>
) : (
matchConfidence
)}
</TableCell>
<TableCell style={cellStyle}>{overrideLabel}</TableCell>
<TableCell style={cellStyle}>{note}</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</div>
</div>
);
}