import { useEffect, useMemo, useState } from 'react'; import { Trash2, Search, Loader2 } from 'lucide-react'; import { LookupCallsign, DXCCForCountry } 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 { 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 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 = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm']; const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9']; const QSL_STATUSES = [ { value: '_', label: '—' }, { value: 'Y', label: 'Yes' }, { value: 'N', label: 'No' }, { value: 'R', label: 'Requested' }, { value: 'I', label: 'Ignore' }, ]; 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; via?: 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: '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 }, { key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any }, ]; // Colour-coded status cell for the confirmation grid. function StatusCell({ value }: { value?: string }) { 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 —; } const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No'; const cls = v === 'Y' ? 'bg-emerald-600 text-white' : v === 'R' ? 'bg-orange-400 text-white' : v === 'I' ? 'bg-stone-400 text-white' : 'bg-amber-400 text-amber-950'; return {label}; } interface Props { qso: QSO; onSave: (q: QSO) => void; onDelete: (id: number) => void; onClose: () => void; countries?: string[]; } function toLocalISO(d: any): string { if (!d) return ''; const date = new Date(d); if (isNaN(date.getTime())) 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 stringifyExtras(e?: Record): string { if (!e) return ''; return Object.entries(e).map(([k, v]) => `${k} = ${v}`).join('\n'); } function parseExtras(t: string): Record | undefined { const out: Record = {}; for (const raw of t.split('\n')) { const line = raw.trim(); if (!line) continue; const idx = line.indexOf('='); if (idx < 0) continue; const k = line.slice(0, idx).trim().toUpperCase(); const v = line.slice(idx + 1).trim(); if (k && v) out[k] = v; } return Object.keys(out).length ? out : undefined; } 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 ( {label} {children} ); } function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) { return ( onChange(v === '_' ? '' : v)}> {QSL_STATUSES.map((s) => {s.label})} ); } export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: Props) { const [draft, setDraft] = useState(() => JSON.parse(JSON.stringify(qso))); // 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: '' }; 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 [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off)); const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off); const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras)); const [localErr, setLocalErr] = useState(''); const [saving, setSaving] = useState(false); const [looking, setLooking] = useState(false); function set(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('Callsign required'); return; } setLooking(true); setLocalErr(''); try { const r: any = await LookupCallsign(call); setDraft((d) => ({ ...d, name: r.name ?? d.name, qth: r.qth ?? d.qth, address: r.address ?? (d as any).address, email: r.email ?? (d as any).email, country: r.country ?? d.country, grid: r.grid ?? d.grid, state: r.state ?? d.state, cnty: r.cnty ?? d.cnty, cont: r.cont ?? d.cont, qsl_via: 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('Lookup: ' + String(e?.message ?? e)); } finally { setLooking(false); } } function save() { if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); 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: (draft.iota ?? '').trim().toUpperCase(), sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(), pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(), 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), extras: parseExtras(extrasText), }; 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], ); return ( { if (!o) onClose(); }}> Edit QSO #{draft.id} — {draft.callsign} Edit fields for QSO #{draft.id} QSO Info Contact's details QSL Info Contest Sat / Prop My Station Extras {extrasCount > 0 && ( {extrasCount} )} {localErr && ( {localErr} )} {/* Top: Callsign + RST + Fetch */} Callsign set('callsign', e.target.value)} /> S set('rst_sent', e.target.value)} className="font-mono" /> R set('rst_rcvd', e.target.value)} className="font-mono" /> {looking ? : } Fetch {/* ── Left column ── */} Name set('name', e.target.value)} /> Band set('band', v)}> {BANDS.map((b) => {b})} RX Band set('band_rx', v === '_' ? '' : v)}> —{BANDS.map((b) => {b})} Mode set('mode', v)}> {MODES.map((m) => {m})} Country ITU set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" /> CQ set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" /> {flagURL(draft.dxcc) && } Freq setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> RX Freq setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> {/* ── Right column ── */} QSO Start (UTC) setDateOn(e.target.value)} /> setEndEnabled(!!c)} /> QSO End (UTC) setDateOff(e.target.value)} /> Grid set('grid', e.target.value)} className="font-mono uppercase" /> PFX Comment set('comment', e.target.value)} /> Note set('notes', e.target.value)} /> {/* Left column */} County set('cnty', e.target.value)} /> State set('state', e.target.value)} /> QTH set('qth', e.target.value)} /> Address set('address', e.target.value)} /> {/* Right column */} E-mail address (set as any)('email', e.target.value)} /> Lat set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /> Lon set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /> QSL Msg set('qsl_msg', e.target.value)} /> QSL Via set('qsl_via', e.target.value)} /> {(() => { 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); }; return ( {/* Left: edit one confirmation channel at a time */} Manage Confirmation {CONFIRMATIONS.map((c) => {c.label})} Sent put(def.sent, v)} /> Received {def.rcvd ? put(def.rcvd, v)} /> : } Date sent put(def.sentDate, e.target.value)} className="font-mono" /> Date received put(def.rcvdDate, e.target.value)} className="font-mono" /> {def.via && ( Via put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /> )} Pick a channel, edit it — the table on the right updates live. Everything is written when you click Save changes. {/* Right: live status grid for every channel */} Type Sent Received {CONFIRMATIONS.map((c) => ( {c.label} {c.rcvd ? : —} ))} ); })()} set('contest_id', e.target.value)} /> set('srx', intOrUndef(e.target.value) as any)} /> set('stx', intOrUndef(e.target.value) as any)} /> set('srx_string', e.target.value)} /> set('stx_string', e.target.value)} /> set('check', e.target.value)} /> set('precedence', e.target.value)} /> set('arrl_sect', e.target.value)} /> set('prop_mode', v === '_' ? '' : v)}> {PROP_MODES.map((p) => {p === '_' ? '—' : p})} set('sat_name', e.target.value)} /> set('sat_mode', e.target.value)} /> set('ant_az', numOrUndef(e.target.value) as any)} /> set('ant_el', numOrUndef(e.target.value) as any)} /> set('ant_path', e.target.value)} /> These override the active station profile for this QSO only. set('station_callsign', e.target.value)} /> set('operator', e.target.value)} /> set('my_grid', e.target.value)} /> set('my_gridsquare_ext', e.target.value)} /> set('my_country', v)} /> set('my_state', e.target.value)} /> set('my_cnty', e.target.value)} /> set('my_dxcc', intOrUndef(e.target.value) as any)} /> set('my_cq_zone', intOrUndef(e.target.value) as any)} /> set('my_itu_zone', intOrUndef(e.target.value) as any)} /> set('my_iota', e.target.value)} /> set('my_sota_ref', e.target.value)} /> set('my_pota_ref', e.target.value)} /> set('my_lat', numOrUndef(e.target.value) as any)} /> set('my_lon', numOrUndef(e.target.value) as any)} /> set('my_street', e.target.value)} /> set('my_city', e.target.value)} /> set('my_postal_code', e.target.value)} /> set('my_rig', e.target.value)} /> set('my_antenna', e.target.value)} /> ADIF fields not promoted to first-class columns. One per line:{' '} FIELD_NAME = value setExtrasText(e.target.value)} /> onDelete(draft.id)} disabled={saving}> Delete Cancel {saving ? 'Saving…' : 'Save changes'} ); }
Pick a channel, edit it — the table on the right updates live. Everything is written when you click Save changes.
These override the active station profile for this QSO only.
ADIF fields not promoted to first-class columns. One per line:{' '} FIELD_NAME = value
FIELD_NAME = value