import { useEffect, useMemo, useState } from 'react'; import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react'; import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { AwardEditor } from '@/components/AwardEditor'; type BandCount = { band: string; worked: number; confirmed: number }; type AwardRef = { ref: string; name?: string; group?: string; subgrp?: string; worked: boolean; confirmed: boolean; validated: boolean; bands: string[]; confirmed_bands: string[]; validated_bands: string[]; }; type AwardResult = { code: string; name: string; dimension: string; worked: number; confirmed: number; validated: number; total: number; bands: BandCount[]; refs: AwardRef[]; }; type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number }; type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] }; // Fixed band columns for the matrix view (Log4OM-style). const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm']; // Per-band status for a reference, highest first. type CellStatus = 'validated' | 'confirmed' | 'worked' | 'none'; function cellStatus(r: AwardRef, band: string): CellStatus { if (r.validated_bands?.includes(band)) return 'validated'; if (r.confirmed_bands?.includes(band)) return 'confirmed'; if (r.bands?.includes(band)) return 'worked'; return 'none'; } const CELL_STYLE: Record = { validated: 'bg-emerald-500 text-white', confirmed: 'bg-amber-400 text-amber-950', worked: 'bg-stone-400 text-white', none: '', }; const CELL_LABEL: Record = { validated: 'V', confirmed: 'C', worked: 'W', none: '' }; function pct(n: number, total: number): number { if (total <= 0) return 0; return Math.min(100, Math.round((n / total) * 100)); } // Two-segment progress: confirmed (solid green) over worked (light amber). function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed: number; total: number }) { if (total <= 0) return null; return (
); } type AwardListItem = { code: string; name: string; valid?: boolean }; export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) { const [awardList, setAwardList] = useState([]); // Computed results are cached per award code — each award is scanned only the // first time it's selected (or when explicitly rescanned). const [byCode, setByCode] = useState>({}); const [loading, setLoading] = useState(false); const [err, setErr] = useState(''); const [selected, setSelected] = useState(''); const [refSearch, setRefSearch] = useState(''); const [editing, setEditing] = useState(false); const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid'); const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all'); const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null); const [showMissing, setShowMissing] = useState(false); const [stats, setStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); // Lazily fetch the statistics matrix when the Stats view is shown. useEffect(() => { if (view !== 'stats' || !selected) return; setStatsLoading(true); GetAwardStats(selected) .then((s) => setStats(s as any)) .catch(() => setStats(null)) .finally(() => setStatsLoading(false)); }, [view, selected]); // Compute one award (cached). force=true bypasses the cache (Rescan). async function compute(code: string, force = false) { if (!code) return; if (!force && byCode[code]) { setSelected(code); return; } setSelected(code); setLoading(true); setErr(''); try { const r = (await GetAward(code)) as any as AwardResult; setByCode((m) => ({ ...m, [code]: r })); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setLoading(false); } } // Load the award list (no QSO scan), then compute only the first award. async function loadList() { try { const defs = ((await GetAwardDefs()) ?? []) as any[]; const list: AwardListItem[] = defs .map((d) => ({ code: d.code, name: d.name, valid: d.valid })) .sort((a, b) => a.code.localeCompare(b.code)); setAwardList(list); const first = list.find((a) => a.code === selected) ?? list[0]; if (first) compute(first.code); } catch (e: any) { setErr(String(e?.message ?? e)); } } useEffect(() => { loadList(); }, []); const current = byCode[selected]; const filteredRefs = useMemo(() => { if (!current) return []; const q = refSearch.trim().toUpperCase(); return (current.refs ?? []).filter((r) => { if (refFilter === 'worked' && !r.worked) return false; if (refFilter === 'notworked' && r.worked) return false; if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false; if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false; return true; }); }, [current, refSearch, refFilter]); return (
{/* Award list */}
Awards
setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} /> {/* Quick selector — scales when there are many awards. */} {awardList.length > 0 && (
)}
{err &&
{err}
} {awardList.map((a) => { const r = byCode[a.code]; return ( ); })}
{/* Detail */}
{!current ? (
{loading ? 'Computing…' : 'No data'}
) : ( <>

{current.code}

{current.name}
{current.worked} worked {current.confirmed} confirmed {current.validated} validated {current.total > 0 && ( of {current.total} · {pct(current.confirmed, current.total)}% confirmed )}
{/* Band breakdown */} {(current.bands ?? []).length > 0 && (
By band (confirmed / worked)
{(current.bands ?? []).map((b) => (
{b.band}{' '} {b.confirmed} /{b.worked}
))}
)} {/* References toolbar */}
setRefSearch(e.target.value)} />
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => ( ))}
{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}
{/* Legend */}
W C V
{/* Statistics matrix: status × band, by mode category */} {view === 'stats' ? (
{statsLoading || !stats ? (
Computing…
) : ( {stats.bands.map((b) => )} {stats.rows.map((row, i) => { const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label); return ( 0 && 'border-t-2 border-border')}> {row.cells.map((c, j) => ( ))} ); })}
Statistic{b}Total Grand
{row.label} 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}{row.total || ''} {row.grand_total || ''}
)}
) : view === 'grid' ? (
{GRID_BANDS.map((b) => ( ))} {filteredRefs.map((r) => ( {GRID_BANDS.map((b) => { const s = cellStatus(r, b); return ( ); })} ))}
Ref Description{b}
{r.ref} {r.name}{r.group ? · {r.group} : ''} {s === 'none' ? : ( )}
) : (
{filteredRefs.map((r) => ( ))}
Ref Name Group Status Bands
{r.ref} {r.name} {r.group} {!r.worked ? — missing : r.validated ? validated : r.confirmed ? confirmed : worked} {r.bands.map((b) => ( {b} ))}
)} )}
{cell && current && ( setCell(null)} /> )} {showMissing && current && ( setShowMissing(false)} onEditQSO={onEditQSO} /> )}
); } // MissingQSOModal lists contacts within an award's scope that carry NO // reference — the silent gaps. Rows open the QSO editor so the operator can add // the missing reference (e.g. a department for DDFM). function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) { const [qsos, setQsos] = useState([]); const [loading, setLoading] = useState(true); const load = () => { setLoading(true); AwardMissingQSOs(code) .then((r) => setQsos((r ?? []) as any)) .catch(() => setQsos([])) .finally(() => setLoading(false)); }; useEffect(() => { load(); }, [code]); // Distinct stations vs total contacts (a station may appear on several QSOs). const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]); const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); }; return (
e.stopPropagation()}>
{code} — contacts missing a reference {name && {name}}
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. {onEditQSO && ' Click a row to open the QSO and add the reference.'}
{loading ? (
Scanning…
) : qsos.length === 0 ? (
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)
) : ( {qsos.map((q, i) => ( onEditQSO && q.id && onEditQSO(q.id as number)}> ))}
Date (UTC)CallsignBandModeCountryQTH / Note
{fmt(q.qso_date)} {q.callsign} {q.band} {q.mode} {q.country} {q.qth || q.notes}
)}
{stations} station{stations > 1 ? 's' : ''} ·{' '} {qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
); } // CellQSOModal lists the QSOs behind one award-grid cell (reference × band). function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) { const [qsos, setQsos] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(true); AwardCellQSOs(code, cell.ref, cell.band) .then((r) => setQsos((r ?? []) as any)) .catch(() => setQsos([])) .finally(() => setLoading(false)); }, [code, cell.ref, cell.band]); const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); }; return (
e.stopPropagation()}>
{code} · {cell.ref} · {cell.band} {cell.name && {cell.name}}
{loading ? (
Loading…
) : qsos.length === 0 ? (
No QSOs.
) : ( {qsos.map((q, i) => ( ))}
Date (UTC)CallsignBandModeQSL
{fmt(q.qso_date)} {q.callsign} {q.band} {q.mode} {[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}
)}
{qsos.length} QSO{qsos.length > 1 ? 's' : ''}
); }