feat(awards): settle each RDA disagreement on its own row

The comparison could show two hundred contacts where the log and the
database name different districts, and leave the operator with nothing to
do about any of them but open each QSO and edit it by hand.

Now each row carries the two values as buttons: click the district you
keep, then apply. There are shortcuts for keeping one source everywhere,
and applying is a separate act — clicking through two hundred rows with
each one written as it is clicked would make a slip permanent before the
reading was finished.

The choice is written as the contact's AWARD REFERENCE, not over the
imported CNTY, and that distinction is the point. CNTY is what the
downloaded log said; overwriting it would destroy the evidence the
comparison runs on, and the next comparison would agree with itself and
prove nothing. The override is the operator's decision, it is what the
award engine counts, and it beats both sources. So the log keeps saying
what it said, the database keeps saying what it says, and the contact
counts for the district that was chosen.

The buttons show the districts themselves rather than a tick box: someone
arbitrating between RO-19 and KO-05 should be picking a value they can
read, not remembering which source a checked box stood for.
This commit is contained in:
2026-08-26 10:22:23 +02:00
parent a621a3ee62
commit fd939797dd
7 changed files with 189 additions and 5 deletions
+78 -1
View File
@@ -13,7 +13,7 @@ import {
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetPSUSettings, SavePSUSettings,
@@ -1946,6 +1946,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// nobody had pressed, and the one that had been pressed showed nothing at
// all, which reads as "the button does nothing".
const [rdaCmpMsg, setRdaCmpMsg] = useState<string>('');
// Which source the operator kept, per QSO id. By id and not by row: a second
// comparison rebuilds the list, and a choice that followed a row number would
// then be applied to somebody else's contact.
const [rdaPick, setRdaPick] = useState<Record<number, 'log' | 'db'>>({});
const [rdaApplyBusy, setRdaApplyBusy] = useState(false);
const runRDACompare = async () => {
setRdaCmpBusy(true); setRdaCmp(null); setRdaCmpMsg('');
try {
@@ -7517,6 +7522,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromLog')}</th>
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromDb')}</th>
<th className="py-1 pr-2 font-medium">{t('rda.cmpKind')}</th>
<th className="py-1 pr-2 font-medium text-center">{t('rda.cmpKeep')}</th>
</tr>
</thead>
<tbody>
@@ -7541,11 +7547,82 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<td className="py-1 pr-2 text-muted-foreground">
{c.dated ? t('rda.cmpDated') : t('rda.cmpCurrent')}
</td>
{/* Which one wins, on the row that shows the two values.
Two buttons rather than a tick box: a tick box would
have to mean one of the sources silently, and an
operator arbitrating between "RO-19" and "KO-05"
should be picking the value they can read, not
remembering what a checked box stands for. */}
<td className="py-1 pr-2 whitespace-nowrap text-center">
<div className="inline-flex rounded border border-border overflow-hidden">
{(['log', 'db'] as const).map((side) => (
<button
key={side}
type="button"
onClick={() => setRdaPick((p) => {
const next = { ...p };
if (next[c.qso_id] === side) delete next[c.qso_id];
else next[c.qso_id] = side;
return next;
})}
className={`px-2 py-0.5 font-mono text-[10px] ${rdaPick[c.qso_id] === side
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'}`}
title={side === 'log' ? t('rda.cmpKeepLog') : t('rda.cmpKeepDb')}
>
{side === 'log' ? c.from_log : c.from_db}
</button>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Applying is a separate, deliberate act. Clicking through two
hundred rows and having each one written as it is clicked would
make a slip permanent before the operator had finished
reading. */}
<div className="flex items-center gap-3 flex-wrap">
<Button size="sm" variant="secondary" disabled={rdaApplyBusy || Object.keys(rdaPick).length === 0}
onClick={async () => {
setRdaApplyBusy(true);
try {
const choices = rdaCmp.conflicts
.filter((c: any) => rdaPick[c.qso_id])
.map((c: any) => ({
qso_id: c.qso_id,
district: rdaPick[c.qso_id] === 'log' ? c.from_log : c.from_db,
}));
const r: any = await ApplyRDAChoices(choices);
setRdaCmpMsg(r?.message || '');
setRdaPick({});
} catch (e: any) {
setRdaCmpMsg(String(e?.message ?? e));
} finally {
setRdaApplyBusy(false);
}
}}>
{rdaApplyBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('rda.cmpApply', { n: Object.keys(rdaPick).length })}
</Button>
<button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
onClick={() => setRdaPick(Object.fromEntries(rdaCmp.conflicts.map((c: any) => [c.qso_id, 'db'])))}>
{t('rda.cmpAllDb')}
</button>
<button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
onClick={() => setRdaPick(Object.fromEntries(rdaCmp.conflicts.map((c: any) => [c.qso_id, 'log'])))}>
{t('rda.cmpAllLog')}
</button>
{Object.keys(rdaPick).length > 0 && (
<button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
onClick={() => setRdaPick({})}>
{t('rda.cmpClear')}
</button>
)}
</div>
<p className="text-[11px] text-muted-foreground">{t('rda.cmpApplyHint')}</p>
</>
)}
</div>