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,
|
||||
|
||||
@@ -156,6 +156,7 @@ const en: Dict = {
|
||||
'uscty.backfillRun': 'Fill missing counties',
|
||||
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the board’s HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
|
||||
'awards.followHint': 'Awards shown in the Awards tab. Leave the right side empty to show them all.', 'awards.available': 'All awards', 'awards.followed': 'Followed', 'awards.search': 'Filter…', 'awards.addAll': 'Add all', 'awards.clear': 'Clear', 'awards.allTracked': 'All awards are followed.', 'awards.noneFollowed': 'Nothing followed yet — the Awards tab shows all.',
|
||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
// CW Keyer settings panel
|
||||
@@ -575,6 +576,7 @@ const fr: Dict = {
|
||||
'uscty.backfillRun': 'Remplir les comtés manquants',
|
||||
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
|
||||
'awards.followHint': 'Diplômes affichés dans l’onglet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour l’instant — l’onglet Awards les montre tous.',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
// Panneau Manipulateur CW
|
||||
|
||||
Vendored
+4
@@ -495,6 +495,8 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetTrackedAwards():Promise<Array<string>>;
|
||||
|
||||
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
|
||||
|
||||
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
|
||||
@@ -897,6 +899,8 @@ export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>
|
||||
|
||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||
|
||||
export function SaveTrackedAwards(arg1:Array<string>):Promise<void>;
|
||||
|
||||
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
|
||||
@@ -938,6 +938,10 @@ export function GetTelemetryEnabled() {
|
||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||
}
|
||||
|
||||
export function GetTrackedAwards() {
|
||||
return window['go']['main']['App']['GetTrackedAwards']();
|
||||
}
|
||||
|
||||
export function GetTunerGeniusSettings() {
|
||||
return window['go']['main']['App']['GetTunerGeniusSettings']();
|
||||
}
|
||||
@@ -1742,6 +1746,10 @@ export function SaveStationSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveTrackedAwards(arg1) {
|
||||
return window['go']['main']['App']['SaveTrackedAwards'](arg1);
|
||||
}
|
||||
|
||||
export function SaveTunerGeniusSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user