award
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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; dxcc_filter?: number[] | null; dynamic?: boolean };
|
||||
type Meta = { code: string; count: number; can_update: boolean };
|
||||
|
||||
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('');
|
||||
const [results, setResults] = 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) => {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (q.length < 2) { setResults([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// References are always scoped to the contacted DXCC entity.
|
||||
const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50);
|
||||
setResults((r ?? []) as any);
|
||||
} catch { setResults([]); }
|
||||
finally { setBusy(false); }
|
||||
}, 200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCode, q, dxcc]);
|
||||
|
||||
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(''); setResults([]); }}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{!busy && q.length < 2 && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
Type 2+ chars to search
|
||||
</div>
|
||||
)}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user