Files
OpsLog/frontend/src/components/AwardRefSelector.tsx
T
2026-06-06 14:16:30 +02:00

273 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { X, Plus, Loader2 } from 'lucide-react';
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../../wailsjs/go/main/App';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
type Meta = { code: string; count: number; can_update: boolean };
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
// are computed, never manually picked, so they don't belong in this picker.
// Fields purely derived from the callsign / cty.dat — their awards are computed,
// never picked. NB: 'state' and 'cnty' are NOT here — they're operator-settable
// QSO fields driving predefined-list awards (WAS/RAC/WAJA/JCC), so they ARE
// pickable (a lookup rarely fills the JA prefecture or VE province).
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid']);
// If DXCC-filtered auto-results exceed this, require the user to type instead.
const AUTO_SHOW_MAX = 100;
interface Props {
dxcc?: number;
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
value: string;
onChange: (v: string) => void;
}
export function AwardRefSelector({ dxcc, value, onChange }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]);
const [metas, setMetas] = useState<Record<string, Meta>>({});
const [awardCode, setAwardCode] = useState('POTA');
const [q, setQ] = useState('');
// autoResults: loaded immediately when award/dxcc changes (empty query, DXCC-filtered).
// Shown when q is short and count ≤ AUTO_SHOW_MAX (e.g. 5 IOTA refs for France).
const [autoResults, setAutoResults] = useState<AwardRef[]>([]);
// searchResults: loaded when user types 2+ chars.
const [searchResults, setSearchResults] = useState<AwardRef[]>([]);
const [busy, setBusy] = useState(false);
const [selectedRef, setSelectedRef] = useState<AwardRef | null>(null);
const [selectedEntry, setSelectedEntry] = useState<string | null>(null);
const entries = value ? value.split(';').filter(Boolean) : [];
useEffect(() => {
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
.then(([d, m]) => {
setDefs((d ?? []) as any);
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
})
.catch(() => {});
}, []);
// An award is offered when its DXCC scope matches the contacted entity (or it
// has no scope) AND it has references to pick from (a loaded list, an online
// list, or dynamic references like POTA). This is why DDFM (scope 227) shows
// for a French call but not for others.
const awards = useMemo(() => {
return defs.filter((d) => {
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
const m = metas[String(d.code).toUpperCase()];
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
if (!hasRefs) return false;
const scope = d.dxcc_filter ?? [];
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
return true;
}).map((d) => ({ code: d.code, name: d.name }));
}, [defs, metas, dxcc]);
// Keep the selected award valid as the offered list changes with the call.
useEffect(() => {
if (awards.length === 0) return;
if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code);
}, [awards, awardCode]);
// Is the selected award a giant dynamic list (POTA/SOTA/IOTA)? Those carry a
// per-reference DXCC so we filter by entity; predefined lists (WAS/RAC/WAJA)
// are small and their refs may lack a per-ref DXCC, so we load them whole.
const isDynamic = useMemo(
() => !!defs.find((d) => d.code === awardCode)?.dynamic,
[defs, awardCode],
);
// For dynamic lists, restrict to the contacted entity; otherwise load all.
const refDxcc = isDynamic ? (dxcc ?? 0) : 0;
// Auto-load refs on award/dxcc change with empty query. Fetches AUTO_SHOW_MAX+1
// so we can distinguish "all results shown" from "too many to list".
useEffect(() => {
setAutoResults([]);
// Dynamic lists need an entity to scope to; predefined lists load regardless.
if (isDynamic && !dxcc) return;
SearchAwardReferences(awardCode, '', refDxcc, AUTO_SHOW_MAX + 1)
.then((r) => setAutoResults((r ?? []) as any))
.catch(() => {});
}, [awardCode, dxcc, isDynamic, refDxcc]);
// Typed search (2+ chars).
useEffect(() => {
if (q.length < 2) { setSearchResults([]); return; }
const t = window.setTimeout(async () => {
setBusy(true);
try {
const r = await SearchAwardReferences(awardCode, q, refDxcc, 50);
setSearchResults((r ?? []) as any);
} catch { setSearchResults([]); }
finally { setBusy(false); }
}, 200);
return () => window.clearTimeout(t);
}, [awardCode, q, refDxcc]);
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
// When q is empty/short: show auto-results (if ≤ AUTO_SHOW_MAX); when typing: show search results.
const results: AwardRef[] = q.length >= 2 ? searchResults : (tooManyAuto ? [] : autoResults);
function addRef(ref: AwardRef) {
const entry = `${awardCode}@${ref.code}`;
if (!entries.includes(entry)) {
onChange([...entries, entry].join(';'));
}
}
function removeEntry(entry: string) {
const next = entries.filter((e) => e !== entry).join(';');
onChange(next);
if (selectedEntry === entry) setSelectedEntry(null);
}
return (
<div className="flex gap-2 h-[210px]">
{/* Left panel */}
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
<Select
value={awardCode}
onValueChange={(v) => { setAwardCode(v); setSelectedRef(null); setQ(''); setSearchResults([]); }}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{awards.map((a) => (
<SelectItem key={a.code} value={a.code}>{a.code}</SelectItem>
))}
</SelectContent>
</Select>
{/* Group / Sub from selected ref */}
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
<span className="text-muted-foreground text-[11px]">Group</span>
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
<span className="text-muted-foreground text-[11px]">Sub</span>
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
</div>
{/* Selected ref chip */}
{selectedRef ? (
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0">
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span>
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
<X className="size-3" />
</button>
</div>
) : (
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
pick a reference
</div>
)}
{/* Add — references are always scoped to the contacted DXCC */}
<div className="flex items-center gap-2">
<button
disabled={!selectedRef}
onClick={() => selectedRef && addRef(selectedRef)}
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="size-3" />Add
</button>
<span className="text-[11px] text-muted-foreground">
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
</span>
</div>
<div className="h-px bg-border shrink-0" />
{/* Added refs list */}
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
{entries.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic py-0.5">No references added yet</p>
) : (
entries.map((entry) => (
<div
key={entry}
className={`flex items-center gap-1.5 px-2 py-0.5 rounded text-xs cursor-pointer select-none ${
selectedEntry === entry ? 'bg-accent' : 'hover:bg-accent/50'
}`}
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
>
<span className="font-mono font-semibold flex-1 truncate">{entry}</span>
<button
className="shrink-0 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
>
<X className="size-3" />
</button>
</div>
))
)}
</div>
</div>
{/* Right panel: reference search */}
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
<span className="text-xs font-semibold">References</span>
<input
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="Search…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
{busy && (
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />Searching
</div>
)}
{/* Too many auto-results → require typed search */}
{!busy && q.length < 2 && tooManyAuto && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
Type 2+ chars to search
</div>
)}
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
note the list is empty (predefined awards with no references). */}
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
{isDynamic && !dxcc
? 'Enter a callsign, or type to search.'
: 'No references for this entity.'}
</div>
)}
{/* Typed search, no results */}
{!busy && q.length >= 2 && results.length === 0 && (
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
No results.
<br />
<span className="text-[10px]">Download reference lists in the Awards panel Import data.</span>
</div>
)}
{results.map((r) => (
<div
key={r.code}
className={`px-2 py-1 cursor-pointer border-b border-border/30 last:border-0 ${
selectedRef?.code === r.code ? 'bg-accent' : 'hover:bg-accent/50'
}`}
onClick={() => setSelectedRef(r)}
onDoubleClick={() => { setSelectedRef(r); addRef(r); }}
>
<div className="font-mono font-semibold leading-tight text-[11px]">{r.code}</div>
{r.name && (
<div className="text-[10px] text-muted-foreground leading-tight truncate">{r.name}</div>
)}
</div>
))}
</div>
</div>
</div>
);
}