Files
OpsLog/frontend/src/components/AwardsPanel.tsx
T
rouggy cb430a22ee feat: Ultrabeam over USB, Paper QSL from the awards grid, per-role schemas
Ultrabeam on a serial port never worked, and three faults were stacked so
each hid the next:

  - Stop() did not wait for the poll loop, so a stopped client kept the COM
    port. Every later client then failed with "Serial port busy" — the
    program holding it being OpsLog itself.
  - startUltrabeam tore the old client down CONCURRENTLY with starting the
    new one, and "Test connection" built a second client on a port already
    ours. Harmless over TCP, fatal on a port with one owner.
  - A silent serial port returns (0, nil) and bufio retries that a hundred
    times: a 4 s timeout became ~7 minutes of a frozen poll loop logging
    nothing.

The controller then answered at once. Confirmed on hardware: the USB cable
presents TWO COM ports, only the second reaches the controller, and only at
19200 baud — so the speed is pinned in code (an FTDI cable opens at any
speed, and a wrong one is indistinguishable from a dead controller) and the
port field says which one to pick. The first exchange after each connect is
hex-dumped, which separates silence from a wrong baud from a misread frame.

Databases now carry only the tables their role needs. Every target used to
get the whole migration set, so a shared MySQL logbook grew settings and
station_profiles tables nothing ever wrote to — an operator inspecting the
server could not tell which copy was authoritative. Statements are filtered
by role, unknown tables are kept in both (fail-safe), and existing databases
are cleaned once, dropping only EMPTY tables. Settings → Database gains a
Compact button, since SQLite frees pages inside the file and never shrinks it.

Also:
  - Awards: the callsigns behind a cell open the QSL Manager on Paper QSL,
    searched, ready for the card dates.
  - The record button no longer goes missing after an update: whether manual
    recording is possible is a per-profile question that was asked once, at
    startup, before the profile was known.
  - Alert rules and filter presets confirm that they were saved.
  - Spot clicks on the radio panadapter carry the POTA park into F3.
  - The build gate is re-checked wherever the active callsign can change; it
    ran at startup alone, and a fresh install has no callsign then.
2026-08-22 14:07:00 +02:00

909 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, GetTrackedAwards } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
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 { isQSLConfirmed } from '@/lib/qsl';
import { AwardEditor } from '@/components/AwardEditor';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
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[];
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
modes?: string[]; confirmed_modes?: 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<CellStatus, string> = {
validated: 'bg-success text-success-foreground',
confirmed: 'bg-warning text-warning-foreground',
worked: 'bg-muted-foreground text-background',
none: '',
};
const CELL_LABEL: Record<CellStatus, string> = { 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 (
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
<div className="bg-success" style={{ width: `${pct(confirmed, total)}%` }} />
<div className="bg-warning/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
</div>
);
}
// scoped: the award is limited to one or more DXCC entities. Missing-reference
// detection only means anything for those — see the Missing refs button.
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[]; scoped?: boolean };
export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
onEditQSO?: (id: number) => void;
onAwardsChanged?: () => void;
onPaperQSL?: (call: string) => void;
} = {}) {
const { t } = useI18n();
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
// are different results, and caching them under one key showed the previous
// modes numbers after switching.
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [selected, setSelected] = useState<string>('');
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');
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
// confirmed" is two questions at once, and answering only one of them is what
// sends an operator to a spreadsheet.
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
const [showMissing, setShowMissing] = useState(false);
const [stats, setStats] = useState<AwardStats | null>(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;
const key = `${code}|${modeFilter}`;
if (!force && byCode[key]) { setSelected(code); return; }
setSelected(code);
setLoading(true);
setErr('');
try {
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
setByCode((m) => ({ ...m, [key]: 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. The
// list is narrowed to the awards the operator follows (Settings → Awards); an
// empty follow-set means "show them all" so the tab is never blank.
async function loadList() {
try {
const [defs, tracked] = await Promise.all([
GetAwardDefs().then((d) => (d ?? []) as any[]),
GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
]);
const follow = new Set(tracked);
let list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [], scoped: (d.dxcc_filter ?? []).length > 0 }))
.sort((a, b) => a.code.localeCompare(b.code));
if (follow.size > 0) list = list.filter((a) => follow.has(a.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(); }, []);
// Re-filter when the operator changes their followed awards in Settings.
useEffect(() => {
const off = EventsOn('awards:tracked-changed', () => { loadList(); });
return () => { off(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const current = byCode[`${selected}|${modeFilter}`];
// Recompute when the mode class changes: the bands, counts and confirmations
// all describe the selected mode, so they cannot be filtered client-side.
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
// 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 160m6m 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]);
// Sort order for the reference table. Reference is the default because that
// is how award lists are published and how an operator reads a paper list;
// description answers the other question — "have I worked anything in
// Alicante" — which reference order scatters across the whole table.
// Remembered through writeUiPref rather than localStorage, so the choice
// travels with a copied data/ folder like every other UI preference here.
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
() => {
const v = localStorage.getItem('opslog.awardRefSort');
return v === 'name' || v === 'group' ? v : 'ref';
});
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
if (k === refSort) {
const d = refSortDir === 'asc' ? 'desc' : 'asc';
setRefSortDir(d);
writeUiPref('opslog.awardRefSortDir', d);
} else {
setRefSort(k);
setRefSortDir('asc');
writeUiPref('opslog.awardRefSort', k);
writeUiPref('opslog.awardRefSortDir', 'asc');
}
};
const filteredRefs = useMemo(() => {
if (!current) return [];
const q = refSearch.trim().toUpperCase();
const rows = (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 (modeFilter !== 'all' && refFilter !== 'notworked') {
// A reference never worked has no mode, so "not worked" plus a mode is
// a contradiction: the mode filter stands aside rather than emptying
// the list.
if (!(r.modes ?? []).includes(modeFilter)) return false;
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
// on CW and confirmed on SSB is still a CW entity to chase, and the
// whole point of the filter is to find those.
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
}
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
return true;
});
const dir = refSortDir === 'asc' ? 1 : -1;
return rows.sort((a, b) => {
if (refSort === 'name') {
// localeCompare, not a byte comparison: these lists are Spanish, French
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
// than after "Zaragoza".
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
}
if (refSort === 'group') {
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
// entities. Blank prefixes sink to the bottom regardless of direction,
// so an unresolved entity never heads the list.
const ag = a.group ?? '', bg = b.group ?? '';
if (!ag !== !bg) return ag ? -1 : 1;
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
}
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
});
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
// The group column earns its width only when the list actually carries one
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
// value IS the primary prefix, so it is labelled as such.
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
return (
<div className="flex h-full min-h-0">
{/* Award list */}
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<AwardIcon className="size-4 text-primary" />
<span className="text-sm font-semibold">{t('awp.awards')}</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title={t('awp.editAwards')}>
<Pencil className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
title={t('awp.rescanTitle')}>
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
{t('awp.rescan')}
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
{/* Quick selector — scales when there are many awards. */}
{awardList.length > 0 && (
<div className="px-2 py-2 border-b border-border/40">
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder={t('awp.selectAward')} /></SelectTrigger>
<SelectContent className="max-h-80">
{awardList.map((a) => (
<SelectItem key={a.code} value={a.code} className="text-xs">
<span className="font-semibold">{a.code}</span>
<span className="text-muted-foreground"> {a.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
{awardList.map((a) => {
const r = byCode[`${a.code}|${modeFilter}`];
return (
<button
key={a.code}
onClick={() => { setRefSearch(''); compute(a.code); }}
className={cn(
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
selected === a.code && 'bg-accent/60',
a.valid === false && 'opacity-50',
)}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-semibold text-sm">{a.code}</span>
{r ? (
<span className="text-[11px] font-mono text-muted-foreground">
<span className="text-success">{r.confirmed}</span>
/<span className="text-foreground">{r.worked}</span>
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
</span>
) : (
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
)}
</div>
<div className="text-[11px] text-muted-foreground truncate mb-1">{a.name}</div>
{r && <ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />}
</button>
);
})}
</div>
</div>
{/* Detail */}
<div className="flex-1 flex flex-col min-h-0">
{!current ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
{loading ? t('awp.computing') : t('awp.noData')}
</div>
) : (
<>
<div className="px-4 py-3 border-b border-border/60">
<div className="flex items-baseline gap-2">
<h3 className="text-lg font-bold">{current.code}</h3>
<span className="text-sm text-muted-foreground">{current.name}</span>
</div>
<div className="mt-1 flex items-center gap-4 text-sm">
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
<span><span className="font-bold text-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
<span><span className="font-bold text-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
{current.total > 0 && (
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
)}
</div>
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
</div>
{/* Band breakdown */}
{(current.bands ?? []).length > 0 && (
<div className="px-4 py-2 border-b border-border/60">
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">{t('awp.byBand')}</div>
<div className="flex flex-wrap gap-1.5">
{(current.bands ?? []).map((b) => (
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
<span className="font-mono font-semibold">{b.band}</span>{' '}
<span className="text-success font-mono">{b.confirmed}</span>
<span className="text-muted-foreground font-mono">/{b.worked}</span>
</div>
))}
</div>
</div>
)}
{/* References toolbar */}
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setModeFilter(k)}
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
{/* Only for an award scoped to a DXCC entity. "In this award's
scope but with no reference" needs a scope to be in: on a
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
contact anywhere would qualify, so the answer is always none.
The button used to be there regardless and could only ever
open a window saying it had found nothing. */}
{awardList.find((a) => a.code === selected)?.scoped && (
<button
onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
title={t('awp.missingRefsTitle')}
>
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
</button>
)}
<div className="flex-1" />
{/* Legend */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-muted-foreground" />W</span>
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-warning" />C</span>
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-success" />V</span>
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden">
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.listView')} onClick={() => setView('list')}><List className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.statistics')} onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
</div>
</div>
{/* Statistics matrix: status × band, by mode category */}
{view === 'stats' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
{statsLoading || !stats ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.computing')}</div>
) : (
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
</tr>
</thead>
<tbody>
{statsRows.map((row, i) => {
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
return (
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-success/15 text-success' : 'text-muted-foreground/30')}>{c || ''}</td>
); })}
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
) : view === 'grid' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.ref')}
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
{hasGroup && (
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
{groupColLabel}
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
)}
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.description')}
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
{gridBands.map((b) => (
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
))}
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
{hasGroup && (
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
)}
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
{gridBands.map((b) => {
const s = cellStatus(r, b);
return (
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-11 h-7" /> : (
<button
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={t('awp.cellTitle', { ref: r.ref, band: b })}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 pr-2 font-medium w-24">
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.ref')}
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium">
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.name')}
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-40">
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
{groupColLabel}
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
<td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
: r.validated ? <span className="text-success">{t('awp.validated')}</span>
: r.confirmed ? <span className="text-warning">{t('awp.confirmed')}</span>
: <span className="text-muted-foreground">{t('awp.worked')}</span>}
</td>
<td className="py-1 font-mono text-muted-foreground">
{r.bands.map((b) => (
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-success font-semibold' : r.confirmed_bands.includes(b) && 'text-warning font-semibold')}>{b}</span>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
{cell && current && (
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} onPaperQSL={onPaperQSL} />
)}
{showMissing && current && (
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
)}
</div>
);
}
// 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';
// How many references the assign dropdown will put in the DOM at once. Above
// this a search box appears beside it and the rest are held back, counted.
const REF_MENU_MAX = 300;
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [sel, setSel] = useState<Set<number>>(new Set());
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
const [refSearch, setRefSearch] = 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) — fetched ONLY once there is something to assign it to.
//
// Missing-reference detection needs a DXCC scope, so a worldwide award like
// POTA always has zero rows here and says so. It was still loading every
// reference behind that message: on a log with the full POTA park list
// imported, tens of thousands of them, feeding a dropdown that could not be
// used for anything. That is the window freezing on a screen with nothing to
// offer.
useEffect(() => {
if (loading || qsos.length === 0) { setRefs([]); return; }
ListAwardReferences(code)
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
.catch(() => setRefs([]));
}, [code, loading, qsos.length]);
// What the assign dropdown actually renders. Bounded, and it says how many it
// is holding back rather than silently showing the first few hundred.
const shownRefs = useMemo(() => {
const q = refSearch.trim().toUpperCase();
const rows = q
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q))
: refs;
return rows.slice(0, REF_MENU_MAX);
}, [refs, refSearch]);
const hiddenRefs = useMemo(() => {
const q = refSearch.trim().toUpperCase();
const total = q
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q)).length
: refs.length;
return Math.max(0, total - shownRefs.length);
}, [refs, refSearch, shownRefs.length]);
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 }) => (
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
<span className="inline-flex items-center gap-0.5">{label}
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</span>
</th>
);
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-warning" />
<span className="font-semibold text-sm">{code} {t('awp.contactsMissingRef')}</span>
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
<div className="flex-1" />
<button onClick={load} disabled={loading}
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
title={t('awp.recomputeTitle')}>
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
</button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
</div>
{/* Bulk-assign toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
{/* A search box beside the dropdown, and a bounded list inside it.
A reference list can hold tens of thousands of entries (POTA
parks, Russian districts); every one of them as a menu item is
hundreds of thousands of DOM nodes and a window that stops
answering. Nobody scrolls to K-4521 anyway — they type it. */}
{refs.length > REF_MENU_MAX && (
<Input className="h-7 w-40 text-xs font-mono" value={refSearch}
placeholder={t('awp.filterReferences')}
onChange={(e) => setRefSearch(e.target.value)} />
)}
<Select value={assignRef} onValueChange={setAssignRef}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
<SelectContent className="max-h-72">
{shownRefs.map((r) => (
<SelectItem key={r.code} value={r.code}>
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
</SelectItem>
))}
{hiddenRefs > 0 && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{t('awp.refsNarrow', { n: hiddenRefs })}
</div>
)}
</SelectContent>
</Select>
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
</Button>
{msg && <span className="text-[11px] text-success">{msg}</span>}
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.scanning')}</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">
{t('awp.noGaps')}
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
<tr>
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<SortTh k="qso_date" label={t('awp.dateUtc')} className="pl-0" />
<SortTh k="callsign" label={t('awp.callsign')} />
<SortTh k="band" label={t('awp.band')} />
<SortTh k="mode" label={t('awp.mode')} />
<SortTh k="country" label={t('awp.country')} />
<SortTh k="qth" label={t('awp.qthNote')} />
</tr>
</thead>
<tbody>
{sorted.map((q, i) => {
const id = q.id as number;
return (
<tr key={id ?? i}
className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
<td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
</td>
<td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
<td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
<td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
{qsos.length} {t('awp.contactsWithoutRef')}
</div>
</div>
</div>
);
}
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, modeClass, onClose, onPaperQSL }: {
code: string; cell: { ref: string; band: string; name?: string }; modeClass: string;
onClose: () => void;
// Send this callsign to the QSL Manager's Paper QSL view. Chasing a
// confirmation starts here — an unconfirmed entity, the contact behind it —
// and used to continue by retyping the callsign into another tab.
onPaperQSL?: (call: string) => void;
}) {
const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
}, [code, cell.ref, cell.band, modeClass]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<span className="font-semibold text-sm">{code} · <span className="font-mono">{cell.ref}</span> · {cell.band}</span>
{cell.name && <span className="text-xs text-muted-foreground truncate">{cell.name}</span>}
<div className="flex-1" />
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.loading')}</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">{t('awp.noQsos')}</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr><th className="py-1 px-3 font-medium">{t('awp.dateUtc')}</th><th className="py-1 pr-2 font-medium">{t('awp.callsign')}</th><th className="py-1 pr-2 font-medium">{t('awp.band')}</th><th className="py-1 pr-2 font-medium">{t('awp.mode')}</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
</thead>
<tbody>
{qsos.map((q, i) => (
<tr key={q.id ?? i} className="border-b border-border/30">
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
<td className="py-1 pr-2 font-mono font-semibold">
{onPaperQSL ? (
<button type="button" className="hover:underline text-primary"
title={t('awp.paperQslTip')}
onClick={() => { onPaperQSL(String(q.callsign ?? '')); onClose(); }}>
{q.callsign}
</button>
) : q.callsign}
</td>
<td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-3 text-muted-foreground">{[isQSLConfirmed(q.lotw_rcvd) && 'LoTW', isQSLConfirmed(q.qsl_rcvd) && 'QSL', isQSLConfirmed(q.eqsl_rcvd) && 'eQSL'].filter(Boolean).join(', ')}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">{qsos.length} QSO{qsos.length > 1 ? 's' : ''}</div>
</div>
</div>
);
}