This commit is contained in:
2026-06-05 22:35:28 +02:00
parent 88623f55df
commit 51d3a734e8
21 changed files with 2613 additions and 153 deletions
+19
View File
@@ -27,8 +27,10 @@ import {
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
QSOAudioBegin, QSOAudioCancel,
GetAwardDefs,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
import { EventsOn } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -94,6 +96,7 @@ const emptyDetails: DetailsState = {
sat_name: '', sat_mode: '',
contest_id: '', srx: undefined, stx: undefined,
email: '',
award_refs: '',
};
function fmtDateUTC(s: any): string {
@@ -658,6 +661,19 @@ export default function App() {
});
myCallRef.current = (station.callsign || '').toUpperCase();
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
// picked award references to the QSO field/extras each award actually reads.
const awardFieldRef = useRef<Record<string, string>>({});
useEffect(() => {
GetAwardDefs()
.then((defs) => {
const m: Record<string, string> = {};
for (const d of (defs ?? []) as any[]) m[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
awardFieldRef.current = m;
})
.catch(() => {});
}, []);
// === Clock ===
const [utcNow, setUtcNow] = useState('');
useEffect(() => {
@@ -1077,6 +1093,7 @@ export default function App() {
srx: details.srx, stx: details.stx,
email: details.email,
};
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
await AddQSO(payload);
resetEntry();
await refresh();
@@ -1096,6 +1113,7 @@ export default function App() {
if (!locks.start) setQsoStartedAt(null);
if (!locks.end) setQsoEndedAt(null);
resetAutoFill();
setWb(null); // clear the Worked-before grid for the just-cleared callsign
setLookupError('');
rstUserEditedRef.current = false;
applyModePreset(mode);
@@ -1106,6 +1124,7 @@ export default function App() {
qsl_msg: '', qsl_via: '',
contest_id: '', srx: undefined, stx: undefined,
email: '',
award_refs: '',
}));
}
+432 -86
View File
@@ -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&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 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>
);
}
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'react';
import { X, Loader2 } from 'lucide-react';
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
interface Props {
code: string; // award code, e.g. "POTA"
label: string; // display label
dxcc?: number; // contacted-station DXCC; used when "this country" is on
countryOnly: boolean;
value: string; // currently assigned reference
onChange: (ref: string) => void;
}
// AwardRefPicker — type-ahead search over an award's reference list (POTA parks,
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
// result assigns it to the QSO.
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
const [q, setQ] = useState('');
const [results, setResults] = useState<Ref[]>([]);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const t = window.setTimeout(async () => {
setBusy(true);
try {
const r = await SearchAwardReferences(code, q, countryOnly ? (dxcc ?? 0) : 0, 30);
setResults((r ?? []) as any);
} catch { setResults([]); }
finally { setBusy(false); }
}, 200);
return () => window.clearTimeout(t);
}, [q, open, code, dxcc, countryOnly]);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
window.addEventListener('mousedown', close);
return () => window.removeEventListener('mousedown', close);
}, [open]);
return (
<div className="grid grid-cols-[60px_1fr] items-center gap-2">
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
<div className="relative" ref={boxRef}>
{value ? (
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
<span className="font-mono font-semibold">{value}</span>
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title="Remove"><X className="size-3.5" /></button>
</div>
) : (
<input
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={`Search ${label}`}
value={q}
onFocus={() => setOpen(true)}
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
/>
)}
{open && !value && (
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Searching</div>}
{!busy && results.length === 0 && (
<div className="px-3 py-2 text-muted-foreground">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
)}
{results.map((r) => (
<button
key={r.code}
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onChange(r.code); setOpen(false); setQ(''); }}
>
<span className="font-mono font-semibold shrink-0">{r.code}</span>
<span className="text-muted-foreground truncate">{r.name}</span>
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -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>
);
}
+14 -6
View File
@@ -8,12 +8,13 @@ import { AwardEditor } from '@/components/AwardEditor';
type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
ref: string; name?: string; worked: boolean; confirmed: boolean;
ref: string; name?: string; group?: string; subgrp?: string;
worked: boolean; confirmed: boolean; validated: boolean;
bands: string[]; confirmed_bands: string[];
};
type AwardResult = {
code: string; name: string; dimension: string;
worked: number; confirmed: number; total: number;
worked: number; confirmed: number; validated: number; total: number;
bands: BandCount[]; refs: AwardRef[];
};
@@ -121,6 +122,7 @@ export function AwardsPanel() {
<div className="mt-1 flex items-center gap-4 text-sm">
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
{current.total > 0 && (
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
)}
@@ -158,17 +160,23 @@ export function AwardsPanel() {
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1 pr-2 font-medium w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</th>
<th className="py-1 pr-2 font-medium w-20">Status</th>
<th className="py-1 pr-2 font-medium w-40">Group</th>
<th className="py-1 pr-2 font-medium w-24">Status</th>
<th className="py-1 font-medium">Bands</th>
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className="border-b border-border/30">
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[260px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
<td className="py-1 pr-2">
{r.confirmed ? (
{!r.worked ? (
<span className="text-muted-foreground/70"> missing</span>
) : r.validated ? (
<span className="inline-flex items-center gap-1 text-sky-600"><CheckCircle2 className="size-3" /> valid.</span>
) : r.confirmed ? (
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="size-3" /> conf.</span>
) : (
<span className="text-amber-600">worked</span>
+11 -5
View File
@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react';
import { Construction } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -9,6 +8,7 @@ import {
import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { AwardRefSelector } from '@/components/AwardRefSelector';
export interface DetailsState {
state: string;
@@ -37,6 +37,10 @@ export interface DetailsState {
srx?: number;
stx?: number;
email: string;
// Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
award_refs?: string;
}
interface Props {
@@ -84,7 +88,6 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) {
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
const path = useMemo(
@@ -197,9 +200,12 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
)}
{open === 'awards' && (
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div>
<div className="px-3 py-2.5">
<AwardRefSelector
dxcc={details.dxcc}
value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })}
/>
</div>
)}
+81 -5
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign, DXCCForCountry } from '../../wailsjs/go/main/App';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { applyAwardRefs, buildAwardRefs } from '@/lib/awardRefs';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -166,6 +168,47 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
const [saving, setSaving] = useState(false);
const [looking, setLooking] = useState(false);
// === Award references (Log4OM-style tab) ===
// Manual refs are edited as a "CODE@REF;…" string; computed refs (DXCC, WAZ,
// WPX, …) are derived from the QSO by the backend and shown read-only.
const awardFieldRef = useRef<Record<string, string>>({});
const [awardRefs, setAwardRefs] = useState('');
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
// Load award definitions once, then seed the editable manual refs from the QSO.
useEffect(() => {
GetAwardDefs()
.then(async (defs) => {
const list = (defs ?? []) as any[];
const fieldOf: Record<string, string> = {};
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
awardFieldRef.current = fieldOf;
// Which awards are reference-list (manual) ones? Ask the backend, which
// also tells us pickable vs computed for the current QSO.
try {
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
const pickableCodes = new Set(all.filter((r: any) => r.pickable).map((r: any) => String(r.code).toUpperCase()));
const pickable = list
.filter((d) => pickableCodes.has(String(d.code).toUpperCase()))
.map((d) => ({ code: String(d.code), field: String(d.field || '').toLowerCase() }));
setAwardRefs(buildAwardRefs(draft, pickable));
} catch { /* leave manual refs empty on failure */ }
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Recompute the read-only computed refs whenever a source field changes.
useEffect(() => {
const t = window.setTimeout(async () => {
try {
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
setComputedRefs(all.filter((r: any) => !r.pickable).map((r: any) => ({ code: r.code, ref: r.ref, name: r.name })));
} catch { setComputedRefs([]); }
}, 250);
return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value }));
}
@@ -228,9 +271,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
operator: (draft.operator ?? '').trim().toUpperCase(),
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
iota: (draft.iota ?? '').trim().toUpperCase(),
sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(),
pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(),
// iota / sota_ref / pota_ref are set below from the Award Refs tab.
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
@@ -253,6 +294,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
tx_pwr: numOrUndef(draft.tx_pwr),
extras: parseExtras(extrasText),
};
// The Award Refs tab is authoritative for the reference-list awards. Reset
// the dedicated columns, then route the picked refs back onto the payload
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
applyAwardRefs(out, awardRefs, awardFieldRef.current);
onSave(out);
}
@@ -283,6 +329,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
<TabsList className="px-3 overflow-x-auto">
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
<TabsTrigger value="contact">Contact's details</TabsTrigger>
<TabsTrigger value="awards">Award Refs</TabsTrigger>
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
<TabsTrigger value="contest">Contest</TabsTrigger>
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
@@ -411,6 +458,35 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
</div>
</TabsContent>
<TabsContent value="awards" className="mt-0">
<div className="grid grid-cols-[1fr_240px] gap-5">
{/* Left: pick reference-list awards (POTA/SOTA/IOTA/WWFF/…) */}
<div>
<AwardRefSelector dxcc={draft.dxcc} value={awardRefs} onChange={setAwardRefs} />
</div>
{/* Right: computed awards (read-only) derived from this QSO */}
<div className="flex flex-col gap-1.5 min-w-0">
<span className="text-xs font-semibold">Computed (automatic)</span>
<p className="text-[11px] text-muted-foreground leading-snug">
Derived from this QSO's fields (DXCC, zones, prefix, notes). Not editable here.
</p>
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
{computedRefs.length === 0 ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
) : (
computedRefs.map((r) => (
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
<span className="font-mono font-semibold">{r.code}@{r.ref}</span>
{r.name && <span className="text-[10px] text-muted-foreground ml-1.5 truncate">{r.name}</span>}
</div>
))
)}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="qsl" className="mt-0">
{(() => {
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
+98
View File
@@ -0,0 +1,98 @@
// Shared helpers for per-QSO award references.
//
// In the UI a QSO's manually-assigned award references are edited as a single
// semicolon-delimited string of "CODE@REF" entries, e.g.
// "POTA@FR-11553;IOTA@EU-064"
// On save each entry is routed to the QSO field its award actually reads from
// (see internal/award/award.go): POTA/SOTA/IOTA have dedicated columns; WWFF
// and custom awards live in uppercase ADIF extras keys.
// parseAwardRefs turns "POTA@FR-11553;IOTA@EU-064" into
// { POTA: "FR-11553", IOTA: "EU-064" }. Repeated codes join with commas.
export function parseAwardRefs(v: string): Record<string, string> {
const out: Record<string, string> = {};
for (const entry of (v ?? '').split(';').filter(Boolean)) {
const at = entry.indexOf('@');
if (at <= 0) continue;
const code = entry.slice(0, at).toUpperCase();
const ref = entry.slice(at + 1).trim().toUpperCase();
if (!ref) continue;
out[code] = out[code] ? `${out[code]},${ref}` : ref;
}
return out;
}
// appendTokens adds space-separated tokens (a "A,B" ref string) to a text field,
// skipping any already present, so re-picking is idempotent.
function appendTokens(existing: string | undefined, refs: string): string {
let out = (existing ?? '').trim();
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
const re = new RegExp(`(^|\\s)${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, 'i');
if (!re.test(out)) out = out ? `${out} ${tok}` : tok;
}
return out;
}
// applyAwardRefs writes picked references onto a QSO payload using each award's
// scanned field. fieldOf maps an award CODE (uppercase) to its field name.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
const byCode = parseAwardRefs(awardRefs);
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
for (const [code, ref] of Object.entries(byCode)) {
const field = fieldOf[code] || code.toLowerCase();
switch (field) {
case 'iota': payload.iota = ref; break;
case 'sota_ref': payload.sota_ref = ref; break;
case 'pota_ref': payload.pota_ref = ref; break;
case 'wwff':
extras['WWFF_REF'] = ref;
extras['SIG'] = 'WWFF';
extras['SIG_INFO'] = ref;
break;
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM
// scans the note for "D06"). Picking such a reference appends its code(s)
// to that field so the matcher finds it.
case 'note': case 'notes':
payload.notes = appendTokens(payload.notes, ref);
break;
case 'comment':
payload.comment = appendTokens(payload.comment, ref);
break;
default:
extras[field.toUpperCase()] = ref;
break;
}
}
if (Object.keys(extras).length > 0) payload.extras = extras;
}
// awardRefValue reads a single award's stored reference from a QSO, inverse of
// applyAwardRefs. Used to seed the editor when opening an existing QSO.
export function awardRefValue(qso: any, code: string, field: string): string {
switch (field) {
case 'iota': return (qso.iota ?? '').toUpperCase();
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
case 'wwff': {
const ex = qso.extras ?? {};
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
if (String(ex['SIG'] ?? '').toUpperCase() === 'WWFF') return String(ex['SIG_INFO'] ?? '').toUpperCase();
return '';
}
default: {
const ex = qso.extras ?? {};
return String(ex[field.toUpperCase()] ?? '').toUpperCase();
}
}
}
// buildAwardRefs reconstructs the "CODE@REF;…" editor string from a QSO for the
// given pickable awards (code → field). Only awards with a stored value appear.
export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field: string }>): string {
const out: string[] = [];
for (const { code, field } of pickable) {
const v = awardRefValue(qso, code, field);
if (v) out.push(`${code.toUpperCase()}@${v}`);
}
return out.join(';');
}
+29
View File
@@ -5,6 +5,7 @@ import {main} from '../models';
import {profile} from '../models';
import {adif} from '../models';
import {award} from '../models';
import {awardref} from '../models';
import {cat} from '../models';
import {cluster} from '../models';
import {extsvc} from '../models';
@@ -18,12 +19,16 @@ export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AwardFields():Promise<Array<string>>;
export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef>>;
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
export function ConnectAllClusters():Promise<void>;
@@ -50,8 +55,12 @@ export function DVKStopRecord():Promise<void>;
export function DXCCForCountry(arg1:string):Promise<number>;
export function DXCCName(arg1:number):Promise<string>;
export function DeleteAllQSO():Promise<number>;
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
export function DeleteClusterServer(arg1:number):Promise<void>;
export function DeleteOperatingAntenna(arg1:number):Promise<void>;
@@ -90,6 +99,10 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAwardDefs():Promise<Array<award.Def>>;
export function GetAwardPresets():Promise<Array<awardref.Preset>>;
export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>;
@@ -140,12 +153,18 @@ export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
export function ListAwardReferences(arg1:string):Promise<Array<awardref.Ref>>;
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
export function ListCountries():Promise<Array<string>>;
@@ -186,6 +205,8 @@ export function PickOpenDatabase():Promise<string>;
export function PickSaveDatabase():Promise<string>;
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>;
@@ -196,6 +217,8 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function ReloadUDPIntegrations():Promise<Array<string>>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
@@ -216,6 +239,8 @@ export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
@@ -246,6 +271,8 @@ export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
@@ -282,6 +309,8 @@ export function TestQRZUpload():Promise<string>;
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
+56
View File
@@ -10,6 +10,10 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
}
export function AwardFields() {
return window['go']['main']['App']['AwardFields']();
}
@@ -22,6 +26,10 @@ export function ClusterSpotStatuses(arg1) {
return window['go']['main']['App']['ClusterSpotStatuses'](arg1);
}
export function ComputeQSOAwardRefs(arg1) {
return window['go']['main']['App']['ComputeQSOAwardRefs'](arg1);
}
export function ComputeStationInfo(arg1, arg2) {
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
}
@@ -74,10 +82,18 @@ export function DXCCForCountry(arg1) {
return window['go']['main']['App']['DXCCForCountry'](arg1);
}
export function DXCCName(arg1) {
return window['go']['main']['App']['DXCCName'](arg1);
}
export function DeleteAllQSO() {
return window['go']['main']['App']['DeleteAllQSO']();
}
export function DeleteAwardReference(arg1, arg2) {
return window['go']['main']['App']['DeleteAwardReference'](arg1, arg2);
}
export function DeleteClusterServer(arg1) {
return window['go']['main']['App']['DeleteClusterServer'](arg1);
}
@@ -154,6 +170,14 @@ export function GetAwardDefs() {
return window['go']['main']['App']['GetAwardDefs']();
}
export function GetAwardPresets() {
return window['go']['main']['App']['GetAwardPresets']();
}
export function GetAwardReferenceMeta() {
return window['go']['main']['App']['GetAwardReferenceMeta']();
}
export function GetAwards() {
return window['go']['main']['App']['GetAwards']();
}
@@ -254,10 +278,18 @@ export function GetWinkeyerStatus() {
return window['go']['main']['App']['GetWinkeyerStatus']();
}
export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
export function ImportADIF(arg1, arg2, arg3) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
}
export function ImportAwardReferencesText(arg1, arg2) {
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
}
export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices']();
}
@@ -266,6 +298,10 @@ export function ListAudioOutputDevices() {
return window['go']['main']['App']['ListAudioOutputDevices']();
}
export function ListAwardReferences(arg1) {
return window['go']['main']['App']['ListAwardReferences'](arg1);
}
export function ListClusterServers() {
return window['go']['main']['App']['ListClusterServers']();
}
@@ -346,6 +382,10 @@ export function PickSaveDatabase() {
return window['go']['main']['App']['PickSaveDatabase']();
}
export function PopulateBuiltinReferences(arg1) {
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
}
export function QSOAudioBegin() {
return window['go']['main']['App']['QSOAudioBegin']();
}
@@ -366,6 +406,10 @@ export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations']();
}
export function ReplaceAwardReferences(arg1, arg2) {
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
}
export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs']();
}
@@ -406,6 +450,10 @@ export function SaveAwardDefs(arg1) {
return window['go']['main']['App']['SaveAwardDefs'](arg1);
}
export function SaveAwardReference(arg1, arg2) {
return window['go']['main']['App']['SaveAwardReference'](arg1, arg2);
}
export function SaveBackupSettings(arg1) {
return window['go']['main']['App']['SaveBackupSettings'](arg1);
}
@@ -466,6 +514,10 @@ export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
}
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
}
export function SendClusterCommand(arg1) {
return window['go']['main']['App']['SendClusterCommand'](arg1);
}
@@ -538,6 +590,10 @@ export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1);
}
export function UpdateAwardReferenceList(arg1) {
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
}
export function UpdateQSO(arg1) {
return window['go']['main']['App']['UpdateQSO'](arg1);
}
+171
View File
@@ -85,10 +85,33 @@ export namespace award {
export class Def {
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[];
valid_bands?: string[];
valid_modes?: string[];
emission?: string[];
confirm: string[];
validate?: string[];
grant_codes?: string;
export_credit_granted?: boolean;
total: number;
builtin: boolean;
@@ -100,10 +123,33 @@ export namespace award {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.name = source["name"];
this.description = source["description"];
this.valid = source["valid"];
this.protected = source["protected"];
this.url = source["url"];
this.download_url = source["download_url"];
this.ref_url = source["ref_url"];
this.valid_from = source["valid_from"];
this.valid_to = source["valid_to"];
this.alias = source["alias"];
this.type = source["type"];
this.field = source["field"];
this.match_by = source["match_by"];
this.exact_match = source["exact_match"];
this.pattern = source["pattern"];
this.leading_str = source["leading_str"];
this.trailing_str = source["trailing_str"];
this.multi = source["multi"];
this.dynamic = source["dynamic"];
this.add_prefixes = source["add_prefixes"];
this.dxcc_filter = source["dxcc_filter"];
this.valid_bands = source["valid_bands"];
this.valid_modes = source["valid_modes"];
this.emission = source["emission"];
this.confirm = source["confirm"];
this.validate = source["validate"];
this.grant_codes = source["grant_codes"];
this.export_credit_granted = source["export_credit_granted"];
this.total = source["total"];
this.builtin = source["builtin"];
}
@@ -111,8 +157,11 @@ export namespace award {
export class Ref {
ref: string;
name?: string;
group?: string;
subgrp?: string;
worked: boolean;
confirmed: boolean;
validated: boolean;
bands: string[];
confirmed_bands: string[];
@@ -124,8 +173,11 @@ export namespace award {
if ('string' === typeof source) source = JSON.parse(source);
this.ref = source["ref"];
this.name = source["name"];
this.group = source["group"];
this.subgrp = source["subgrp"];
this.worked = source["worked"];
this.confirmed = source["confirmed"];
this.validated = source["validated"];
this.bands = source["bands"];
this.confirmed_bands = source["confirmed_bands"];
}
@@ -136,6 +188,7 @@ export namespace award {
field: string;
worked: number;
confirmed: number;
validated: number;
total: number;
bands: BandCount[];
refs: Ref[];
@@ -152,6 +205,7 @@ export namespace award {
this.field = source["field"];
this.worked = source["worked"];
this.confirmed = source["confirmed"];
this.validated = source["validated"];
this.total = source["total"];
this.bands = this.convertValues(source["bands"], BandCount);
this.refs = this.convertValues(source["refs"], Ref);
@@ -179,6 +233,87 @@ export namespace award {
}
export namespace awardref {
export class Ref {
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;
static createFrom(source: any = {}) {
return new Ref(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.name = source["name"];
this.dxcc = source["dxcc"];
this.group = source["group"];
this.subgrp = source["subgrp"];
this.dxcc_list = source["dxcc_list"];
this.pattern = source["pattern"];
this.valid = source["valid"];
this.valid_from = source["valid_from"];
this.valid_to = source["valid_to"];
this.score = source["score"];
this.bonus = source["bonus"];
this.gridsquare = source["gridsquare"];
this.alias = source["alias"];
}
}
export class Preset {
key: string;
name: string;
field: string;
dxcc: number;
refs: Ref[];
static createFrom(source: any = {}) {
return new Preset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.key = source["key"];
this.name = source["name"];
this.field = source["field"];
this.dxcc = source["dxcc"];
this.refs = this.convertValues(source["refs"], Ref);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace cat {
export class RigState {
@@ -523,6 +658,24 @@ export namespace main {
this.mic_gain = source["mic_gain"];
}
}
export class AwardRefMeta {
code: string;
count: number;
updated_at: string;
can_update: boolean;
static createFrom(source: any = {}) {
return new AwardRefMeta(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.count = source["count"];
this.updated_at = source["updated_at"];
this.can_update = source["can_update"];
}
}
export class BackupSettings {
enabled: boolean;
folder: string;
@@ -796,6 +949,24 @@ export namespace main {
this.qrzcom_confirmed = source["qrzcom_confirmed"];
}
}
export class QSOAwardRef {
code: string;
ref: string;
name?: string;
pickable: boolean;
static createFrom(source: any = {}) {
return new QSOAwardRef(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.ref = source["ref"];
this.name = source["name"];
this.pickable = source["pickable"];
}
}
export class RotatorHeading {
enabled: boolean;
ok: boolean;