import { useEffect, useMemo, useState } from 'react'; import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react'; import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { AwardEditor } from '@/components/AwardEditor'; import { useI18n } from '@/lib/i18n'; 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-success text-success-foreground', confirmed: 'bg-warning text-warning-foreground', worked: 'bg-muted-foreground text-background', 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; bands?: string[]; emission?: string[] }; export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) { const { t } = useI18n(); 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); // Bumped by Rescan to force the stats matrix to re-fetch (the selected award // didn't change, but the backend snapshot did). const [rescanTick, setRescanTick] = useState(0); // 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, rescanTick]); // Rescan: drop the backend snapshot (so confirmations from a fresh LoTW/QRZ // download are picked up) and the cached results, then recompute everything. async function rescan() { if (!selected) return; setLoading(true); try { await RescanAwards(); setByCode({}); setRescanTick((t) => t + 1); await compute(selected, true); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setLoading(false); } } // 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, bands: d.valid_bands ?? [], emission: d.emission ?? [] })) .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]; // Bands relevant to the selected award, used by BOTH the grid and the stats // matrix so neither is padded with bands the award doesn't use. Rule: the // award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else // the bands the operator actually has contacts on (so DXCC, which has no band // restriction, shows 160m–6m instead of empty VHF/UHF columns). Empty set = // no basis yet → callers fall back to all bands. const awardBands = useMemo(() => { const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase()); if (vb.length > 0) return new Set(vb); const worked = (current?.bands ?? []) .filter((b) => (b.worked ?? 0) > 0) .map((b) => String(b.band).toLowerCase()); return new Set(worked); }, [awardList, selected, current]); const gridBands = useMemo(() => { if (awardBands.size === 0) return GRID_BANDS; const filtered = GRID_BANDS.filter((b) => awardBands.has(b)); return filtered.length ? filtered : GRID_BANDS; }, [awardBands]); // Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the // award doesn't cover. The "ALL" rows (no suffix) always show; a category row // shows only when the award has no emission restriction or lists that emission. const statsRows = useMemo(() => { if (!stats) return []; const em = new Set((awardList.find((a) => a.code === selected)?.emission ?? []).map((e) => e.toUpperCase())); if (em.size === 0) return stats.rows; return stats.rows.filter((r) => { const m = String(r.label).toUpperCase().match(/\b(CW|DIGITAL|PHONE)$/); return !m || em.has(m[1]); }); }, [stats, awardList, selected]); // Indices into stats.bands to display (and to slice each row's cells by), same // band basis as the grid. const statsBandIdx = useMemo(() => { if (!stats) return [] as number[]; const all = stats.bands.map((_, i) => i); if (awardBands.size === 0) return all; const idx = stats.bands .map((b, i) => ({ b: b.toLowerCase(), i })) .filter((x) => awardBands.has(x.b)) .map((x) => x.i); return idx.length ? idx : all; }, [stats, awardBands]); 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 */}
{t('awp.awards')}
setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} /> {/* 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 ? t('awp.computing') : t('awp.noData')}
) : ( <>

{current.code}

{current.name}
{current.worked} {t('awp.worked')} {current.confirmed} {t('awp.confirmed')} {current.validated} {t('awp.validated')} {current.total > 0 && ( {t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })} )}
{/* Band breakdown */} {(current.bands ?? []).length > 0 && (
{t('awp.byBand')}
{(current.bands ?? []).map((b) => (
{b.band}{' '} {b.confirmed} /{b.worked}
))}
)} {/* References toolbar */}
setRefSearch(e.target.value)} />
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => ( ))}
{filteredRefs.length} {t('awp.refs')}
{/* Legend */}
W C V
{/* Statistics matrix: status × band, by mode category */} {view === 'stats' ? (
{statsLoading || !stats ? (
{t('awp.computing')}
) : ( {statsBandIdx.map((i) => )} {statsRows.map((row, i) => { const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label); return ( 0 && 'border-t-2 border-border')}> {statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return ( ); })} ); })}
{t('awp.statistic')}{stats.bands[i]}{t('awp.total')} {t('awp.grand')}
{row.label} 0 ? 'bg-success/15 text-success' : 'text-muted-foreground/30')}>{c || ''}{row.total || ''} {row.grand_total || ''}
)}
) : view === 'grid' ? (
{gridBands.map((b) => ( ))} {filteredRefs.map((r) => ( {gridBands.map((b) => { const s = cellStatus(r, b); return ( ); })} ))}
{t('awp.ref')} {t('awp.description')}{b}
{r.ref} {r.name}{r.group ? · {r.group} : ''} {s === 'none' ? : ( )}
) : (
{filteredRefs.map((r) => ( ))}
{t('awp.ref')} {t('awp.name')} {t('awp.groupCol')} {t('awp.status')} {t('awp.bands')}
{r.ref} {r.name} {r.group} {!r.worked ? {t('awp.missing')} : r.validated ? {t('awp.validated')} : r.confirmed ? {t('awp.confirmed')} : {t('awp.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). type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth'; function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) { const { t } = useI18n(); const [qsos, setQsos] = useState([]); const [loading, setLoading] = useState(true); const [sel, setSel] = useState>(new Set()); const [sortKey, setSortKey] = useState('callsign'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [refs, setRefs] = useState>([]); const [assignRef, setAssignRef] = useState(''); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); const load = () => { setLoading(true); setSel(new Set()); AwardMissingQSOs(code) .then((r) => setQsos((r ?? []) as any)) .catch(() => setQsos([])) .finally(() => setLoading(false)); }; useEffect(() => { load(); }, [code]); // The award's reference list drives the "assign" dropdown (e.g. China provinces). useEffect(() => { ListAwardReferences(code) .then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') })))) .catch(() => setRefs([])); }, [code]); const qthOf = (q: any) => String(q.qth || q.notes || ''); const sorted = useMemo(() => { const dir = sortDir === 'asc' ? 1 : -1; const val = (q: any): string => { switch (sortKey) { case 'qso_date': return String(q.qso_date ?? ''); case 'callsign': return String(q.callsign ?? '').toUpperCase(); case 'band': return String(q.band ?? ''); case 'mode': return String(q.mode ?? ''); case 'country': return String(q.country ?? '').toUpperCase(); case 'qth': return qthOf(q).toUpperCase(); } }; return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir); }, [qsos, sortKey, sortDir]); const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]); const allSelected = ids.length > 0 && ids.every((id) => sel.has(id)); const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids)); const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const setSort = (k: MissingSortKey) => { if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); else { setSortKey(k); setSortDir('asc'); } }; async function applyAssign() { if (!assignRef || sel.size === 0) return; setBusy(true); setMsg(''); try { const assignedIds = Array.from(sel); const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds); // Optimistic: the assigned contacts now carry a reference, so drop them // from the list locally instead of re-running the slow whole-log scan. const done = new Set(assignedIds); setQsos((list) => list.filter((q) => !done.has(q.id as number))); setSel(new Set()); setMsg(t('awp.assignedMsg', { code, ref: assignRef, n })); } catch (e: any) { setMsg(String(e?.message ?? e)); } finally { setBusy(false); } } 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', ' '); }; const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => ( setSort(k)}> {label} {sortKey === k && (sortDir === 'asc' ? : )} ); return (
e.stopPropagation()}>
{code} — {t('awp.contactsMissingRef')} {name && {name}}
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
{/* Bulk-assign toolbar */}
{t('awp.selectedArrow', { n: sel.size })} {msg && {msg}}
{loading ? (
{t('awp.scanning')}
) : qsos.length === 0 ? (
{t('awp.noGaps')}
) : ( {sorted.map((q, i) => { const id = q.id as number; return ( ); })}
e.stopPropagation()}> toggle(id)} /> onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)} onEditQSO && id && onEditQSO(id)}>{q.callsign} {q.band} {q.mode} {q.country} {qthOf(q)}
)}
{stations} {t('awp.stations')} ·{' '} {qsos.length} {t('awp.contactsWithoutRef')}
); } // 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 { t } = useI18n(); 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 ? (
{t('awp.loading')}
) : qsos.length === 0 ? (
{t('awp.noQsos')}
) : ( {qsos.map((q, i) => ( ))}
{t('awp.dateUtc')}{t('awp.callsign')}{t('awp.band')}{t('awp.mode')}QSL
{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' : ''}
); }