Files
OpsLog/frontend/src/components/QSOEditModal.tsx
T
rouggy 22e4266d38 fix(editor): a corrected frequency corrects its band
Band and frequency have to agree — the log, every award and every upload
are read on the BAND — and an operator fixing a wrong frequency is not
also expecting to fix the band by hand. That is exactly how a QSO ends
up filed on 20m at 7 MHz. Both sides follow, TX and RX.

Only when the number lands in a known allocation, because half a
frequency is typed on the way to all of it and a band must never be
blanked by that. bandForMHz moves out of App.tsx into lib/bandplan,
where the two callers can share one answer instead of drifting.
2026-08-31 22:21:22 +02:00

1074 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs';
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 { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n';
import { bandForMHz } from '@/lib/bandplan';
import { titleCase, sentenceCase } from '@/lib/textCase';
import type { QSOForm } from '@/types';
type QSO = QSOForm;
// Quick prefix from a callsign (drops portable suffixes, keeps a slashed
// prefix). Read-only display, mirrors Log4OM's PFX box.
function pfxOf(call: string): string {
const c = (call || '').trim().toUpperCase();
if (!c) return '';
const base = c.includes('/') ? c.split('/')[0] : c;
let lastDigit = -1;
for (let i = 0; i < base.length; i++) if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
}
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
// label holds an i18n key (resolved with t() at render time).
// V is in the list because the log HOLDS it: a LoTW download writes "verified"
// rather than "yes". A dropdown without it renders a verified contact as blank,
// which reads as "nothing recorded" — and the operator's next click would replace
// the strongest confirmation they have with whatever they picked instead.
const QSL_STATUSES = [
{ value: '_', label: 'qedit.qslDash' },
{ value: 'Y', label: 'qedit.qslYes' },
{ value: 'V', label: 'qedit.qslVerified' },
{ value: 'N', label: 'qedit.qslNo' },
{ value: 'R', label: 'qedit.qslRequested' },
{ value: 'I', label: 'qedit.qslIgnore' },
];
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
// Confirmation channels — each maps to its QSO sent/received status, dates and
// (paper-only) via fields. Drives the "Manage Confirmation" editor and the
// live status grid (Log4OM style).
type ConfDef = {
key: string; label: string;
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
sentDate?: keyof QSOForm; rcvdDate?: 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', 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 },
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
];
// i18n label keys for confirmation channels whose label has translatable words
// (brand names like LoTW/eQSL stay as their literal label in CONFIRMATIONS).
const CONF_LABEL_KEYS: Record<string, string> = {
QSL: 'qedit.confQslPaper',
};
// Reading order for the channel list: the two that carry an ARRL award first —
// paper QSL and LoTW — then everything else alphabetically, so a channel is
// found by its name rather than by remembering the order it was added in.
const CONF_FIRST: Record<string, number> = { QSL: 0, LOTW: 1 };
function confOrder<T extends { key: string; label: string }>(rows: T[]): T[] {
return rows.slice().sort((a, b) => {
const ra = CONF_FIRST[a.key] ?? 2;
const rb = CONF_FIRST[b.key] ?? 2;
if (ra !== rb) return ra - rb;
return a.label.localeCompare(b.label);
});
}
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
// columns and this channel is backed by ADIF extras — but it still belongs in
// the channel picker and the status table alongside the rest.
const OPSLOG_CONF = 'OPSLOG';
// HAMLOG.online. Out of CONFIRMATIONS for the same reason as the OpsLog card —
// it is stored in the ADIF extras, not in QSO columns — but it is an upload
// service like QRZ.com or Club Log, so it gets the ordinary sent/received/date
// editor rather than a special one. The received key is THEIR field name, the
// one their ADIF export writes; see award.HamlogQSLKey.
const HAMLOG_CONF = 'HAMLOG';
const HAMLOG_KEYS = {
sent: 'APP_OPSLOG_HAMLOG_SENT',
sentDate: 'APP_OPSLOG_HAMLOG_SENT_DATE',
rcvd: 'APP_HAMLOG_QSO_CFM',
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
};
// HamQTH — extras again (ADIF names no HamQTH field), and SENT only: the site
// publishes no confirmation feed, so a received column here would be a promise
// nothing can keep.
const HAMQTH_CONF = 'HAMQTH';
const HAMQTH_KEYS = {
sent: 'APP_OPSLOG_HAMQTH_SENT',
sentDate: 'APP_OPSLOG_HAMQTH_SENT_DATE',
};
// Colour-coded status cell for the confirmation grid.
function StatusCell({ value }: { value?: string }) {
const { t } = useI18n();
const v = (value || '').toUpperCase();
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
if (v === '') {
return <span className="block text-center text-[11px] text-muted-foreground"></span>;
}
// One colour per state, and each colour means something:
// Yes green — confirmed, the thing you wanted
// Requested blue — in flight, waiting on the other end. Not a problem.
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
// This is the ONLY state asking for action, so it gets the
// only alarming colour.
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
// every row; painting that orange (as it used to be, in the
// same orange as Requested) made the table shout about a
// non-problem and told you nothing apart.
// Verified green — ADIF's V: confirmed AND validated by the awarding body.
// It is what a LoTW download writes, and this table used
// to fall through to "No" for it — reporting a verified
// contact as unconfirmed, in the one place an operator
// goes to check.
// Ignore dashed — deliberately excluded, on purpose.
const label = v === 'Y' ? t('qedit.qslYes') : v === 'V' ? t('qedit.qslVerified') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
const cls = v === 'Y' || v === 'V' ? 'bg-success text-success-foreground border border-success'
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
: 'bg-muted text-muted-foreground border border-border';
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
}
interface Props {
qso: QSO;
onSave: (q: QSO) => void;
onDelete: (id: number) => void;
onClose: () => void;
countries?: string[];
bands?: string[];
modes?: string[];
}
function toLocalISO(d: any): string {
if (!d) return '';
const date = new Date(d);
if (isNaN(date.getTime())) return '';
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
// doesn't apply to a time struct), so a QSO with no end time arrives as a
// year-1 date. Treat anything that old as unset — otherwise the datetime
// field shows a garbage value and fights the user's typing.
if (date.getUTCFullYear() <= 1) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
}
function parseLocalISO(s: string): string | null {
if (!s) return null;
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return null;
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
}
function numOrUndef(v: any): number | undefined {
if (v === '' || v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : parseFloat(String(v));
return isNaN(n) ? undefined : n;
}
function intOrUndef(v: any): number | undefined {
const n = numOrUndef(v);
return n === undefined ? undefined : Math.trunc(n);
}
function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
return (
<div className={cn('flex flex-col gap-1 min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
<Label>{label}</Label>
{children}
</div>
);
}
// AdifDateInput — a real date picker over an ADIF date.
//
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
// are converted at the edge and the log keeps its ADIF form. The native control
// is used on purpose: it brings the OS calendar, the locale's day/month order
// and keyboard entry for free, which a hand-rolled popover would have to
// reimplement and get wrong.
//
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
// a plain text box instead, so it stays visible and correctable rather than
// silently disappearing behind an empty picker.
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
const raw = (value ?? '').trim();
const valid = /^\d{8}$/.test(raw);
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
const today = () => {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
// UTC: every date in the log is UTC, and near midnight the local day is the
// wrong one.
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
};
if (raw !== '' && !valid) {
return (
<div className="flex gap-1">
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
</div>
);
}
return (
<div className="flex gap-1">
<Input
type="date"
value={iso}
disabled={disabled}
onChange={(e) => {
const v = e.target.value; // "" when cleared
onChange(v ? v.replace(/-/g, '') : '');
}}
className="font-mono"
/>
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
onClick={today} title="Today (UTC)">
<CalendarDays className="size-3.5" />
</Button>
</div>
);
}
function QslSelect({ 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_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
</SelectContent>
</Select>
);
}
// 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);
// fall back to the built-in sets. Always include the QSO's own band/mode so an
// imported/legacy value is never silently dropped from the dropdown.
const bandList = useMemo(() => {
const base = (bands && bands.length ? bands : BANDS).slice();
if (qso.band && !base.includes(qso.band)) base.unshift(qso.band);
return base;
}, [bands, qso.band]);
const modeList = useMemo(() => {
const base = (modes && modes.length ? modes : MODES).slice();
if (qso.mode && !base.includes(qso.mode)) base.unshift(qso.mode);
return base;
}, [modes, qso.mode]);
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
useEffect(() => {
GetListsSettings()
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
.catch(() => {});
}, []);
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
: { khz: '', hz: '' };
// Correcting a frequency corrects its band. The pair has to agree — the log,
// every award and every upload are read on the BAND — and an operator fixing
// a wrong frequency is not also expecting to fix the band by hand, which is
// exactly how a QSO ends up filed on 20m at 7 MHz.
//
// Only when the number lands in a known allocation: half a frequency is typed
// on the way to all of it, and a band must never be blanked by that.
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
if (!khz.trim()) return;
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
if (b) set(field, b as any);
};
const f0 = splitHz(draft.freq_hz);
const fr0 = splitHz(draft.freq_rx_hz);
const [freqKHz, setFreqKHz] = useState(f0.khz);
const [freqHz, setFreqHz] = useState(f0.hz);
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
const [dateOff, setDateOff] = useState(dateOffISO);
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false);
const [looking, setLooking] = useState(false);
// === Award references (Log4OM-style tab) ===
// Manual refs are edited as a "CODE@REF;…" string; computed refs (DXCC, WAZ,
// WPX, …) are derived from the QSO by the backend and shown read-only.
const awardFieldRef = useRef<Record<string, string>>({});
const [awardRefs, setAwardRefs] = useState('');
// The refs present when the editor opened, so on save we can tell which ones
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
const seedAwardRefsRef = useRef('');
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
// Load award definitions once, then seed the editable manual refs from the QSO.
useEffect(() => {
GetAwardDefs()
.then(async (defs) => {
const list = (defs ?? []) as any[];
const fieldOf: Record<string, string> = {};
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
awardFieldRef.current = fieldOf;
// Seed the editable manual refs from the backend, which already matched
// each reference against its award's own list. Seeding from the raw QSO
// field instead would wrongly seed every state-award (WAS/RAC/WAJA) from
// the same `state` value — e.g. a US "CA" would seed RAC@CA too.
try {
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
const seed = all
.filter((r: any) => r.pickable)
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
.join(';');
seedAwardRefsRef.current = seed;
setAwardRefs(seed);
} catch { /* leave manual refs empty on failure */ }
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Recompute the read-only computed refs whenever a source field changes.
useEffect(() => {
const t = window.setTimeout(async () => {
try {
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
setComputedRefs(all.filter((r: any) => !r.pickable).map((r: any) => ({ code: r.code, ref: r.ref, name: r.name })));
} catch { setComputedRefs([]); }
}, 250);
return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
// accepts letters (sections/zones), not just numbers.
function setExchange(which: 'srx' | 'stx', raw: string) {
const t = raw.trim();
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
}
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value }));
}
// Country drives the DXCC entity number (ADIF). The DXCC field is read-only;
// picking a Country resolves and stamps its DXCC# so they can't diverge.
async function onCountryChange(v: string) {
set('country', v);
try {
const n = await DXCCForCountry(v);
set('dxcc', (n && n > 0 ? n : undefined) as any);
} catch { /* leave DXCC as-is if resolution fails */ }
}
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
// the draft — handy after correcting the callsign. Only overwrites the
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
async function fetchLookup() {
const call = (draft.callsign ?? '').trim().toUpperCase();
if (!call) { setLocalErr(t('qedit.callsignRequired')); return; }
setLooking(true);
setLocalErr('');
try {
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
// refreshes it. A cached answer from a thinner QRZ subscription (or any
// stale row) otherwise stayed for its whole 30-day life and the button
// appeared to do nothing.
// The QSO's OWN date: a ClubLog exception is resolved as of when the
// contact happened, not as of today. Re-looking-up a DXpedition contact
// months later must not move it to whatever the prefix means now.
const r: any = await LookupCallsignFresh(call, (dateOn || '').slice(0, 10));
// The lookup WINS over what is in the record — that is the point of asking
// for it. But an EMPTY result must never blank a good value: `??` only
// guards against null, and Go marshals an unset string as "", so a QRZ
// record with no grid used to wipe the grid that was already there.
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
setDraft((d) => ({
...d,
name: keep(r.name, d.name),
qth: keep(r.qth, d.qth),
address: keep(r.address, (d as any).address),
email: keep(r.email, (d as any).email),
country: keep(r.country, d.country),
grid: keep(r.grid, d.grid),
state: keep(r.state, d.state),
cnty: keep(r.cnty, d.cnty),
cont: keep(r.cont, d.cont),
qsl_via: keep(r.qsl_via, d.qsl_via),
dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz,
lat: r.lat || d.lat,
lon: r.lon || d.lon,
}));
} catch (e: any) {
setLocalErr(t('qedit.lookupError', { msg: String(e?.message ?? e) }));
} finally {
setLooking(false);
}
}
function save() {
if (!draft.callsign?.trim()) { setLocalErr(t('qedit.callsignRequired')); return; }
setSaving(true);
setLocalErr('');
const out: any = {
...draft,
callsign: draft.callsign.trim().toUpperCase(),
grid: (draft.grid ?? '').trim().toUpperCase(),
gridsquare_ext: (draft.gridsquare_ext ?? '').trim().toUpperCase(),
station_callsign: (draft.station_callsign ?? '').trim().toUpperCase(),
operator: (draft.operator ?? '').trim().toUpperCase(),
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
// iota / sota_ref / pota_ref are set below from the Award Refs tab.
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
qso_date_off: endEnabled ? (parseLocalISO(dateOff) ?? undefined) : undefined,
freq_hz: freqKHz.trim() ? parseInt(freqKHz, 10) * 1000 + (parseInt(freqHz, 10) || 0) : undefined,
freq_rx_hz: freqRxKHz.trim() ? parseInt(freqRxKHz, 10) * 1000 + (parseInt(freqRxHz, 10) || 0) : undefined,
dxcc: intOrUndef(draft.dxcc),
cqz: intOrUndef(draft.cqz),
ituz: intOrUndef(draft.ituz),
age: intOrUndef(draft.age),
srx: intOrUndef(draft.srx),
stx: intOrUndef(draft.stx),
my_dxcc: intOrUndef(draft.my_dxcc),
my_cq_zone: intOrUndef(draft.my_cq_zone),
my_itu_zone: intOrUndef(draft.my_itu_zone),
lat: numOrUndef(draft.lat), lon: numOrUndef(draft.lon),
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
tx_pwr: numOrUndef(draft.tx_pwr),
distance: numOrUndef(draft.distance),
rx_pwr: numOrUndef(draft.rx_pwr),
a_index: numOrUndef(draft.a_index),
k_index: numOrUndef(draft.k_index),
sfi: numOrUndef(draft.sfi),
extras: draft.extras && Object.keys(draft.extras).length ? draft.extras : undefined,
};
// The Award Refs tab is authoritative for the reference-list awards. Reset
// the dedicated columns, then route the picked refs back onto the payload
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
onSave(out);
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
});
const extrasCount = useMemo(
() => (draft.extras ? Object.keys(draft.extras).length : 0),
[draft.extras],
);
// OpsLog QSL "received" marker (ADIF extra). Drives the card's PSE/TNX stamp:
// received → TNX QSL (thanks for your card), otherwise PSE QSL (please send one).
// Saved with the normal Save via draft.extras.
const OPSLOG_QSL_RCVD = 'APP_OPSLOG_QSL_RCVD';
const qslReceived = !!String(draft.extras?.[OPSLOG_QSL_RCVD] ?? '').trim();
// Sent side, for the confirmations table. Two key names because the marker was
// renamed once and old QSOs still carry the first one — same test the Recent
// QSOs "OpsLog QSL" column makes.
const opslogQslSent = !!(draft.extras?.['APP_OPSLOG_QSL_SENT'] || draft.extras?.['APP_OPSLOG_QSL_CARD_SENT']);
const toggleQslReceived = (on: boolean) => {
// Reflect immediately in the draft (for the PSE/TNX indicator)…
const next = { ...(draft.extras ?? {}) };
if (on) next[OPSLOG_QSL_RCVD] = new Date().toISOString();
else delete next[OPSLOG_QSL_RCVD];
set('extras', next as any);
// …and persist right away with a targeted write that reliably sets OR clears
// the key (the modal's extras-merge on Save wouldn't clear a removed key).
if ((draft as any).id) SetOpsLogQSLReceived((draft as any).id, on).catch(() => {});
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="flex-row items-baseline gap-2">
<DialogTitle>{t('qedit.title')}</DialogTitle>
<span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span>
<DialogDescription className="sr-only">{t('qedit.editFieldsFor', { id: draft.id })}</DialogDescription>
</DialogHeader>
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
<TabsList className="px-3 overflow-x-auto">
<TabsTrigger value="qsoinfo">{t('qedit.tabQsoInfo')}</TabsTrigger>
<TabsTrigger value="contact">{t('qedit.tabContact')}</TabsTrigger>
<TabsTrigger value="awards">{t('qedit.tabAwards')}</TabsTrigger>
<TabsTrigger value="qsl">{t('qedit.tabQsl')}</TabsTrigger>
<TabsTrigger value="contest">{t('qedit.tabContest')}</TabsTrigger>
<TabsTrigger value="sat">{t('qedit.tabSat')}</TabsTrigger>
<TabsTrigger value="mystation">{t('qedit.tabMyStation')}</TabsTrigger>
<TabsTrigger value="moreadif">{t('qedit.tabMoreAdif')}</TabsTrigger>
<TabsTrigger value="extras">
{t('qedit.tabAdifFields')}
{extrasCount > 0 && (
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
)}
</TabsTrigger>
</TabsList>
{localErr && (
<div className="mx-5 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2">
{localErr}
</div>
)}
<div className="overflow-y-auto px-5 py-4 flex-1">
<TabsContent value="qsoinfo" className="mt-0">
{/* Top: Callsign + RST + Fetch */}
<div className="flex items-end gap-2 mb-3">
<div className="flex flex-col flex-1 min-w-0">
<div className="flex items-center">
<Label>{t('qedit.callsign')}</Label>
{(draft.callsign ?? '').trim() && (
<button
type="button"
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
onClick={() => {
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
}}
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
>
QRZ
</button>
)}
</div>
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div>
<div className="flex flex-col w-20"><Label>S</Label>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
<div className="flex flex-col w-20"><Label>R</Label>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
</Button>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
{/* ── Left column ── */}
<div className="flex flex-col gap-2.5">
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)}
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.rxBand')}</Label>
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="_"></SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.mode')}</Label>
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.country')}</Label>
<Combobox value={draft.country ?? ''} options={countries} placeholder={t('qedit.country')}
onChange={onCountryChange} className="flex-1" />
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">ITU</Label>
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
<Label>CQ</Label>
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title={t('qedit.dxccTitle')} />
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
<Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
<Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
</div>
{/* ── Right column ── */}
<div className="flex flex-col gap-2.5">
<div><Label>{t('qedit.qsoStart')}</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
<div>
<Label className="flex items-center gap-2">
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
const on = !!c;
setEndEnabled(on);
// Prefill an empty end with the start time so the user
// only tweaks the minutes instead of typing a full date.
if (on && !dateOff) setDateOff(dateOn);
}} /> {t('qedit.qsoEnd')}
</Label>
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
<div className="flex flex-col w-24"><Label>{t('qedit.gridExt')}</Label><Input title={t('qedit.gridExtTip')} value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} className="font-mono uppercase" /></div>
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
</div>
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></div>
</div>
</div>
</TabsContent>
<TabsContent value="contact" className="mt-0">
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
{/* Left column */}
<div className="flex flex-col gap-2.5">
<div><Label>{t('qedit.county')}</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
<div><Label>{t('qedit.state')}</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
<div>
<Label>{t('qedit.continent')}</Label>
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_"></SelectItem>
{['NA', 'SA', 'EU', 'AF', 'AS', 'OC', 'AN'].map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)}
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></div>
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
</div>
{/* Right column */}
<div className="flex flex-col gap-2.5">
<div><Label>{t('qedit.email')}</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
<div className="flex items-end gap-2">
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
</div>
{/* The OpsLog QSL marker used to sit here, under QSL Msg. It
belongs with the other confirmation channels — see the QSL
Info tab. */}
<div>
<Label>{t('qedit.qslMsg')}</Label>
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', 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>
<TabsContent value="awards" className="mt-0">
<div className="grid grid-cols-[1fr_240px] gap-5">
{/* Left: pick reference-list awards (POTA/SOTA/IOTA/WWFF/…) */}
<div>
<AwardRefSelector dxcc={draft.dxcc} value={awardRefs} onChange={setAwardRefs} fieldValues={{ state: draft.state ?? '', cnty: draft.cnty ?? '' }} />
</div>
{/* Right: computed awards (read-only) derived from this QSO */}
<div className="flex flex-col gap-1.5 min-w-0">
<span className="text-xs font-semibold">{t('qedit.computedAuto')}</span>
<p className="text-[11px] text-muted-foreground leading-snug">
{t('qedit.computedHint')}
</p>
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
{computedRefs.length === 0 ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('qedit.noneYet')}</div>
) : (
computedRefs.map((r) => (
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
<span className="font-mono font-semibold">{r.code}@{r.ref}</span>
{r.name && <span className="text-[10px] text-muted-foreground ml-1.5 truncate">{r.name}</span>}
</div>
))
)}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="qsl" className="mt-0">
{(() => {
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
// The extras-backed channel reads and writes the same way, one
// level down. Save merges the extras rather than replacing them,
// so an emptied field keeps its stored value — which is why the
// statuses are written as explicit Y/N and never removed.
const exVal = (k: string) => String(draft.extras?.[k] ?? '');
const exPut = (k: string, v: any) => {
const next = { ...(draft.extras ?? {}) };
next[k] = String(v ?? '');
set('extras', next as any);
};
return (
<div className="flex gap-6">
{/* Left: edit one confirmation channel at a time */}
<div className="flex-1 max-w-sm space-y-3">
<div>
<Label>{t('qedit.manageConf')}</Label>
<Select value={confSel} onValueChange={setConfSel}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{confOrder([
...CONFIRMATIONS.map((c) => ({ key: c.key, label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label })),
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog') },
{ key: HAMLOG_CONF, label: 'HAMLOG.online' },
{ key: HAMQTH_CONF, label: 'HamQTH' },
]).map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}
{/* The three above that are NOT in CONFIRMATIONS — the
QSO columns, and this channel lives in the ADIF
OpsLog card, HAMLOG.online and HamQTH — are backed by
ADIF extras rather than QSO columns, and each gets its
own editor below instead of the generic
sent/received/date grid, which has no field to bind
to. */}
</SelectContent>
</Select>
</div>
{confSel === HAMQTH_CONF ? (
<>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMQTH_KEYS.sent)} onChange={(v) => exPut(HAMQTH_KEYS.sent, v)} /></div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMQTH_KEYS.sentDate)} onChange={(v) => exPut(HAMQTH_KEYS.sentDate, v)} /></div>
</div>
<p className="text-[11px] text-muted-foreground">
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
</p>
</>
) : confSel === HAMLOG_CONF ? (
<>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
<div><Label>{t('qedit.received')}</Label><QslSelect value={exVal(HAMLOG_KEYS.rcvd)} onChange={(v) => exPut(HAMLOG_KEYS.rcvd, v)} /></div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.sentDate)} onChange={(v) => exPut(HAMLOG_KEYS.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.rcvdDate)} onChange={(v) => exPut(HAMLOG_KEYS.rcvdDate, v)} /></div>
</div>
<p className="text-[11px] text-muted-foreground">
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
</p>
</>
) : confSel === OPSLOG_CONF ? (
/* OpsLog's own card. "Sent" is stamped when the card
actually goes out, so it is shown, not offered: ticking
it by hand would record something that never happened.
"Received" drives the PSE/TNX stamp printed on the card,
which is the whole reason the flag exists — hence the
live indicator next to it. It writes immediately rather
than on Save, because clearing an extras key would not
survive the merge Save does. */
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('qedit.sent')}</Label>
<Input disabled value={opslogQslSent ? t('qedit.qslYes') : t('qedit.qslNo')} />
</div>
<div>
<Label>{t('qedit.received')}</Label>
<label className="flex h-9 items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
{t('qedit.qslReceived')}
</label>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{qslReceived ? 'TNX QSL' : 'PSE QSL'}
</span>
<span className="text-[11px] text-muted-foreground">{t('qedit.pseTnxHint')}</span>
</div>
<p className="text-[11px] text-muted-foreground">{t('qedit.opslogSentHint')}</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
<div><Label>{t('qedit.received')}</Label>
{def.rcvd
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />}
</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>
{/* 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">
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
</p>
</>
)}
</div>
{/* Right: live status grid for every channel.
Sized by its content, not pinned to a width: a fixed 288px box
left the label column too narrow, so "QSL (paper)" wrapped onto
two lines and padded out the whole row. There is spare width to
the right — spend it on the label. */}
<div className="shrink-0">
<table className="border-separate" style={{ borderSpacing: 4 }}>
<thead>
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-semibold">{t('qedit.thType')}</th>
<th className="font-semibold">{t('qedit.sent')}</th>
<th className="font-semibold">{t('qedit.received')}</th>
</tr>
</thead>
<tbody>
{/* The extras-backed channels (OpsLog card,
HAMLOG.online, HamQTH) sit in the same ordered list
as the column-backed ones: the reader is looking for
a name, not for a storage detail. A dash in RECEIVED
means the channel has nothing to receive — Club Log
and HamQTH publish no confirmations — which is not
the same statement as "N". */}
{confOrder([
...CONFIRMATIONS.map((c) => ({
key: c.key,
label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label,
sent: val(c.sent),
rcvd: c.rcvd ? val(c.rcvd) : null,
})),
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog'), sent: opslogQslSent ? 'Y' : 'N', rcvd: qslReceived ? 'Y' : 'N' },
{ key: HAMLOG_CONF, label: 'HAMLOG.online', sent: exVal(HAMLOG_KEYS.sent), rcvd: exVal(HAMLOG_KEYS.rcvd) },
{ key: HAMQTH_CONF, label: 'HamQTH', sent: exVal(HAMQTH_KEYS.sent), rcvd: null },
]).map((c) => (
<tr key={c.key} className="text-xs">
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
<td className="w-24"><StatusCell value={c.sent} /></td>
<td className="w-24">{c.rcvd === null
? <span className="block text-center text-[11px] text-muted-foreground"></span>
: <StatusCell value={c.rcvd} />}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})()}
</TabsContent>
<TabsContent value="contest" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label={t('qedit.contestId')} span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder={t('qedit.rcvdExchange')} onChange={(e) => setExchange('srx', e.target.value)} /></F>
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder={t('qedit.sentExchange')} onChange={(e) => setExchange('stx', e.target.value)} /></F>
<F label={t('qedit.check')}><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label={t('qedit.precedence')}><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label={t('qedit.arrlSection')}><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="sat" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label={t('qedit.propMode')}>
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
</Select>
</F>
<F label={t('qedit.satName')}><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
<F label={t('qedit.satMode')}><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
<F label={t('qedit.antAz')}><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.antEl')}><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.antPath')}><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="mystation" className="mt-0 space-y-3">
<p className="text-xs text-muted-foreground">{t('qedit.myStationHint')}</p>
<div className="grid grid-cols-6 gap-3">
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
<F label={t('qedit.myGridExt')}><Input title={t('qedit.gridExtTip')} value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.cqZone')}><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.ituZone')}><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
<F label={t('qedit.sotaRef')}><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
<F label={t('qedit.potaRef')}><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="moreadif" className="mt-0 space-y-4">
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.specialActivity')}</p>
<div className="grid grid-cols-6 gap-3">
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
<F label={t('qedit.sigInfo')} span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
<F label={t('qedit.wwffRef')} span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.region')}><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
</div>
</div>
{/* Power & propagation */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
<div className="grid grid-cols-6 gap-3">
{/* YOUR power first, then theirs. tx_pwr was saved by this
editor but had no field: a contact logged at the wrong
power could be read back and never corrected. */}
<F label={t('qedit.txPower')}><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.kIndex')}><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
</div>
</div>
{/* Identity & clubs */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.identityClubs')}</p>
<div className="grid grid-cols-6 gap-3">
<F label={t('qedit.contactedOp')} span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.formerCall')} span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.class')}><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
<F label="DARC DOK"><Input value={draft.darc_dok ?? ''} onChange={(e) => set('darc_dok', e.target.value)} /></F>
</div>
</div>
{/* Flags & credits */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.flagsCredits')}</p>
<div className="grid grid-cols-6 gap-3">
<F label={t('qedit.qsoComplete')}><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
<F label={t('qedit.qsoRandom')}><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
<F label={t('qedit.silentKey')}><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
<F label={t('qedit.creditGranted')} span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
<F label={t('qedit.creditSubmitted')} span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
</div>
</div>
{/* My station extras */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.myStationAdif')}</p>
<div className="grid grid-cols-6 gap-3">
<F label={t('qedit.myName')} span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
<F label={t('qedit.myWwffRef')} span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.myArrlSect')} span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
<F label={t('qedit.mySig')}><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
<F label={t('qedit.mySigInfo')} span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
<F label={t('qedit.myDarcDok')}><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
<F label={t('qedit.myVuccGrids')} span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
</div>
</div>
</TabsContent>
<TabsContent value="extras" className="mt-0">
<AdifExtrasEditor value={draft.extras} onChange={(next) => set('extras', next as any)} />
</TabsContent>
</div>
</Tabs>
<DialogFooter className="!flex-row gap-2">
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
<Trash2 className="size-3.5" /> {t('qedit.delete')}
</Button>
<div className="flex-1" />
<Button variant="outline" onClick={onClose} disabled={saving}>{t('qedit.cancel')}</Button>
<Button onClick={save} disabled={saving}>{saving ? t('qedit.saving') : t('qedit.saveChanges')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}