feat(qsl): set the QSL route per direction from the QSL Manager

The paper form had one "Via", which since the QSL_VIA split writes
QSL_SENT_VIA. QSL_RCVD_VIA had no way in at all outside the per-QSO editor —
a field with a column, an import and an export, and nothing to fill it.

Each direction now has its own route dropdown, sitting with the status and
date it belongs to: how the card was sent, beside Sent; how it came back,
beside Received. Both apply to the whole selection, which is the shape of the
job — a stack confirmed in one go usually arrived the same way, a bureau
delivery being exactly that, and doing it one QSO at a time in the editor was
the only option before.

Both store the ADIF enumeration (B/D/E), and neither touches QSL_VIA — that
holds the manager's callsign and belongs to the QSO, not to a stack of cards
being confirmed together.

The two columns were already available in the paper list: it renders through
RecentQSOsGrid, which gained them with the field itself.
This commit is contained in:
2026-08-14 16:32:12 +02:00
parent 2cd1274975
commit 7470ebf47e
5 changed files with 48 additions and 24 deletions
+12 -1
View File
@@ -486,6 +486,7 @@ var defaultBands = []string{
"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m",
"12m", "10m", "6m", "2m", "70cm", "23cm", "12m", "10m", "6m", "2m", "70cm", "23cm",
} }
// defaultModes is what a fresh install starts with — the modes most operators // defaultModes is what a fresh install starts with — the modes most operators
// actually use, in the order they are offered. AM and DIGITALVOICE are NOT here // actually use, in the order they are offered. AM and DIGITALVOICE are NOT here
// on purpose: they stay in the Available catalogue for whoever wants them, but // on purpose: they stay in the Available catalogue for whoever wants them, but
@@ -6217,7 +6218,11 @@ type QSLBulkUpdate struct {
RcvdStatus string `json:"rcvd_status"` // Y|N|R|I — "" = unchanged RcvdStatus string `json:"rcvd_status"` // Y|N|R|I — "" = unchanged
SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged
RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged
Via string `json:"via"` // QSL_VIA — "" = unchanged // Via and RcvdVia are the ADIF QSL Via enumeration (B bureau / D direct /
// E electronic), NOT the manager — that is QSL_VIA and belongs on the QSO,
// not on a stack of cards being confirmed together. "" = unchanged.
Via string `json:"via"` // → QSL_SENT_VIA
RcvdVia string `json:"rcvd_via"` // → QSL_RCVD_VIA
Notes string `json:"notes"` // NOTES — "" = unchanged Notes string `json:"notes"` // NOTES — "" = unchanged
Comment string `json:"comment"` // COMMENT — "" = unchanged Comment string `json:"comment"` // COMMENT — "" = unchanged
} }
@@ -6256,6 +6261,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if v := adif.NormaliseQSLVia(u.Via); v != "" { if v := adif.NormaliseQSLVia(u.Via); v != "" {
q.QSLSentVia, changed = v, true q.QSLSentVia, changed = v, true
} }
// How the card CAME BACK. A stack confirmed together usually arrived the
// same way — a bureau delivery is exactly that — so it belongs here
// beside the received date rather than one QSO at a time in the editor.
if v := adif.NormaliseQSLVia(u.RcvdVia); v != "" {
q.QSLRcvdVia, changed = v, true
}
if v := strings.TrimSpace(u.Notes); v != "" { if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true q.Notes, changed = v, true
} }
+4 -2
View File
@@ -8,7 +8,8 @@
"Chase new: a panel listing the stations PSK Reporter is hearing within about 300 km of you that are new against your log — on FT8, FT4, FT2, PSK31 and RTTY, and only on the bands you have selected. One badge per row, the most valuable first (entity, band, mode, slot, prefix, square), with a filter for each, a toolbar button to show it and a cross to close it. Click a row to put the callsign in the entry and tune the rig.", "Chase new: a panel listing the stations PSK Reporter is hearing within about 300 km of you that are new against your log — on FT8, FT4, FT2, PSK31 and RTTY, and only on the bands you have selected. One badge per row, the most valuable first (entity, band, mode, slot, prefix, square), with a filter for each, a toolbar button to show it and a cross to close it. Click a row to put the callsign in the entry and tune the rig.",
"Serial ports: a port claimed by two devices in the Windows port map was listed twice in every port dropdown, and showed as “COM3COM3”. Listed once now, and in natural order — COM4 between COM3 and COM8, not after COM9.", "Serial ports: a port claimed by two devices in the Windows port map was listed twice in every port dropdown, and showed as “COM3COM3”. Listed once now, and in natural order — COM4 between COM3 and COM8, not after COM9.",
"Motorized antenna: an antenna that lost its serial port stopped answering with nothing at all in the log. Every reason it fails to start or reconnect is now written down, once.", "Motorized antenna: an antenna that lost its serial port stopped answering with nothing at all in the log. Every reason it fails to start or reconnect is now written down, once.",
"UDP: an ADIF listener sharing a port with a WSJT source logged a line per decode — hundreds of them. It now says what the traffic really is, once, and how to fix it." "UDP: an ADIF listener sharing a port with a WSJT source logged a line per decode — hundreds of them. It now says what the traffic really is, once, and how to fix it.",
"QSL Manager, paper QSL: the route is set per direction — bureau, direct or electronic, beside the sent status and beside the received one — and applies to the whole selection at once."
], ],
"fr": [ "fr": [
"Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de lindicatif. Un auto-spot porte VOS références dactivation.", "Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de lindicatif. Un auto-spot porte VOS références dactivation.",
@@ -16,7 +17,8 @@
"Chasse au nouveau : un panneau listant les stations que PSK Reporter entend à moins de 300 km de chez vous et qui sont nouvelles par rapport à votre log — en FT8, FT4, FT2, PSK31 et RTTY, et seulement sur les bandes que vous avez sélectionnées. Une seule indication par ligne, la plus précieuse dabord (entité, bande, mode, créneau, préfixe, carré), avec un filtre pour chacune, un bouton dans la barre pour lafficher et une croix pour le fermer. Un clic met lindicatif en saisie et accorde la radio.", "Chasse au nouveau : un panneau listant les stations que PSK Reporter entend à moins de 300 km de chez vous et qui sont nouvelles par rapport à votre log — en FT8, FT4, FT2, PSK31 et RTTY, et seulement sur les bandes que vous avez sélectionnées. Une seule indication par ligne, la plus précieuse dabord (entité, bande, mode, créneau, préfixe, carré), avec un filtre pour chacune, un bouton dans la barre pour lafficher et une croix pour le fermer. Un clic met lindicatif en saisie et accorde la radio.",
"Ports série : un port revendiqué par deux périphériques dans la table Windows apparaissait en double dans toutes les listes, et saffichait « COM3COM3 ». Une seule fois désormais, et dans lordre naturel — COM4 entre COM3 et COM8, pas après COM9.", "Ports série : un port revendiqué par deux périphériques dans la table Windows apparaissait en double dans toutes les listes, et saffichait « COM3COM3 ». Une seule fois désormais, et dans lordre naturel — COM4 entre COM3 et COM8, pas après COM9.",
"Antenne motorisée : une antenne ayant perdu son port série cessait de répondre sans rien laisser dans le journal. Chaque raison dun démarrage ou dune reconnexion manquée est désormais écrite, une fois.", "Antenne motorisée : une antenne ayant perdu son port série cessait de répondre sans rien laisser dans le journal. Chaque raison dun démarrage ou dune reconnexion manquée est désormais écrite, une fois.",
"UDP : un écouteur ADIF partageant un port avec une source WSJT écrivait une ligne par décodage — des centaines. Il dit maintenant ce quest vraiment ce trafic, une fois, et comment le corriger." "UDP : un écouteur ADIF partageant un port avec une source WSJT écrivait une ligne par décodage — des centaines. Il dit maintenant ce quest vraiment ce trafic, une fois, et comment le corriger.",
"Gestionnaire QSL, QSL papier : la voie se règle par sens — bureau, direct ou électronique, à côté du statut envoyé et à côté du statut reçu — et sapplique à toute la sélection dun coup."
] ]
}, },
{ {
+16 -7
View File
@@ -54,7 +54,9 @@ const QSL_STATUSES = [
{ v: 'I', label: 'qslm.ignore' }, { v: 'I', label: 'qslm.ignore' },
]; ];
// QSL routing methods for the paper-QSL "Via" dropdown (was free text). // QSL routing methods for the paper-QSL route dropdowns, one per direction.
// These are the ADIF QSL Via enumeration (B/D/E) and go to QSL_SENT_VIA and
// QSL_RCVD_VIA — NOT to QSL_VIA, which holds the manager's callsign.
const QSL_VIA_OPTIONS = [ const QSL_VIA_OPTIONS = [
{ v: '_', label: 'qslm.leave' }, { v: '_', label: 'qslm.leave' },
{ v: 'B', label: 'qslm.viaBureau' }, { v: 'B', label: 'qslm.viaBureau' },
@@ -149,7 +151,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [qslSent, setQslSent] = useState('_'); const [qslSent, setQslSent] = useState('_');
const [qslRcvdDate, setQslRcvdDate] = useState(''); const [qslRcvdDate, setQslRcvdDate] = useState('');
const [qslSentDate, setQslSentDate] = useState(''); const [qslSentDate, setQslSentDate] = useState('');
const [qslVia, setQslVia] = useState('_'); const [qslVia, setQslVia] = useState('_'); // how the card was SENT
const [qslRcvdVia, setQslRcvdVia] = useState('_'); // how it CAME BACK
const [qslNotes, setQslNotes] = useState(''); const [qslNotes, setQslNotes] = useState('');
const [qslComment, setQslComment] = useState(''); const [qslComment, setQslComment] = useState('');
@@ -181,6 +184,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
sent_date: ymd(qslSentDate), sent_date: ymd(qslSentDate),
rcvd_date: ymd(qslRcvdDate), rcvd_date: ymd(qslRcvdDate),
via: qslVia === '_' ? '' : qslVia, via: qslVia === '_' ? '' : qslVia,
rcvd_via: qslRcvdVia === '_' ? '' : qslRcvdVia,
notes: qslNotes, notes: qslNotes,
comment: qslComment, comment: qslComment,
} as any); } as any);
@@ -553,6 +557,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{/* Paper-QSL apply form */} {/* Paper-QSL apply form */}
{service === 'paper' && ( {service === 'paper' && (
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0"> <div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
{/* Each direction carries its own route, and each sits with the status
and date it belongs to. A stack confirmed in one go usually arrived
the same way — a bureau delivery is exactly that — which is why the
route belongs on this form and not one QSO at a time in the editor. */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
@@ -561,6 +569,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent> <SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
</Select> </Select>
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} /> <Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
<Select value={qslRcvdVia} onValueChange={setQslRcvdVia}>
<SelectTrigger className="h-8 w-32" title={t('qslm.rcvdViaTitle')}><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -571,15 +583,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent> <SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
</Select> </Select>
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} /> <Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
<Select value={qslVia} onValueChange={setQslVia}> <Select value={qslVia} onValueChange={setQslVia}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-32" title={t('qslm.sentViaTitle')}><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent> <SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
</Select> </Select>
</div> </div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} /> <Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
File diff suppressed because one or more lines are too long
+2
View File
@@ -2739,6 +2739,7 @@ export namespace main {
sent_date: string; sent_date: string;
rcvd_date: string; rcvd_date: string;
via: string; via: string;
rcvd_via: string;
notes: string; notes: string;
comment: string; comment: string;
@@ -2753,6 +2754,7 @@ export namespace main {
this.sent_date = source["sent_date"]; this.sent_date = source["sent_date"];
this.rcvd_date = source["rcvd_date"]; this.rcvd_date = source["rcvd_date"];
this.via = source["via"]; this.via = source["via"];
this.rcvd_via = source["rcvd_via"];
this.notes = source["notes"]; this.notes = source["notes"];
this.comment = source["comment"]; this.comment = source["comment"];
} }