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 (
{children}
); } function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) { return ( ); } 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 */}
set('callsign', e.target.value)} />
set('rst_sent', e.target.value)} className="font-mono" />
set('rst_rcvd', e.target.value)} className="font-mono" />
{/* ── Left column ── */}
set('name', e.target.value)} />
set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" /> set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" /> {flagURL(draft.dxcc) && }
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" />
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 ── */}
setDateOn(e.target.value)} />
setDateOff(e.target.value)} />
set('grid', e.target.value)} className="font-mono uppercase" />
set('comment', e.target.value)} />