This commit is contained in:
2026-06-05 17:22:38 +02:00
parent cf9dbf26f3
commit 88623f55df
21 changed files with 2123 additions and 50 deletions
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save } 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 { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields } from '../../wailsjs/go/main/App';
export type AwardDef = {
code: string; name: string; field: string; pattern: string;
dxcc_filter: number[] | null; confirm: string[] | null; total: number; builtin?: boolean;
};
const CONFIRM_SRC = [{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }];
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
}
export function AwardEditor({ open, onClose, onSaved }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]);
const [fields, setFields] = useState<string[]>([]);
const [err, setErr] = useState('');
useEffect(() => {
if (!open) return;
setErr('');
Promise.all([GetAwardDefs(), AwardFields()])
.then(([d, f]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); })
.catch((e) => setErr(String(e?.message ?? e)));
}, [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] });
};
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 ?? [] }));
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)); }
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogHeader className="px-6 py-4">
<DialogTitle>Edit awards</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&nbsp;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>
))}
</div>
<Button variant="outline" size="sm" className="h-8 mt-2" onClick={addAward}>
<Plus className="size-3.5 mr-1" /> Add award
</Button>
</div>
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
<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>
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}