Files
OpsLog/frontend/src/components/BulkEditModal.tsx
T
rouggy 4e88bdfaa7 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.
2026-08-14 11:50:25 +02:00

271 lines
15 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { BulkUpdateField } from '../../wailsjs/go/main/App';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
type FieldKind = 'status' | 'text' | 'freq' | 'date';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
// the backend BulkUpdateField whitelist.
// label holds an i18n key (resolved with t() at render time).
const FIELDS: FieldDef[] = [
// QSL / upload status
// One channel at a time, each with its status and its date, in the same order
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
// QSL is not uploaded — so every entry now reads "<channel> sent/received
// status" or "… date", which is also what the ADIF field is called.
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
{ 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' },
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
// My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
{ id: 'my_city', label: 'bulk.fMyCity', group: 'My station', kind: 'text' },
{ id: 'my_postal_code', label: 'bulk.fMyPostal', group: 'My station', kind: 'text' },
{ id: 'my_country', label: 'bulk.fMyCountry', group: 'My station', kind: 'text' },
{ id: 'my_state', label: 'bulk.fMyState', group: 'My station', kind: 'text', upper: true },
{ id: 'my_cnty', label: 'bulk.fMyCounty', group: 'My station', kind: 'text' },
{ id: 'my_iota', label: 'bulk.fMyIota', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sota_ref', label: 'bulk.fMySota', group: 'My station', kind: 'text', upper: true },
{ id: 'my_pota_ref', label: 'bulk.fMyPota', group: 'My station', kind: 'text', upper: true },
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
// Contest
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
{ id: 'stx_string', label: 'bulk.fStxString', group: 'Contest', kind: 'text' },
{ id: 'arrl_sect', label: 'bulk.fArrlSect', group: 'Contest', kind: 'text', upper: true },
{ id: 'precedence', label: 'bulk.fPrecedence', group: 'Contest', kind: 'text', upper: true },
{ id: 'class', label: 'bulk.fClass', group: 'Contest', kind: 'text', upper: true },
// Propagation / satellite
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
// Contacted station (location / activation refs / SIG)
{ id: 'grid', label: 'bulk.fGrid', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
// The contact itself — repair fields, not description fields. An import that
// mapped every QSO to SSB, or an ADIF with no MODE at all, is fixed here
// instead of one row at a time.
//
// Band is deliberately absent: it travels with the frequency below, because a
// band contradicting its own frequency is invalid ADIF and every export would
// carry the contradiction.
{ id: 'mode', label: 'bulk.fMode', group: 'The contact', kind: 'text', upper: true },
{ id: 'submode', label: 'bulk.fSubmode', group: 'The contact', kind: 'text', upper: true },
{ id: 'rst_sent', label: 'bulk.fRstSent', group: 'The contact', kind: 'text' },
{ id: 'rst_rcvd', label: 'bulk.fRstRcvd', group: 'The contact', kind: 'text' },
// Misc
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
// logged on a stale/default frequency after CAT dropped.
{ id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' },
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
{ id: 'ant', label: 'bulk.fAnt', group: 'Misc', kind: 'text' },
];
// label holds an i18n key.
const STATUS_VALUES: { v: string; label: string }[] = [
{ v: 'Y', label: 'bulk.statusY' },
{ v: 'N', label: 'bulk.statusN' },
{ v: 'R', label: 'bulk.statusR' },
{ v: 'I', label: 'bulk.statusI' },
{ v: '_', label: 'bulk.statusBlank' },
];
// Derived from the fields themselves, in the order they are declared.
//
// This used to be a hand-written list, and a group added to FIELDS but not to it
// simply never rendered — the fields existed, passed every check, and could not
// be picked. Two lists that must agree, with nothing to make them.
const GROUPS = [...new Set(FIELDS.map((f) => f.group))];
// Maps the internal group key → its i18n label key.
const GROUP_LABELS: Record<string, string> = {
'QSL / upload': 'bulk.groupQsl',
'My station': 'bulk.groupMyStation',
'Contacted station': 'bulk.groupContacted',
'Contest': 'bulk.groupContest',
'Propagation': 'bulk.groupPropagation',
'Misc': 'bulk.groupMisc',
'The contact': 'bulk.groupContact',
};
type Props = {
open: boolean;
ids: number[];
onClose: () => void;
onApplied: (n: number) => void;
};
// BulkEditModal sets one QSL/upload status field to a chosen value across the
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
// so they become eligible for upload.
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
const { t } = useI18n();
const [field, setField] = useState('qsl_sent');
const [statusValue, setStatusValue] = useState('R');
const [textValue, setTextValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
const isStatus = def.kind === 'status';
const effectiveValue = isStatus
? (statusValue === '_' ? '' : statusValue)
: textValue.trim();
async function apply() {
setBusy(true);
setError('');
try {
const n = await BulkUpdateField(ids as any, field, effectiveValue);
onApplied(Number(n) || 0);
onClose();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('bulk.title')}</DialogTitle>
<DialogDescription>
{t('bulk.desc', { n: ids.length })}
</DialogDescription>
</DialogHeader>
<div className="px-5 py-2 space-y-3">
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
<Label className="text-sm">{t('bulk.fieldLabel')}</Label>
<Select value={field} onValueChange={setField}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{GROUPS.map((g) => (
<div key={g}>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{GROUP_LABELS[g] ? t(GROUP_LABELS[g]) : g}</div>
{FIELDS.filter((f) => f.group === g)
.map((f) => ({ f, txt: t(f.label) }))
.sort((a, b) => a.txt.localeCompare(b.txt))
.map(({ f, txt }) => (
<SelectItem key={f.id} value={f.id}>{txt}</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
<Label className="text-sm">{t('bulk.valueLabel')}</Label>
{isStatus ? (
<Select value={statusValue} onValueChange={setStatusValue}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
</SelectContent>
</Select>
) : def.kind === 'date' ? (
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
// writes an empty value — which is how a wrong date is removed
// from a batch, the same meaning as leaving a text field blank.
<div className="flex gap-1">
<Input
type="date"
className="h-8 text-xs font-mono"
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
/>
<Button
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
title={t('bulk.todayUtc')}
onClick={() => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
// UTC: the whole log is UTC, and near midnight the local day
// is the wrong one.
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
}}
>{t('bulk.today')}</Button>
</div>
) : (
<Input
className="h-8 text-xs"
value={textValue}
inputMode={def.kind === 'freq' ? 'decimal' : undefined}
placeholder={def.kind === 'freq' ? t('bulk.freqPlaceholder') : t('bulk.clearPlaceholder')}
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
/>
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</span> {t('bulk.onQsos', { n: ids.length })}
</div>
{error && <div className="text-xs text-danger">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>{t('bulk.cancel')}</Button>
<Button onClick={apply} disabled={busy || ids.length === 0}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
{t('bulk.applyTo', { n: ids.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}