Files
OpsLog/frontend/src/components/AwardEditor.tsx
T

966 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } 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 { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import {
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
GetAwardReferenceMeta, UpdateAwardReferenceList,
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App';
// Above this many references the editor stops loading the whole list and
// switches to search-only (mirrors Log4OM's "Too many items" behaviour).
const REF_LIST_CAP = 1000;
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
export type AwardDef = {
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;
ref_display?: string; // grid column shows: ref | name | both
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; dynamic?: boolean;
or_rules?: AwardOrRule[];
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;
};
type AwardOrRule = {
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
leading_str?: string; trailing_str?: string; prefix?: string;
};
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;
};
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
// Award types mirror Log4OM: REFERENCE (we supply a list), QSOFIELDS (scan any
// QSO field — handles state/grid4/zones/etc.), CALLSIGN. The type is
// organizational only; matching is driven by the field/pattern/dynamic options,
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
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 { t } = useI18n();
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={t('awed.addCountry')} 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 { t } = useI18n();
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('');
// The err banner doubles as a success/notice area (export path, import counts,
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
// forever; the longer text (export path) gets a bit more time.
useEffect(() => {
if (!err) return;
const t = window.setTimeout(() => setErr(''), 8000);
return () => window.clearTimeout(t);
}, [err]);
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(), GetAwardPresets(), ListCountries()])
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.catch((e) => setErr(String(e?.message ?? e)));
loadMeta();
}, [open]);
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
// here exists only on this machine — it is yours alone, and a reinstall loses it
// unless it has been exported. The list flags those.
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
useEffect(() => {
if (!open) return;
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
}, [open]);
// Shipped fixes we did NOT apply, because this award carries the operator's own
// changes and we will not overwrite those behind their back. Offered, not forced.
type AwardUpdate = { code: string; name: string; from: number; to: number };
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
const loadUpdates = useCallback(() => {
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
}, []);
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
// Pending import awaiting the operator's decision on the awards that collide.
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
type ImportPreview = { path: string; awards: ImportEntry[] };
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
const [decisions, setDecisions] = useState<Record<string, string>>({});
useEffect(() => {
if (!importPreview) return;
// Default to the SAFE choice: keep what you have. An import must never destroy
// an award because the operator clicked through a dialog without reading it.
const d: Record<string, string> = {};
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
setDecisions(d);
}, [importPreview]);
const cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
// ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string };
type Step = {
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
skipped?: boolean; error?: string;
};
type Explanation = {
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
};
type TestRow = { qso: any; explanation: Explanation };
const [testCall, setTestCall] = useState('');
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
const [testErr, setTestErr] = useState('');
const [testing, setTesting] = useState(false);
const runTest = async () => {
if (!cur) return;
setTesting(true); setTestErr(''); setTestRows(null);
try {
const r = await ExplainAward(cur.code, testCall);
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
} catch (e: any) {
setTestErr(String(e?.message ?? e));
} finally {
setTesting(false);
}
};
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
// so, rather than let the operator test a rule they only think they applied.
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
// of the same trace is noise. But they don't always: scope is judged per QSO
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
// two contacts can even hold different QTHs. That divergence is often the actual
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
// trace instead of dropping it: one card per distinct outcome, with its count.
const testGroups = useMemo(() => {
if (!testRows) return null;
const groups: { key: string; rows: TestRow[] }[] = [];
for (const row of testRows) {
const key = JSON.stringify(row.explanation);
const g = groups.find((x) => x.key === key);
if (g) g.rows.push(row);
else groups.push({ key, rows: [row] });
}
return groups;
}, [testRows]);
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 {
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); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Export every award definition + reference list to a JSON bundle (backup
// that survives a reinstall / PC change, independent of the database).
async function exportAwards() {
setErr('');
try {
const p = await ExportAwards();
if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Import: LOOK FIRST, then ask.
//
// This used to merge by code with "imported wins", silently — import a WAPC
// someone shared and YOUR WAPC (its province list, its city regexes) was
// destroyed without a word. Sharing awards is exactly what we want people to do,
// so it must not be a data-loss trap.
async function importAwards() {
setErr('');
try {
const p: any = await InspectAwardImport();
if (!p?.path) return; // cancelled
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
if (clashes.length === 0) {
// Nothing collides — nothing to ask. This is the common case.
const dec: Record<string, string> = {};
for (const e of p.awards) dec[e.code] = 'replace';
await applyImport(p.path, dec);
return;
}
setImportPreview(p); // → the collision dialog decides
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function applyImport(path: string, decisions: Record<string, string>) {
try {
const r: any = await ApplyAwardImport(path, decisions);
setImportPreview(null);
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
setDefs((d ?? []) as any); setSel(0);
onSaved();
if (r?.awards || r?.references) {
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
}
} 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();
// Keep the original index `i` (used for selection/patch) but display the
// list sorted alphabetically by code — scales as the catalogue grows.
return defs.map((d, i) => ({ d, i }))
.filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q))
.sort((a, b) => a.d.code.localeCompare(b.d.code));
}, [defs, search]);
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<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>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader>
<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={t('awed.searchAwards')} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
<div className="flex-1 overflow-auto">
{filtered.map(({ d, i }) => {
// An award that is NOT in the shipped catalog exists only in THIS
// database: nobody else has it, and a reinstall loses it unless it
// has been exported. That deserves to be visible at a glance, not
// discovered the hard way.
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
// A pending update is only reachable from the award's own banner, so
// the list has to say which award to open — otherwise the fix waits
// behind a click nobody knows to make.
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
return (
<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-success')} />
<span className="font-mono font-semibold shrink-0">{d.code}</span>
<span className="text-muted-foreground truncate">{d.name}</span>
{hasUpdate && (
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
<ArrowUpCircle className="size-3.5 text-info" />
</span>
)}
{onlyHere && (
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
title={t('awed.onlyHereTip')}>
{t('awed.onlyHere')}
</span>
)}
</button>
);
})}
</div>
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
</Button>
</div>
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden">
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{/* A fix shipped for an award this operator has customised. We did NOT
apply it — that would destroy their work — so we offer it, and say
plainly what accepting costs. */}
{cur && selUpdate && (
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
<div className="flex items-center gap-2">
<ArrowUpCircle className="size-4 text-info shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium">{t('awed.updateAvailable')}</div>
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
</div>
<Button size="sm" className="h-7 px-2 text-[11px]"
onClick={async () => {
try {
await ApplyAwardUpdate(cur.code);
// The backend rewrote this award (and its references) — pull the
// new state back, or the editor would keep showing, and on the
// next Save re-persist, the definition we just replaced.
setDefs(((await GetAwardDefs()) ?? []) as any);
loadMeta();
loadUpdates();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}}>{t('awed.updateApply')}</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
onClick={async () => {
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}}>{t('awed.updateKeepMine')}</Button>
</div>
</div>
)}
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : (
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
<TabsList className="px-3 justify-start">
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
<TabsTrigger value="test">{t('awed.tabTest')}</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={t('awed.awardName')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
the catalog derives that on load. Asking the author to tick a
box to declare it would be one more step nobody can guess —
forget it and the award silently misses every future catalog
correction. "Protected" stays: whether an award can be deleted
IS a real choice. */}
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
</label>
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
</div>
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
<Field2 label={t('awed.refDisplay')}>
<Select value={cur.ref_display || 'ref'} onValueChange={(v) => patch({ ref_display: v })}>
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ref">{t('awed.refDisplayRef')}</SelectItem>
<SelectItem value="name">{t('awed.refDisplayName')}</SelectItem>
<SelectItem value="both">{t('awed.refDisplayBoth')}</SelectItem>
</SelectContent>
</Select>
</Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
<Field2 label={t('awed.validTo')}><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">{t('awed.dxccFilter')}</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">{t('awed.validBands')}</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">{t('awed.emission')}</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">{t('awed.validModes')}</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={t('awed.awardType')}>
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
<SelectContent>{
// Keep a legacy type (DXCC/GRID) selectable if an existing
// award still uses it, so it isn't silently changed.
(cur.type && !AWARD_TYPES.includes(cur.type) ? [cur.type, ...AWARD_TYPES] : AWARD_TYPES)
.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)
}</SelectContent>
</Select>
</Field2>
{/* No "allow multiple references" switch: a QSO always counts for
every reference its field holds (an n-fer POTA activation, a
VUCC grid-line contact). The old checkbox was read by nothing. */}
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
<div className="border-t pt-2.5 mt-1 space-y-2.5">
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
<Field2 label={t('awed.searchInField')}>
<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={t('awed.matchBy')}>
<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 })} /> {t('awed.exactMatch')}</label>
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
</div>
{/* Fallback searches: tried in order, only while nothing
has matched yet — the first that hits wins (short-circuit),
so a value already resolved by the primary rule isn't
re-derived differently by a later one. */}
<div className="border-t pt-2.5 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
<Button size="sm" variant="outline" className="h-7"
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
<Plus className="size-3.5" /> {t('awed.addOr')}
</Button>
</div>
{(cur.or_rules ?? []).map((r, ri) => {
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
return (
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{t('awed.orSearchIn')}</span>
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-2 text-[11px]">
{['code', 'description', 'pattern'].map((m) => (
<label key={m} className="flex items-center gap-1 cursor-pointer">
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
</label>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> {t('awed.exact')}</label>
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title={t('awed.removeOrSearch')}><Trash2 className="size-4" /></button>
</div>
<div className="grid grid-cols-[1fr_120px] gap-2">
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder={t('awed.orPatternPlaceholder')} />
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} />
</div>
</div>
);
})}
</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">{t('awed.confirmationLabel')}</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">{t('awed.validationLabel')}</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>
{/* "Grant codes" and "export credit_granted" used to live here. No
ADIF export has ever written CREDIT_GRANTED, so both controls
did nothing at all. The stored values are kept (see award.Def);
put the controls back when the export is actually wired up. */}
</TabsContent>
{/* ── Test ──
Runs the SAVED award against a real QSO and shows every rule:
what it scanned, what it produced, and why each candidate was
rejected. An award that matches nothing used to be a black box. */}
<TabsContent value="test" className="mt-0 space-y-3">
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
</div>
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
{t('awed.testRun')}
</Button>
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
</div>
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
{testGroups?.map((g, ri) => {
const row = g.rows[0];
const ex = row.explanation;
const matched = (ex.result ?? []).length > 0;
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
return (
<div key={ri} className="rounded border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
{g.rows.length > 1 && (
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
</span>
)}
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
</span>
</div>
{!ex.in_scope ? (
<div className="px-3 py-2 text-xs">
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
<span className="text-muted-foreground">{ex.scope_error}</span>
</div>
) : (
<div className="divide-y divide-border/50">
{(ex.steps ?? []).map((s, si) => (
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
<span className="text-muted-foreground">
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
</span>
{s.skipped && <span className="text-muted-foreground italic"> {t('awed.testSkipped')}</span>}
{s.error && <span className="text-destructive"> {s.error}</span>}
</div>
{!s.skipped && !s.error && (
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
<div>
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
{s.field_value
? <span className="font-mono">{s.field_value}</span>
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
</div>
{(s.kept ?? []).map((k) => (
<div key={k} className="text-success font-mono"> {k}</div>
))}
{(s.rejected ?? []).map((r, i2) => (
<div key={i2} className="font-mono text-muted-foreground">
{r.candidate} <span className="font-sans"> {r.reason}</span>
</div>
))}
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
)}
</div>
)}
</div>
))}
{(ex.manual ?? []).length > 0 && (
<div className="px-3 py-2 text-xs">
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
</div>
)}
</div>
)}
</div>
);
})}
</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-5 py-3 border-t !flex-row">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
<Download className="size-3.5 mr-1" /> {t('awed.export')}
</Button>
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
</Button>
{/* The drop folder: put an award JSON here and it installs on restart —
no rebuild, nobody to ask. Opening it beats printing a path someone
then has to retype. */}
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button>
<div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
</DialogFooter>
</DialogContent>
{/* Collision dialog. The import cannot silently replace an award you already
have — importing a shared WAPC used to destroy yours (province list, city
regexes, band scope) without a word. You decide, per award. */}
{importPreview && (
<Dialog open onOpenChange={() => setImportPreview(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
{importPreview.awards.map((e) => (
<div key={e.code} className="rounded-md border border-border p-2.5">
<div className="flex items-baseline gap-2 min-w-0">
<span className="font-mono font-semibold text-sm">{e.code}</span>
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
{t('awed.importRefs', { n: e.references })}
</span>
</div>
{!e.exists ? (
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
) : (
<>
<p className="mt-1 text-[11px] text-warning-muted-foreground">
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
{e.protected && ` · ${t('awed.importProtected')}`}
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{([
['skip', t('awed.importKeepMine')],
['replace', t('awed.importReplace')],
['copy', t('awed.importCopy', { code: e.code })],
] as [string, string][]).map(([v, label]) => (
<button key={v} type="button"
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
className={cn('px-2 h-7 rounded-md border text-xs',
decisions[e.code] === v
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-muted')}>
{label}
</button>
))}
</div>
</>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
<Button onClick={() => applyImport(importPreview.path, decisions)}>
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</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 { t } = useI18n();
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 total = meta?.count ?? 0;
const large = total > REF_LIST_CAP;
// Small lists are loaded whole and filtered client-side; large lists (POTA,
// 85k parks) are search-only to stay responsive.
const load = () => {
if (!code) return;
if (total > REF_LIST_CAP) { setRefs([]); return; }
setBusy(true);
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
};
useEffect(load, [code, total]);
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
// Server-side search for large lists (debounced, min 2 chars).
useEffect(() => {
if (!large) return;
const s = q.trim();
if (s.length < 2) { setRefs([]); return; }
const t = window.setTimeout(() => {
setBusy(true);
SearchAwardReferences(code, s, 0, 200).then((r) => setRefs((r ?? []) as any)).catch(() => setRefs([])).finally(() => setBusy(false));
}, 200);
return () => window.clearTimeout(t);
}, [code, q, large]);
async function populateBuiltin() {
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(t('awed.populatedMsg', { n })); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
const sel = refs.find((r) => r.code === selCode) || null;
// Large lists are already filtered by the server; small lists filter locally.
const filtered = useMemo(() => {
if (large) return refs;
const s = q.trim().toUpperCase();
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
}, [refs, q, large]);
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(t('awed.newRefCodePrompt'))?.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(t('awed.importedRefsMsg', { n })); }
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">{t('awed.referenceCount')} <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
<div className="flex-1" />
<Select value="" onValueChange={applyPreset}>
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder={t('awed.applyPreset')} /></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)}>{t('awed.pasteCsv')}</Button>
{hasBuiltin && (
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title={t('awed.populateBuiltinTitle')}>
{t('awed.populateBuiltin')}
</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" />} {t('awed.updateOnline')}
</Button>
)}
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> {t('awed.add')}</Button>
</div>
{showBulk && (
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
<p className="text-[11px] text-muted-foreground">{t('awed.onePerLine')} <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> {t('awed.replacesList')}</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)}>{t('awed.cancel')}</Button><Button size="sm" className="h-7" onClick={importBulk}>{t('awed.import2')}</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={t('awed.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" /> {t('awed.searching')}</div>}
{!busy && large && q.trim().length < 2 && (
<div className="m-2 rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground">
{t('awed.tooManyItems', { total: total.toLocaleString() })}
</div>
)}
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('awed.noReferences')}</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">{t('awed.selectReference')}</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 })} /> {t('awed.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={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
{/* One per row: side by side, each half spends 120px of its width on
the label column and the input is left too narrow to read a group
name ("Basilicata" → "Basili"). */}
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
<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={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
{/* Score / Bonus were here. Nothing computes an award score, so both
boxes were pure decoration. The columns stay in the database — a
third-party list may carry the values — but they are not offered
for editing until something actually reads them. */}
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
</div>
)}
</div>
</div>
</div>
);
}