award
This commit is contained in:
@@ -1,134 +1,338 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save, Download, Loader2, Search } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields } from '../../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
ListAwardReferences, SaveAwardReference, DeleteAwardReference,
|
||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
|
||||
|
||||
export type AwardDef = {
|
||||
code: string; name: string; field: string; pattern: string;
|
||||
dxcc_filter: number[] | null; confirm: string[] | null; total: number; builtin?: boolean;
|
||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||
total: number; builtin?: boolean;
|
||||
};
|
||||
|
||||
const CONFIRM_SRC = [{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }];
|
||||
type AwardRef = {
|
||||
code: string; name: string; dxcc: number; group: string; subgrp: string;
|
||||
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
|
||||
score?: number; bonus?: number; gridsquare?: string; alias?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
|
||||
|
||||
const AWARD_TYPES = ['QSOFIELDS', 'REFERENCE', 'DXCC', 'GRID'];
|
||||
const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||
|
||||
const emptyAward = (): AwardDef => ({
|
||||
code: 'NEW', name: 'New award', description: '', valid: true,
|
||||
type: 'QSOFIELDS', field: 'note', match_by: 'code', exact_match: true, pattern: '',
|
||||
dxcc_filter: null, confirm: ['lotw', 'qsl'], validate: ['lotw', 'qsl'], total: 0,
|
||||
});
|
||||
|
||||
interface Props { open: boolean; onClose: () => void; onSaved: () => void; }
|
||||
|
||||
// Small reusable multi-toggle chip group.
|
||||
function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onToggle: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{all.map((v) => {
|
||||
const on = value.includes(v);
|
||||
return (
|
||||
<button key={v} type="button" onClick={() => onToggle(v)}
|
||||
className={cn('px-2 py-0.5 rounded text-[11px] border', on
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background border-border text-muted-foreground hover:bg-accent')}>
|
||||
{v}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DxccFilter — pick the entities an award is scoped to by country name (like the
|
||||
// QSO editor), resolving each to its ADIF DXCC number. Stored as number[].
|
||||
function DxccFilter({ value, onChange, countries }: { value: number[]; onChange: (v: number[]) => void; countries: string[] }) {
|
||||
const [names, setNames] = useState<Record<number, string>>({});
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
const miss = value.filter((n) => names[n] === undefined);
|
||||
if (miss.length === 0) return;
|
||||
const got: Record<number, string> = {};
|
||||
for (const n of miss) { try { got[n] = await DXCCName(n); } catch { got[n] = ''; } }
|
||||
if (live) setNames((m) => ({ ...m, ...got }));
|
||||
})();
|
||||
return () => { live = false; };
|
||||
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function addCountry(name: string) {
|
||||
const n = await DXCCForCountry(name);
|
||||
if (n && n > 0 && !value.includes(n)) {
|
||||
setNames((m) => ({ ...m, [n]: name }));
|
||||
onChange([...value, n]);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Combobox value="" options={countries} placeholder="Add country…" onChange={addCountry} className="h-8 w-full" />
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((n) => (
|
||||
<span key={n} className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-accent border border-border">
|
||||
<span className="font-mono">#{n}</span>
|
||||
<span className="text-muted-foreground">{names[n] || '…'}</span>
|
||||
<button className="hover:text-destructive" onClick={() => onChange(value.filter((x) => x !== n))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [fields, setFields] = useState<string[]>([]);
|
||||
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
|
||||
const [presets, setPresets] = useState<Preset[]>([]);
|
||||
const [countries, setCountries] = useState<string[]>([]);
|
||||
const [sel, setSel] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const loadMeta = () => GetAwardReferenceMeta()
|
||||
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setErr('');
|
||||
Promise.all([GetAwardDefs(), AwardFields()])
|
||||
.then(([d, f]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); })
|
||||
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
||||
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
||||
.catch((e) => setErr(String(e?.message ?? e)));
|
||||
loadMeta();
|
||||
}, [open]);
|
||||
|
||||
const patch = (i: number, p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === i ? { ...d, ...p } : d)));
|
||||
const addAward = () => setDefs((ds) => [...ds, { code: 'NEW', name: 'New award', field: 'dxcc', pattern: '', dxcc_filter: null, confirm: ['lotw', 'qsl'], total: 0 }]);
|
||||
const removeAward = (i: number) => setDefs((ds) => ds.filter((_, j) => j !== i));
|
||||
|
||||
const toggleConfirm = (i: number, id: string) => {
|
||||
const cur = defs[i].confirm ?? [];
|
||||
patch(i, { confirm: cur.includes(id) ? cur.filter((c) => c !== id) : [...cur, id] });
|
||||
const cur = defs[sel];
|
||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||
const arr = ((cur?.[key] as string[]) ?? []);
|
||||
patch({ [key]: arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v] } as any);
|
||||
};
|
||||
|
||||
function addAward() {
|
||||
setDefs((ds) => [...ds, emptyAward()]);
|
||||
setSel(defs.length);
|
||||
}
|
||||
function removeAward(i: number) {
|
||||
setDefs((ds) => ds.filter((_, j) => j !== i));
|
||||
setSel((s) => Math.max(0, s >= i ? s - 1 : s));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setErr('');
|
||||
try {
|
||||
// Normalise codes (uppercase, no blanks).
|
||||
const clean = defs
|
||||
.filter((d) => d.code.trim())
|
||||
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [] }));
|
||||
const clean = defs.filter((d) => d.code.trim())
|
||||
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [], validate: d.validate ?? [] }));
|
||||
await SaveAwardDefs(clean as any);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
try { setDefs((await ResetAwardDefs()) as any); } catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
try { setDefs((await ResetAwardDefs()) as any); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateList(code: string) {
|
||||
setUpdating(code); setErr('');
|
||||
try { await UpdateAwardReferenceList(code); await loadMeta(); }
|
||||
catch (e: any) { setErr(`${code}: ${String(e?.message ?? e)}`); }
|
||||
finally { setUpdating(null); }
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toUpperCase();
|
||||
return defs.map((d, i) => ({ d, i })).filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q));
|
||||
}, [defs, search]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader className="px-6 py-4">
|
||||
<DialogTitle>Edit awards</DialogTitle>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>Award management</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-2">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Each award scans one QSO <strong>field</strong>. Leave <strong>pattern</strong> empty to use the whole field value,
|
||||
or enter a regular expression where <span className="font-mono">group 1</span> is the reference — e.g. scan
|
||||
the <span className="font-mono">note</span> field with <span className="font-mono">{'D(\\d{1,2}[AB]?)'}</span> so
|
||||
"D74" counts department 74.
|
||||
</p>
|
||||
{err && <div className="text-xs text-destructive mb-2">{err}</div>}
|
||||
|
||||
<div className="space-y-2 max-h-[55vh] overflow-auto pr-1">
|
||||
{defs.map((d, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-24 font-mono font-semibold text-xs" value={d.code}
|
||||
onChange={(e) => patch(i, { code: e.target.value })} placeholder="CODE" />
|
||||
<Input className="h-8 flex-1 text-sm" value={d.name}
|
||||
onChange={(e) => patch(i, { name: e.target.value })} placeholder="Award name" />
|
||||
{d.builtin && <span className="text-[10px] text-muted-foreground border border-border rounded px-1.5 py-0.5">built-in</span>}
|
||||
<button className="text-muted-foreground hover:text-destructive" title="Remove" onClick={() => removeAward(i)}>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_1fr_auto_1fr] gap-x-3 gap-y-2 items-center text-xs">
|
||||
<label className="text-muted-foreground">Field</label>
|
||||
<Select value={d.field} onValueChange={(v) => patch(i, { field: v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="text-muted-foreground">Pattern</label>
|
||||
<Input className="h-8 font-mono text-xs" value={d.pattern}
|
||||
onChange={(e) => patch(i, { pattern: e.target.value })} placeholder="(optional regex, group 1 = ref)" />
|
||||
|
||||
<label className="text-muted-foreground">DXCC filter</label>
|
||||
<Input className="h-8 font-mono text-xs"
|
||||
value={(d.dxcc_filter ?? []).join(', ')}
|
||||
onChange={(e) => patch(i, { dxcc_filter: e.target.value.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isFinite(n)) })}
|
||||
placeholder="e.g. 227 (empty = any)" />
|
||||
<label className="text-muted-foreground">Total</label>
|
||||
<Input type="number" className="h-8 font-mono text-xs w-28" value={d.total}
|
||||
onChange={(e) => patch(i, { total: parseInt(e.target.value, 10) || 0 })} placeholder="0 = unknown" />
|
||||
|
||||
<label className="text-muted-foreground">Confirmed by</label>
|
||||
<div className="col-span-3 flex items-center gap-4">
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={(d.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleConfirm(i, c.id)} />
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
{/* Left: award list */}
|
||||
<div className="border-r flex flex-col min-h-0">
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input className="h-7 pl-7 text-xs" placeholder="Search awards…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filtered.map(({ d, i }) => (
|
||||
<button key={i} onClick={() => setSel(i)}
|
||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} />
|
||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||
<Plus className="size-3.5 mr-1" /> New award
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8 mt-2" onClick={addAward}>
|
||||
<Plus className="size-3.5 mr-1" /> Add award
|
||||
</Button>
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{err && <div className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{err}</div>}
|
||||
{!cur ? (
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
|
||||
) : (
|
||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||
<TabsList className="px-3 justify-start">
|
||||
<TabsTrigger value="info">Award info</TabsTrigger>
|
||||
<TabsTrigger value="type">Award type</TabsTrigger>
|
||||
<TabsTrigger value="conf">Confirmation</TabsTrigger>
|
||||
<TabsTrigger value="refs">References</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/* ── Award info ── */}
|
||||
<TabsContent value="info" className="mt-0 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder="Award name" />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> Valid</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title="Delete award" onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label="Description"><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label="Award URL"><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label="Reference URL"><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Valid from"><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label="Valid to"><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Label className="text-xs text-muted-foreground pt-1.5">DXCC filter</Label>
|
||||
<DxccFilter value={cur.dxcc_filter ?? []} onChange={(v) => patch({ dxcc_filter: v })} countries={countries} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid bands (empty = all)</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Emission (empty = all)</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid modes (empty = all)</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Award type ── */}
|
||||
<TabsContent value="type" className="mt-0 space-y-2.5">
|
||||
<Field2 label="Award type">
|
||||
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{AWARD_TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> Dynamic references (not predefined — any value counts, like POTA)</label>
|
||||
|
||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||
<p className="text-[11px] text-muted-foreground">QSO parameters (used by QSOFIELDS / REFERENCE types)</p>
|
||||
<Field2 label="Search in field">
|
||||
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<Field2 label="Match by">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{['code', 'description', 'pattern'].map((m) => (
|
||||
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" name="matchby" checked={(cur.match_by || 'code') === m} onChange={() => patch({ match_by: m })} className="accent-primary" /> {m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> Exact match (else search reference inside the field)</label>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder="group 1 = reference (for match-by pattern / dynamic)" /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Confirmation ── */}
|
||||
<TabsContent value="conf" className="mt-0 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">Confirmation (worked → confirmed)</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleIn('confirm', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">Validation (confirmed → validated)</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.validate ?? []).includes(c.id)} onCheckedChange={() => toggleIn('validate', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field2 label="Grant codes"><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> Export award in ADIF credit_granted field</label>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── References ── */}
|
||||
<TabsContent value="refs" className="mt-0">
|
||||
<ReferencesPanel
|
||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||
onChanged={loadMeta} setErr={setErr}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
@@ -138,3 +342,145 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||
// the online updater (POTA/SOTA/WWFF).
|
||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
||||
code: string; presets: Preset[]; meta?: RefMeta;
|
||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||
}) {
|
||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [selCode, setSelCode] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [bulk, setBulk] = useState('');
|
||||
const [showBulk, setShowBulk] = useState(false);
|
||||
const [hasBuiltin, setHasBuiltin] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
if (!code) return;
|
||||
setBusy(true);
|
||||
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
|
||||
};
|
||||
useEffect(load, [code]);
|
||||
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
|
||||
|
||||
async function populateBuiltin() {
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const sel = refs.find((r) => r.code === selCode) || null;
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toUpperCase();
|
||||
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
|
||||
}, [refs, q]);
|
||||
|
||||
const patchSel = (p: Partial<AwardRef>) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r)));
|
||||
|
||||
async function saveRef(r: AwardRef) {
|
||||
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function addRef() {
|
||||
const c = prompt('New reference code:')?.trim().toUpperCase();
|
||||
if (!c) return;
|
||||
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
|
||||
await saveRef(r); setSelCode(c);
|
||||
}
|
||||
async function delRef(c: string) {
|
||||
try { await DeleteAwardReference(code, c); setSelCode(null); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function applyPreset(key: string) {
|
||||
if (!key) return;
|
||||
try { await ApplyAwardPreset(code, key); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function importBulk() {
|
||||
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(`Imported ${n} references.`); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{refs.length}</span></span>
|
||||
<div className="flex-1" />
|
||||
<Select value="" onValueChange={applyPreset}>
|
||||
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
|
||||
<SelectContent>{presets.map((p) => <SelectItem key={p.key} value={p.key}>{p.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>Paste / CSV</Button>
|
||||
{hasBuiltin && (
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title="Replace with the shipped built-in list (DXCC entities, French departments, …)">
|
||||
Populate built-in
|
||||
</Button>
|
||||
)}
|
||||
{meta?.can_update && (
|
||||
<Button variant="outline" size="sm" className="h-7" disabled={updating} onClick={onUpdateOnline}>
|
||||
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} Update online
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> Add</Button>
|
||||
</div>
|
||||
|
||||
{showBulk && (
|
||||
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
|
||||
<p className="text-[11px] text-muted-foreground">One reference per line: <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> (comma/semicolon/tab). Replaces the whole list.</p>
|
||||
<Textarea rows={6} className="font-mono text-xs" value={bulk} onChange={(e) => setBulk(e.target.value)} placeholder={'ON,Ontario,,,1\nQC,Quebec,,,1'} />
|
||||
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>Cancel</Button><Button size="sm" className="h-7" onClick={importBulk}>Import</Button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[200px_1fr] gap-3">
|
||||
{/* List */}
|
||||
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
|
||||
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Loading…</div>}
|
||||
{!busy && filtered.length === 0 && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
|
||||
{filtered.map((r) => (
|
||||
<button key={r.code} onClick={() => setSelCode(r.code)}
|
||||
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
|
||||
<span className="font-mono font-semibold shrink-0">{r.code}</span>
|
||||
<span className="text-muted-foreground truncate">{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-reference editor */}
|
||||
<div className="border rounded p-3">
|
||||
{!sel ? (
|
||||
<div className="grid place-items-center h-full text-xs text-muted-foreground">Select a reference, or Add / import a list.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> Valid</label>
|
||||
<div className="flex-1" />
|
||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label="Description"><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Group"><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label="Subgroup"><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field2 label="Score"><Input type="number" className="h-8 font-mono" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label="Bonus"><Input type="number" className="h-8 font-mono" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label="Grid"><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user