fix(qsl): separate the QSL manager from the routing method (#16)

QSL_VIA is the manager. QSL_SENT_VIA and QSL_RCVD_VIA are the ADIF "QSL Via"
enumeration — B bureau, D direct, E electronic, M manager (import-only) —
and say how a card travelled. OpsLog had one column for all three:

  - the import folded QSL_SENT_VIA into QSL_VIA whenever QSL_VIA was empty,
    which is exactly a Log4OM export (it defaults QSL_SENT_VIA to E), so
    OE6CLD saw "E" everywhere OpsLog shows the manager;
  - QSL_RCVD_VIA was listed in adifPromoted with no column behind it, so it
    was not stored, not kept among the extras, and not exported — dropped
    outright on import;
  - neither was ever written on export, so an import followed by an export
    destroyed both;
  - and OpsLog polluted the field itself: the QSL Manager panel wrote
    "Bureau" / "Direct" / "Electronic", in full words, into QSL_VIA.

Two columns added (migration 0027), carried through the five places a
promoted ADIF field has to touch, with round-trip tests pinning the reported
case. The QSL panel now offers Bureau / Direct / Electronic for each
direction and stores the enumeration; the manager field is labelled as the
manager and holds only that. M is kept when a file gives it and never
written back out.

Existing logs hold a mixture of the two in one column. The repair is offered,
not performed: the count is shown once per log with a plain question, and a
"no" is remembered. It moves only where QSL_SENT_VIA is still empty, and only
values that normalise to the enumeration — a manager is a callsign and can
never be one of those six words, which a test pins against real manager calls.
This commit is contained in:
2026-08-14 11:50:25 +02:00
parent 30143b01bf
commit 4e88bdfaa7
20 changed files with 542 additions and 35 deletions
+36
View File
@@ -52,6 +52,7 @@ import {
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -1793,6 +1794,20 @@ export default function App() {
// close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false);
// How many QSOs hold a QSL routing word where the manager belongs — null when
// there is nothing to offer, or the operator has already answered. Asked once
// per log, a moment after startup so it does not race the first paint.
const [qslViaRepair, setQslViaRepair] = useState<number | null>(null);
useEffect(() => {
let alive = true;
const id = window.setTimeout(async () => {
try {
const s: any = await QSLViaRepairStatus();
if (alive && !s?.asked && (s?.affected ?? 0) > 0) setQslViaRepair(s.affected);
} catch { /* a log we cannot query yet will be offered next start */ }
}, 4000);
return () => { alive = false; window.clearTimeout(id); };
}, []);
const [showAbout, setShowAbout] = useState(false);
// "What's new": the changelog for the version(s) since the operator last ran,
// shown once on the first launch after an update (EN/FR per the UI language).
@@ -7275,6 +7290,27 @@ export default function App() {
/>
);
})()}
{/* One-time offer to move QSL routing words out of the manager field.
Shown with a count and never acted on without an answer: it rewrites
what the operator sees in their own log. Either answer is final
asking again after a "no" would be a nag. */}
{qslViaRepair !== null && (
<ConfirmDialog
title={t('qslvia.title')}
message={t('qslvia.body', { n: qslViaRepair.toLocaleString() })}
confirmLabel={t('qslvia.confirm')}
cancelLabel={t('qslvia.cancel')}
onConfirm={async () => {
const n = qslViaRepair;
setQslViaRepair(null);
try {
const r: any = await RepairQSLVia();
showToast(t('qslvia.done', { n: (r?.moved ?? n).toLocaleString() }));
} catch { /* the log keeps the reason; nothing to undo */ }
}}
onCancel={() => { setQslViaRepair(null); DismissQSLViaRepair().catch(() => {}); }}
/>
)}
{showDeleteAll && (
<ConfirmDialog
title="Delete ALL QSOs?"
@@ -30,6 +30,8 @@ const FIELDS: FieldDef[] = [
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
{ id: 'qsl_sent_via', label: 'bulk.fQslSentVia', group: 'QSL / upload', kind: 'text' },
{ id: 'qsl_rcvd_via', label: 'bulk.fQslRcvdVia', group: 'QSL / upload', kind: 'text' },
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
@@ -68,6 +68,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
{ value: 'qsl_sent_via', label: 'fltb.fQslSentVia', type: 'text' },
{ value: 'qsl_rcvd_via', label: 'fltb.fQslRcvdVia', type: 'text' },
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
+3 -3
View File
@@ -57,9 +57,9 @@ const QSL_STATUSES = [
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [
{ v: '_', label: 'qslm.leave' },
{ v: 'Bureau', label: 'qslm.viaBureau' },
{ v: 'Direct', label: 'qslm.viaDirect' },
{ v: 'Electronic', label: 'qslm.viaElectronic' },
{ v: 'B', label: 'qslm.viaBureau' },
{ v: 'D', label: 'qslm.viaDirect' },
{ v: 'E', label: 'qslm.viaElectronic' },
];
// Maps a service value → its i18n label key (only for services with
+40 -5
View File
@@ -57,10 +57,12 @@ type ConfDef = {
key: string; label: string;
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
via?: keyof QSOForm;
// How the card travelled, each way (ADIF QSL_SENT_VIA / QSL_RCVD_VIA). Paper
// only — the electronic channels below ARE the route.
sentVia?: keyof QSOForm; rcvdVia?: keyof QSOForm;
};
const CONFIRMATIONS: ConfDef[] = [
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', sentVia: 'qsl_sent_via', rcvdVia: 'qsl_rcvd_via' },
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
@@ -216,6 +218,28 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
);
}
// The ADIF QSL Via enumeration. M (manager) is import-only in the standard, so
// it is not offered here — a file that arrives carrying it keeps it, and this
// list is what an operator may choose.
const QSL_VIA_CHOICES = [
{ value: '_', label: 'qedit.qslDash' },
{ value: 'B', label: 'qedit.viaBureau' },
{ value: 'D', label: 'qedit.viaDirect' },
{ value: 'E', label: 'qedit.viaElectronic' },
];
function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n();
return (
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{QSL_VIA_CHOICES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
</SelectContent>
</Select>
);
}
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
const { t } = useI18n();
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
@@ -633,7 +657,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Label>{t('qedit.qslMsg')}</Label>
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
</div>
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
{/* The manager, and only the manager — ADIF QSL_VIA. How the
card travelled is a separate field, on the QSL Info tab. */}
<div>
<Label>{t('qedit.qslVia')}</Label>
<Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} placeholder={t('qedit.qslViaPlaceholder')} />
</div>
</div>
</div>
</TabsContent>
@@ -734,8 +763,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && (
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
{/* How the card travelled, each way — ADIF's QSL Via
enumeration. Not the manager: that is a callsign
and lives on the Contact's details tab. */}
{def.sentVia && (
<div><Label>{t('qedit.sentVia')}</Label><QslViaSelect value={val(def.sentVia)} onChange={(v) => put(def.sentVia, v)} /></div>
)}
{def.rcvdVia && (
<div><Label>{t('qedit.rcvdVia')}</Label><QslViaSelect value={val(def.rcvdVia)} onChange={(v) => put(def.rcvdVia, v)} /></div>
)}
</div>
<p className="text-[11px] text-muted-foreground">
@@ -186,6 +186,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
{ group: 'QSL', label: t('rqg.c.qsl_sent_via'), colId: 'qsl_sent_via', headerName: t('rqg.h.qsl_sent_via'), field: 'qsl_sent_via' as any, width: 110 },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_via'), colId: 'qsl_rcvd_via', headerName: t('rqg.h.qsl_rcvd_via'), field: 'qsl_rcvd_via' as any, width: 110 },
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
File diff suppressed because one or more lines are too long