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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user