696 lines
37 KiB
TypeScript
696 lines
37 KiB
TypeScript
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';
|
||
|
||
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<CellStatus, string> = {
|
||
validated: 'bg-emerald-500 text-white',
|
||
confirmed: 'bg-amber-400 text-amber-950',
|
||
worked: 'bg-stone-400 text-white',
|
||
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-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} />
|
||
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||
|
||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||
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).
|
||
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');
|
||
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;
|
||
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 (
|
||
<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">Awards</span>
|
||
<div className="flex-1" />
|
||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||
<Pencil className="size-3.5" />
|
||
</Button>
|
||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
|
||
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||
Rescan
|
||
</Button>
|
||
</div>
|
||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
||
{/* 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="Select an award…" /></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];
|
||
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-emerald-600">{r.confirmed}</span>
|
||
/<span className="text-foreground">{r.worked}</span>
|
||
{r.total > 0 && <span className="text-muted-foreground/70"> 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 ? 'Computing…' : 'No data'}
|
||
</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">worked</span></span>
|
||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
|
||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
|
||
{current.total > 0 && (
|
||
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</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">By band (confirmed / worked)</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-emerald-600 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="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||
</div>
|
||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] 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>
|
||
<span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||
<button
|
||
onClick={() => setShowMissing(true)}
|
||
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
||
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
||
>
|
||
<AlertTriangle className="size-3" /> Missing refs
|
||
</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-stone-400" />W</span>
|
||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
|
||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />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="Grid view" 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="List view" 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="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" /> 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">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">Total</th>
|
||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">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-emerald-500/15 text-emerald-700' : '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">Ref</th>
|
||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</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>
|
||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||
</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={`${r.ref} · ${b} — click to view QSOs`}
|
||
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">Ref</th>
|
||
<th className="py-1.5 pr-2 font-medium">Name</th>
|
||
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
|
||
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
||
<th className="py-1.5 font-medium">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">— missing</span>
|
||
: r.validated ? <span className="text-emerald-600">validated</span>
|
||
: r.confirmed ? <span className="text-amber-600">confirmed</span>
|
||
: <span className="text-stone-500">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-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
|
||
))}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{cell && current && (
|
||
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
||
)}
|
||
{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';
|
||
|
||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||
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 [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(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
||
} 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-amber-600" />
|
||
<span className="font-semibold text-sm">{code} — contacts missing a reference</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="Recompute now — contacts you've fixed drop off the list">
|
||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} 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">
|
||
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
||
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
||
</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">{sel.size} selected →</span>
|
||
<Select value={assignRef} onValueChange={setAssignRef}>
|
||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
|
||
<SelectContent className="max-h-72">
|
||
{refs.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>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
|
||
</Button>
|
||
{msg && <span className="text-[11px] text-emerald-700">{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" /> Scanning…</div>
|
||
) : qsos.length === 0 ? (
|
||
<div className="p-4 text-xs text-muted-foreground">
|
||
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)
|
||
</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="Date (UTC)" className="pl-0" />
|
||
<SortTh k="callsign" label="Callsign" />
|
||
<SortTh k="band" label="Band" />
|
||
<SortTh k="mode" label="Mode" />
|
||
<SortTh k="country" label="Country" />
|
||
<SortTh k="qth" label="QTH / Note" />
|
||
</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> station{stations > 1 ? 's' : ''} ·{' '}
|
||
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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<any[]>([]);
|
||
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 (
|
||
<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" /> Loading…</div>
|
||
) : qsos.length === 0 ? (
|
||
<div className="p-4 text-xs text-muted-foreground">No QSOs.</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">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">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">{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">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && '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>
|
||
);
|
||
}
|