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