feat(awards): follow-list picker in Settings → Awards, filter the Awards tab

New per-profile "tracked awards" selection: Settings → Awards (under User
configuration) is a two-column transfer list — every defined award on the left,
the ones you follow on the right, click to move either way. The Awards tab's
list is narrowed to the followed set; an empty set means "show them all" so the
tab is never blank.

Backend: app_awards_tracked.go adds keyAwardsTracked (per-profile JSON array of
award codes) with GetTrackedAwards/SaveTrackedAwards; saving emits
awards:tracked-changed so the Awards tab re-filters live. Award definitions stay
global — only the follow selection is per profile.
This commit is contained in:
2026-08-06 17:29:00 +02:00
parent 01dcd91253
commit faf084ddfc
7 changed files with 177 additions and 8 deletions
+18 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -137,13 +138,20 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
}
}
// Load the award list (no QSO scan), then compute only the first award.
// Load the award list (no QSO scan), then compute only the first award. The
// list is narrowed to the awards the operator follows (Settings → Awards); an
// empty follow-set means "show them all" so the tab is never blank.
async function loadList() {
try {
const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs
const [defs, tracked] = await Promise.all([
GetAwardDefs().then((d) => (d ?? []) as any[]),
GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
]);
const follow = new Set(tracked);
let list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
.sort((a, b) => a.code.localeCompare(b.code));
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
if (first) compute(first.code);
@@ -152,6 +160,12 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
}
}
useEffect(() => { loadList(); }, []);
// Re-filter when the operator changes their followed awards in Settings.
useEffect(() => {
const off = EventsOn('awards:tracked-changed', () => { loadList(); });
return () => { off(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const current = byCode[`${selected}|${modeFilter}`];
// Recompute when the mode class changes: the bands, counts and confirmations
+84 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
User, Database, Radio, Cog, Server, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
} from 'lucide-react';
import {
@@ -50,6 +50,7 @@ import {
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -254,6 +255,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.profiles'), id: 'profiles' },
{ kind: 'item', label: t('sec.operating'), id: 'operating' },
{ kind: 'item', label: t('sec.confirmations'), id: 'confirmations' },
{ kind: 'item', label: t('sec.awards'), id: 'awards' },
{ kind: 'item', label: t('sec.external'), id: 'external-services' },
],
},
@@ -944,6 +946,86 @@ function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }
);
}
// AwardsSelectionPanel is a two-column transfer list: every defined award on the
// left, the ones the operator follows on the right. The Awards tab shows only the
// followed set (empty = all). Per-profile, saved immediately (local SQLite).
function AwardsSelectionPanel({ profile }: { profile?: { name?: string; callsign?: string } }) {
const { t } = useI18n();
const [all, setAll] = useState<{ code: string; name: string }[]>([]);
const [tracked, setTracked] = useState<string[]>([]);
const [err, setErr] = useState('');
const [q, setQ] = useState('');
useEffect(() => {
(async () => {
try {
const [defs, tr] = await Promise.all([
GetAwardDefs().then((d) => (d ?? []) as any[]),
GetTrackedAwards().then((v) => (v ?? []) as string[]).catch(() => [] as string[]),
]);
setAll(defs.map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code)));
setTracked(tr);
} catch (e: any) { setErr(String(e?.message ?? e)); }
})();
}, []);
async function persist(next: string[]) {
setTracked(next);
try { await SaveTrackedAwards(next); } catch (e: any) { setErr(String(e?.message ?? e)); }
}
const trackedSet = new Set(tracked);
const byCode = new Map(all.map((a) => [a.code, a] as const));
const needle = q.trim().toLowerCase();
const available = all.filter((a) => !trackedSet.has(a.code)
&& (needle === '' || `${a.code} ${a.name}`.toLowerCase().includes(needle)));
const trackedItems = tracked.map((c) => byCode.get(c)).filter(Boolean) as { code: string; name: string }[];
const Row = ({ a, arrow, onClick }: { a: { code: string; name: string }; arrow: 'right' | 'left'; onClick: () => void }) => (
<button type="button" onClick={onClick}
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
{arrow === 'left' && <span className="text-muted-foreground opacity-0 group-hover:opacity-100"></span>}
<span className="font-mono text-xs shrink-0">{a.code}</span>
<span className="text-xs text-muted-foreground truncate flex-1">{a.name}</span>
{arrow === 'right' && <span className="text-primary opacity-0 group-hover:opacity-100"></span>}
</button>
);
return (
<div>
<SectionHeader title={t('sec.awards')} hint={t('awards.followHint')} />
<ProfileScopeNote profile={profile} />
{err && <div className="mb-2 text-xs text-destructive">{err}</div>}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-border bg-card/40 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
<span className="text-sm font-medium">{t('awards.available')} <span className="text-muted-foreground">({available.length})</span></span>
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
disabled={available.length === 0} onClick={() => persist(all.map((a) => a.code))}>{t('awards.addAll')}</button>
</div>
<div className="p-2 border-b border-border/60">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('awards.search')} className="h-8" />
</div>
<div className="max-h-[340px] overflow-y-auto p-1.5 space-y-0.5">
{available.map((a) => <Row key={a.code} a={a} arrow="right" onClick={() => persist([...tracked, a.code])} />)}
{available.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('awards.allTracked')}</div>}
</div>
</div>
<div className="rounded-lg border border-primary/40 bg-primary/5 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
<span className="text-sm font-medium">{t('awards.followed')} <span className="text-muted-foreground">({tracked.length})</span></span>
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
disabled={tracked.length === 0} onClick={() => persist([])}>{t('awards.clear')}</button>
</div>
<div className="max-h-[392px] overflow-y-auto p-1.5 space-y-0.5">
{trackedItems.map((a) => <Row key={a.code} a={a} arrow="left" onClick={() => persist(tracked.filter((c) => c !== a.code))} />)}
{tracked.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('awards.noneFollowed')}</div>}
</div>
</div>
</div>
</div>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction;
@@ -5822,7 +5904,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
database: DatabasePanel,
uscounties: USCountiesPanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />,
awards: () => <AwardsSelectionPanel profile={activeProfile ?? undefined} />,
cat: CATPanel,
rotator: RotatorPanel,
winkeyer: WinkeyerPanel,