273 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|