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([]); const [metas, setMetas] = useState>({}); 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([]); // searchResults: loaded when user types 2+ chars. const [searchResults, setSearchResults] = useState([]); const [busy, setBusy] = useState(false); const [selectedRef, setSelectedRef] = useState(null); const [selectedEntry, setSelectedEntry] = useState(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 (
{/* Left panel */}
{/* Group / Sub from selected ref */}
Group {selectedRef?.group || '—'} Sub {selectedRef?.subgrp || '—'}
{/* Selected ref chip */} {selectedRef ? (
{selectedRef.code} {selectedRef.name}
) : (
← pick a reference
)} {/* Add — references are always scoped to the contacted DXCC */}
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
{/* Added refs list */}
{entries.length === 0 ? (

No references added yet

) : ( entries.map((entry) => (
setSelectedEntry(selectedEntry === entry ? null : entry)} > {entry}
)) )}
{/* Right panel: reference search */}
References setQ(e.target.value)} />
{busy && (
Searching…
)} {/* Too many auto-results → require typed search */} {!busy && q.length < 2 && tooManyAuto && (
Type 2+ chars to search
)} {/* 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 && (
{isDynamic && !dxcc ? 'Enter a callsign, or type to search.' : 'No references for this entity.'}
)} {/* Typed search, no results */} {!busy && q.length >= 2 && results.length === 0 && (
No results.
Download reference lists in the Awards panel → Import data.
)} {results.map((r) => (
setSelectedRef(r)} onDoubleClick={() => { setSelectedRef(r); addRef(r); }} >
{r.code}
{r.name && (
{r.name}
)}
))}
); }