From 51d3a734e85dbc6aae590e31157bc2934e81165d Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 5 Jun 2026 22:35:28 +0200 Subject: [PATCH] award --- app.go | 366 ++++++++++++- frontend/src/App.tsx | 19 + frontend/src/components/AwardEditor.tsx | 518 +++++++++++++++--- frontend/src/components/AwardRefPicker.tsx | 85 +++ frontend/src/components/AwardRefSelector.tsx | 219 ++++++++ frontend/src/components/AwardsPanel.tsx | 20 +- frontend/src/components/DetailsPanel.tsx | 16 +- frontend/src/components/QSOEditModal.tsx | 86 ++- frontend/src/lib/awardRefs.ts | 98 ++++ frontend/wailsjs/go/main/App.d.ts | 29 + frontend/wailsjs/go/main/App.js | 56 ++ frontend/wailsjs/go/models.ts | 171 ++++++ internal/award/award.go | 378 +++++++++++-- internal/award/award_test.go | 36 +- internal/awardref/awardref.go | 282 ++++++++++ internal/awardref/builtin.go | 99 ++++ internal/awardref/importers.go | 161 ++++++ internal/awardref/presets.go | 77 +++ .../db/migrations/0017_award_references.sql | 15 + .../migrations/0018_award_references_rich.sql | 13 + internal/dxcc/adif_numbers.go | 22 +- 21 files changed, 2613 insertions(+), 153 deletions(-) create mode 100644 frontend/src/components/AwardRefPicker.tsx create mode 100644 frontend/src/components/AwardRefSelector.tsx create mode 100644 frontend/src/lib/awardRefs.ts create mode 100644 internal/awardref/awardref.go create mode 100644 internal/awardref/builtin.go create mode 100644 internal/awardref/importers.go create mode 100644 internal/awardref/presets.go create mode 100644 internal/db/migrations/0017_award_references.sql create mode 100644 internal/db/migrations/0018_award_references_rich.sql diff --git a/app.go b/app.go index 7476dd3..258d864 100644 --- a/app.go +++ b/app.go @@ -22,6 +22,7 @@ import ( "hamlog/internal/cat" "hamlog/internal/clublog" "hamlog/internal/award" + "hamlog/internal/awardref" "hamlog/internal/cluster" "hamlog/internal/pota" "hamlog/internal/db" @@ -91,7 +92,9 @@ const ( keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent keyAudioMicGain = "audio.mic_gain" // mic mix level, percent - keyAwardDefs = "awards.defs" // JSON array of award definitions (editable) + keyAwardDefs = "awards.defs" // JSON array of award definitions (editable) + keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp + keyAwardRefsSeeded = "awards.refs.seeded" // "1" once built-in lists were seeded keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions @@ -329,6 +332,7 @@ type App struct { dxcc *dxcc.Manager cluster *cluster.Manager pota *pota.Cache + awardRefs *awardref.Repo operating *operating.Repo udp *udp.Manager udpRepo *udp.Repo @@ -515,6 +519,8 @@ func (a *App) startup(ctx context.Context) { a.qso = qso.NewRepo(conn) a.settings = settings.NewStore(conn) a.profiles = profile.NewRepo(conn) + a.awardRefs = awardref.NewRepo(conn) + a.seedBuiltinReferences() // first-run: populate built-in award reference lists a.operating = operating.NewRepo(conn) a.udpRepo = udp.NewRepo(conn) a.udp = udp.NewManager(a.udpRepo) @@ -1114,6 +1120,12 @@ func (a *App) DXCCForCountry(name string) int { return dxcc.EntityDXCC(name) } +// DXCCName returns a display name for a DXCC entity number (or "" if unknown). +// Used by the award editor to label the DXCC-filter chips. +func (a *App) DXCCName(n int) string { + return dxcc.NameForDXCC(n) +} + // ComputeStationInfo resolves a station's structured metadata from the // callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The // frontend calls this whenever Callsign or Grid changes in the Station @@ -1367,7 +1379,357 @@ func (a *App) GetAwards() ([]award.Result, error) { } return "" } - return award.Compute(a.awardDefs(), all, nameOf), nil + defs := a.awardDefs() + refMetas := a.awardRefMetas(defs) + results := award.Compute(defs, all, refMetas, nameOf) + // Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their + // list can be huge. Enrich them after the fact: real Total from the stored + // count, and reference names for the worked references only. + if a.awardRefs != nil { + counts, _ := a.awardRefs.Counts(a.ctx) + for i := range results { + r := &results[i] + if _, predef := refMetas[strings.ToUpper(r.Code)]; predef { + continue // predefined awards are already complete (totals + names) + } + if total := counts[strings.ToUpper(r.Code)]; total > 0 { + r.Total = total + } + codes := make([]string, 0, len(r.Refs)) + for _, rf := range r.Refs { + if rf.Name == "" { + codes = append(codes, rf.Ref) + } + } + if len(codes) == 0 { + continue + } + if names, err := a.awardRefs.NamesFor(a.ctx, r.Code, codes); err == nil { + for j := range r.Refs { + if r.Refs[j].Name == "" { + r.Refs[j].Name = names[strings.ToUpper(r.Refs[j].Ref)] + } + } + } + } + } + return results, nil +} + +// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false) +// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists +// are large and not needed for matching; their names are filled afterwards. +func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta { + out := map[string][]award.RefMeta{} + if a.awardRefs == nil { + return out + } + for _, d := range defs { + if d.Dynamic { + continue + } + code := strings.ToUpper(d.Code) + refs, err := a.awardRefs.List(a.ctx, code) + if err != nil || len(refs) == 0 { + continue + } + metas := make([]award.RefMeta, 0, len(refs)) + for _, rf := range refs { + dxccList := rf.DXCCList + if len(dxccList) == 0 && rf.DXCC > 0 { + dxccList = []int{rf.DXCC} + } + metas = append(metas, award.RefMeta{ + Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp, + DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid, + }) + } + out[code] = metas + } + return out +} + +// QSOAwardRef is one award reference a single QSO contributes to. Pickable +// marks awards backed by a reference list (POTA, SOTA, …) — those are assigned +// manually; the rest (DXCC, WAZ, WPX, DDFM, …) are computed from QSO fields. +type QSOAwardRef struct { + Code string `json:"code"` + Ref string `json:"ref"` + Name string `json:"name,omitempty"` + Pickable bool `json:"pickable"` +} + +// ComputeQSOAwardRefs returns every award reference a single QSO contributes to +// — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for +// the per-QSO Award Refs editor. Reuses the same engine as GetAwards. +func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) { + nameOf := func(field, ref string) string { + switch field { + case "dxcc": + if n, err := strconv.Atoi(ref); err == nil { + return dxcc.NameForDXCC(n) + } + case "cont": + return continentName(ref) + } + return "" + } + defs := a.awardDefs() + results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf) + var counts map[string]int + if a.awardRefs != nil { + counts, _ = a.awardRefs.Counts(a.ctx) + } + var out []QSOAwardRef + for i := range results { + r := &results[i] + pickable := counts[strings.ToUpper(r.Code)] > 0 || awardref.CanUpdate(r.Code) + for _, rf := range r.Refs { + if !rf.Worked { + continue // a single QSO only contributes worked references + } + out = append(out, QSOAwardRef{Code: r.Code, Ref: rf.Ref, Name: rf.Name, Pickable: pickable}) + } + } + return out, nil +} + +// AwardRefMeta describes a reference list's state for the UI. +type AwardRefMeta struct { + Code string `json:"code"` + Count int `json:"count"` + UpdatedAt string `json:"updated_at"` + CanUpdate bool `json:"can_update"` +} + +// GetAwardReferenceMeta returns the reference-list status for every defined +// award (count + last update + whether an online updater exists). +func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) { + if a.awardRefs == nil { + return nil, fmt.Errorf("db not initialized") + } + counts, err := a.awardRefs.Counts(a.ctx) + if err != nil { + return nil, err + } + var out []AwardRefMeta + for _, d := range a.awardDefs() { + code := strings.ToUpper(d.Code) + updated := "" + if a.settings != nil { + updated, _ = a.settings.Get(a.ctx, keyAwardRefsUpdated+code) + } + out = append(out, AwardRefMeta{ + Code: d.Code, + Count: counts[code], + UpdatedAt: updated, + CanUpdate: awardref.CanUpdate(d.Code), + }) + } + return out, nil +} + +// UpdateAwardReferenceList downloads the latest reference list for an award and +// replaces the stored set. Returns the new reference count. +func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) { + if a.awardRefs == nil { + return AwardRefMeta{}, fmt.Errorf("db not initialized") + } + if !awardref.CanUpdate(code) { + return AwardRefMeta{}, fmt.Errorf("no online reference list for %q", code) + } + refs, err := awardref.Download(a.ctx, code) + if err != nil { + return AwardRefMeta{}, err + } + n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs) + if err != nil { + return AwardRefMeta{}, err + } + now := time.Now().Format("2006-01-02 15:04") + if a.settings != nil { + _ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), now) + } + applog.Printf("award-refs: %s updated — %d references", strings.ToUpper(code), n) + return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil +} + +// SearchAwardReferences finds references of an award by code/name (for the +// per-QSO reference picker). dxcc>0 restricts to one entity. +func (a *App) SearchAwardReferences(code, query string, dxcc, limit int) ([]awardref.Ref, error) { + if a.awardRefs == nil { + return nil, fmt.Errorf("db not initialized") + } + return a.awardRefs.Search(a.ctx, code, query, dxcc, limit) +} + +// ListAwardReferences returns every reference of an award (for the editor). +func (a *App) ListAwardReferences(code string) ([]awardref.Ref, error) { + if a.awardRefs == nil { + return nil, fmt.Errorf("db not initialized") + } + return a.awardRefs.List(a.ctx, code) +} + +// SaveAwardReference inserts or updates a single reference. +func (a *App) SaveAwardReference(code string, ref awardref.Ref) error { + if a.awardRefs == nil { + return fmt.Errorf("db not initialized") + } + return a.awardRefs.Upsert(a.ctx, code, ref) +} + +// DeleteAwardReference removes one reference from an award. +func (a *App) DeleteAwardReference(code, refCode string) error { + if a.awardRefs == nil { + return fmt.Errorf("db not initialized") + } + return a.awardRefs.Delete(a.ctx, code, refCode) +} + +// ReplaceAwardReferences atomically replaces an award's whole reference list +// (used by paste / CSV import and presets). Returns the new count. +func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, error) { + if a.awardRefs == nil { + return 0, fmt.Errorf("db not initialized") + } + n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs) + if err != nil { + return 0, err + } + if a.settings != nil { + _ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04")) + } + return n, nil +} + +// GetAwardPresets returns the catalogue of built-in reference lists. +func (a *App) GetAwardPresets() []awardref.Preset { return awardref.Presets() } + +// ApplyAwardPreset replaces an award's reference list with a built-in preset. +// Returns the new reference count. +func (a *App) ApplyAwardPreset(code, presetKey string) (int, error) { + p, ok := awardref.PresetByKey(presetKey) + if !ok { + return 0, fmt.Errorf("unknown preset %q", presetKey) + } + return a.ReplaceAwardReferences(code, p.Refs) +} + +// PopulateBuiltinReferences seeds an award's reference list from the built-in +// data (DXCC entities, CQ zones, continents, US states, French departments). +// Returns the new count; ok=false awards (online / custom) yield an error. +func (a *App) PopulateBuiltinReferences(code string) (int, error) { + refs, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code))) + if !ok { + return 0, fmt.Errorf("no built-in reference list for %q", code) + } + return a.ReplaceAwardReferences(code, refs) +} + +// HasBuiltinReferences reports whether an award code ships a built-in list. +func (a *App) HasBuiltinReferences(code string) bool { + _, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code))) + return ok +} + +// seedBuiltinReferences populates the reference lists of built-in awards on +// first run (idempotent: only seeds an award that currently has none, and only +// once overall, tracked by a settings flag so a user who clears a list is not +// overruled on the next launch). +func (a *App) seedBuiltinReferences() { + if a.awardRefs == nil || a.settings == nil { + return + } + if done, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded); done == "1" { + return + } + counts, err := a.awardRefs.Counts(a.ctx) + if err != nil { + return + } + for _, d := range a.awardDefs() { + code := strings.ToUpper(d.Code) + if counts[code] > 0 { + continue + } + if refs, ok := awardref.BuiltinRefs(code); ok { + if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil { + applog.Printf("award-refs: seeded %s — %d references", code, n) + } + } + } + _ = a.settings.Set(a.ctx, keyAwardRefsSeeded, "1") +} + +// ImportAwardReferencesText parses pasted lines or CSV into references and +// replaces the award's list. Accepted per line (comma/semicolon/tab separated): +// +// CODE +// CODE,Description +// CODE,Description,Group +// CODE,Description,Group,Subgroup +// CODE,Description,Group,Subgroup,DXCC +// +// A leading header row (first field "code"/"ref"/"reference") is skipped. +func (a *App) ImportAwardReferencesText(code, text string) (int, error) { + refs := parseRefLines(text) + if len(refs) == 0 { + return 0, fmt.Errorf("no references found in input") + } + return a.ReplaceAwardReferences(code, refs) +} + +// parseRefLines turns pasted/CSV text into references (best-effort, tolerant of +// comma, semicolon or tab delimiters). +func parseRefLines(text string) []awardref.Ref { + var out []awardref.Ref + for i, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(strings.TrimRight(raw, "\r")) + if line == "" { + continue + } + var fields []string + switch { + case strings.Contains(line, "\t"): + fields = strings.Split(line, "\t") + case strings.Contains(line, ";"): + fields = strings.Split(line, ";") + default: + fields = strings.Split(line, ",") + } + for j := range fields { + fields[j] = strings.TrimSpace(fields[j]) + } + code := strings.ToUpper(fields[0]) + if code == "" { + continue + } + // Skip a header row. + if i == 0 { + switch strings.ToLower(fields[0]) { + case "code", "ref", "reference", "ref_code": + continue + } + } + ref := awardref.Ref{Code: code, Valid: true} + if len(fields) > 1 { + ref.Name = fields[1] + } + if len(fields) > 2 { + ref.Group = fields[2] + } + if len(fields) > 3 { + ref.SubGrp = fields[3] + } + if len(fields) > 4 { + if n, err := strconv.Atoi(fields[4]); err == nil { + ref.DXCC = n + } + } + out = append(out, ref) + } + return out } func continentName(code string) string { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ef4ffc..4d2ee9a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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>({}); + useEffect(() => { + GetAwardDefs() + .then((defs) => { + const m: Record = {}; + 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: '', })); } diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index d2c8382..69b6b24 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -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 ( +
+ {all.map((v) => { + const on = value.includes(v); + return ( + + ); + })} +
+ ); +} + +function Field2({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +// 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>({}); + useEffect(() => { + let live = true; + (async () => { + const miss = value.filter((n) => names[n] === undefined); + if (miss.length === 0) return; + const got: Record = {}; + 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 ( +
+ + {value.length > 0 && ( +
+ {value.map((n) => ( + + #{n} + {names[n] || '…'} + + + ))} +
+ )} +
+ ); } export function AwardEditor({ open, onClose, onSaved }: Props) { const [defs, setDefs] = useState([]); const [fields, setFields] = useState([]); + const [meta, setMeta] = useState>({}); + const [presets, setPresets] = useState([]); + const [countries, setCountries] = useState([]); + const [sel, setSel] = useState(0); + const [search, setSearch] = useState(''); + const [updating, setUpdating] = useState(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) => 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) => 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 ( { if (!o) onClose(); }}> - - - Edit awards + + + Award management -
-

- Each award scans one QSO field. Leave pattern empty to use the whole field value, - or enter a regular expression where group 1 is the reference — e.g. scan - the note field with {'D(\\d{1,2}[AB]?)'} so - "D74" counts department 74. -

- {err &&
{err}
} - -
- {defs.map((d, i) => ( -
-
- patch(i, { code: e.target.value })} placeholder="CODE" /> - patch(i, { name: e.target.value })} placeholder="Award name" /> - {d.builtin && built-in} - -
-
- - - - patch(i, { pattern: e.target.value })} placeholder="(optional regex, group 1 = ref)" /> - - - 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)" /> - - patch(i, { total: parseInt(e.target.value, 10) || 0 })} placeholder="0 = unknown" /> - - -
- {CONFIRM_SRC.map((c) => ( - - ))} -
-
+
+ {/* Left: award list */} +
+
+
+ + setSearch(e.target.value)} />
- ))} +
+
+ {filtered.map(({ d, i }) => ( + + ))} +
+
- + {/* Right: tabbed editor for selected award */} +
+ {err &&
{err}
} + {!cur ? ( +
Select or create an award.
+ ) : ( + + + Award info + Award type + Confirmation + References + + +
+ {/* ── Award info ── */} + +
+ patch({ code: e.target.value })} placeholder="CODE" /> + patch({ name: e.target.value })} placeholder="Award name" /> + + +
+ patch({ description: e.target.value })} /> + patch({ url: e.target.value })} /> + patch({ ref_url: e.target.value })} placeholder="https://…/" /> +
+ patch({ valid_from: e.target.value })} /> + patch({ valid_to: e.target.value })} /> +
+
+ + patch({ dxcc_filter: v })} countries={countries} /> +
+
toggleIn('valid_bands', v)} />
+
toggleIn('emission', v)} />
+
toggleIn('valid_modes', v)} />
+
+ + {/* ── Award type ── */} + + + + + + + +
+

QSO parameters (used by QSOFIELDS / REFERENCE types)

+ + + + +
+ {['code', 'description', 'pattern'].map((m) => ( + + ))} +
+
+ + patch({ pattern: e.target.value })} placeholder="group 1 = reference (for match-by pattern / dynamic)" /> +
+ patch({ leading_str: e.target.value })} /> + patch({ trailing_str: e.target.value })} /> +
+
+
+ + {/* ── Confirmation ── */} + +
+
+ + {CONFIRM_SRC.map((c) => ( + + ))} +
+
+ + {CONFIRM_SRC.map((c) => ( + + ))} +
+
+ patch({ grant_codes: e.target.value })} /> + +
+ + {/* ── References ── */} + + updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()} + onChanged={loadMeta} setErr={setErr} + /> + +
+
+ )} +
- +
@@ -138,3 +342,145 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
); } + +// 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([]); + const [q, setQ] = useState(''); + const [selCode, setSelCode] = useState(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) => 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 ( +
+ {/* Toolbar */} +
+ Reference count: {refs.length} +
+ + + {hasBuiltin && ( + + )} + {meta?.can_update && ( + + )} + +
+ + {showBulk && ( +
+

One reference per line: CODE,Description,Group,Subgroup,DXCC (comma/semicolon/tab). Replaces the whole list.

+