import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
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 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 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 [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>({});
const [awardRefs, setAwardRefs] = useState('');
const [computedRefs, setComputedRefs] = useState>([]);
// 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 = {};
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(';');
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]);
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 / 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);
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 (
);
}