import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react'; import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { EventsOn } from '../../wailsjs/runtime/runtime'; ModuleRegistry.registerModules([AllCommunityModule]); // Warm theme matching the other grids (Recent QSOs / Cluster). const qslTheme = themeQuartz.withParams({ fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419', headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600, oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8', borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 }, cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12, }); type UploadRow = { id: number; qso_date: string; callsign: string; band: string; mode: string; country: string; status: string; }; const UPLOAD_COLS: ColDef[] = [ { field: 'qso_date', headerName: 'Date UTC', valueFormatter: (p) => fmtDate(p.value), minWidth: 150 }, { field: 'callsign', headerName: 'Callsign', cellClass: 'font-mono font-bold', width: 130 }, { field: 'band', headerName: 'Band', width: 90 }, { field: 'mode', headerName: 'Mode', width: 100 }, { field: 'country', headerName: 'Country', flex: 1, minWidth: 140 }, { field: 'status', headerName: 'Sent', width: 90, valueFormatter: (p) => p.value || '—' }, ]; type Confirmation = { callsign: string; qso_date: string; band: string; mode: string; country: string; new_dxcc: boolean; new_band: boolean; new_slot: boolean; }; const SERVICES = [ { v: 'qrz', label: 'QRZ.com' }, { v: 'clublog', label: 'Club Log' }, { v: 'hrdlog', label: 'HRDLog.net' }, { v: 'eqsl', label: 'eQSL.cc' }, { v: 'lotw', label: 'LoTW' }, { v: 'pota', label: 'POTA hunter log' }, { v: 'paper', label: 'Paper QSL' }, ]; const QSL_STATUSES = [ { v: '_', label: '— leave —' }, { v: 'Y', label: 'Yes' }, { v: 'N', label: 'No' }, { v: 'R', label: 'Requested' }, { v: 'I', label: 'Ignore' }, ]; type LogQSO = { id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string; qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string; }; type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number }; type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string }; const SENT_STATUSES = [ { v: 'R', label: 'Requested' }, { v: 'N', label: 'No' }, { v: 'Q', label: 'Queued' }, { v: 'Y', label: 'Yes (already sent)' }, { v: 'I', label: 'Invalid' }, { v: '_', label: '— blank —' }, ]; function fmtDate(iso: string): string { if (!iso) return ''; const d = new Date(iso); if (isNaN(d.getTime())) return iso; const p = (n: number) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`; } // fmtQslDate renders a QSL/LoTW/eQSL/ClubLog date (ADIF YYYYMMDD, or an ISO // datetime) as YYYY-MM-DD — same shape as the QSO date, without the time. export function fmtQslDate(s?: string): string { if (!s) return ''; const t = s.trim(); const m = t.match(/^(\d{4})(\d{2})(\d{2})/); if (m) return `${m[1]}-${m[2]}-${m[3]}`; const d = new Date(t); if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10); return t; } // QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks // and download confirmations, while the rest of the app stays usable. export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) { const [service, setService] = useState('lotw'); // The callsign this profile signs/uploads/downloads as for the selected // service (Force station callsign, else the profile call). Shown so the user // knows WHICH of their calls a download/upload targets in a mixed-call log. const [uploadCall, setUploadCall] = useState(''); useEffect(() => { if (service === 'pota' || service === 'paper') { setUploadCall(''); return; } UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall('')); }, [service]); const [potaSyncing, setPotaSyncing] = useState(false); const [potaRes, setPotaRes] = useState(null); const [potaErr, setPotaErr] = useState(''); const [potaAddMissing, setPotaAddMissing] = useState(false); // Only sync hunts made under the active profile's callsign (skip XV9Q/NQ2H…). const [potaOnlyMyCall, setPotaOnlyMyCall] = useState(true); async function syncPota() { setPotaSyncing(true); setPotaErr(''); setPotaRes(null); try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing, potaOnlyMyCall)) as any as POTASync); } catch (e: any) { setPotaErr(String(e?.message ?? e)); } finally { setPotaSyncing(false); } } // ── Paper QSL: search a callsign, bulk-set sent/received + via + date ── const [paperCall, setPaperCall] = useState(''); const [paperRows, setPaperRows] = useState([]); const [paperSel, setPaperSel] = useState>(new Set()); const [paperBusy, setPaperBusy] = useState(false); const [paperMsg, setPaperMsg] = useState(''); const [qslRcvd, setQslRcvd] = useState('Y'); const [qslSent, setQslSent] = useState('_'); const [qslRcvdDate, setQslRcvdDate] = useState(''); const [qslSentDate, setQslSentDate] = useState(''); const [qslVia, setQslVia] = useState(''); const searchPaper = useCallback(async () => { const c = paperCall.trim().toUpperCase(); if (!c) return; setPaperBusy(true); setPaperMsg(''); try { const r: any = await ListQSO({ callsign: c, limit: 1000 } as any); const list = (r ?? []) as LogQSO[]; setPaperRows(list); setPaperSel(new Set(list.map((x) => x.id))); } catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); } finally { setPaperBusy(false); } }, [paperCall]); function togglePaper(id: number) { setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); } const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length; async function applyPaper() { const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id); if (ids.length === 0) return; setPaperBusy(true); setPaperMsg(''); const ymd = (d: string) => d.replaceAll('-', ''); try { const n = await BulkUpdateQSL(ids as any, { sent_status: qslSent === '_' ? '' : qslSent, rcvd_status: qslRcvd === '_' ? '' : qslRcvd, sent_date: ymd(qslSentDate), rcvd_date: ymd(qslRcvdDate), via: qslVia, } as any); setPaperMsg(`${n} QSO updated.`); await searchPaper(); } catch (e: any) { setPaperMsg(String(e?.message ?? e)); } finally { setPaperBusy(false); } } const [sent, setSent] = useState('R'); const [rows, setRows] = useState([]); // Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly. const gridRef = useRef>(null); const [selectedCount, setSelectedCount] = useState(0); const selectAllNext = useRef(false); // selectAll once after the next data load const [searching, setSearching] = useState(false); const [error, setError] = useState(''); const [addNotFound, setAddNotFound] = useState(false); // Download date window: 'last' = incremental since last pull, 'date' = from a // chosen date, 'all' = everything. const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last'); const [sinceDate, setSinceDate] = useState(''); const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload'); const [confirmations, setConfirmations] = useState([]); const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot const [showLog, setShowLog] = useState(false); const [logLines, setLogLines] = useState([]); const [busy, setBusy] = useState(false); const [logAction, setLogAction] = useState<'upload' | 'download'>('upload'); useEffect(() => { const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line])); const offDone = EventsOn('qslmgr:done', (d: any) => { setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]); setBusy(false); }); const offConf = EventsOn('qslmgr:confirmations', (list: any) => { setConfirmations((list ?? []) as Confirmation[]); setViewMode('confirmations'); }); return () => { offLog(); offDone(); offConf(); }; }, []); const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); // Grid selection → just track the count; ids are read from the grid at upload. function onUploadSelChanged() { setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0); } // After "Select required" loads new rows, select them all (the old default). function onUploadRowsLoaded() { if (selectAllNext.current) { selectAllNext.current = false; gridRef.current?.api?.selectAll(); } } const shownConfs = useMemo(() => confirmations.filter((c) => { switch (confFilter) { case 'new': return c.new_dxcc || c.new_band || c.new_slot; case 'dxcc': return c.new_dxcc; case 'band': return c.new_band; case 'slot': return c.new_slot; default: return true; } }), [confirmations, confFilter]); const selectRequired = useCallback(async () => { setSearching(true); setError(''); try { const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); const list = (r ?? []) as UploadRow[]; selectAllNext.current = true; // pre-select everything once the grid renders setRows(list); setSelectedCount(list.length); setViewMode('upload'); setShowLog(false); } catch (e: any) { setError(String(e?.message ?? e)); setRows([]); setSelectedCount(0); } finally { setSearching(false); } }, [service, sent]); async function upload() { const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id); if (ids.length === 0) return; setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true); try { await UploadQSOsManual(service, ids); } catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); } } async function download() { // Resolve the date window into the backend's `since` argument: // 'all' → "" (everything) // 'last' → "last" (incremental since last successful pull) // 'date' → "YYYY-MM-DD" (the chosen date; falls back to all if empty) const since = sinceMode === 'last' ? 'last' : sinceMode === 'date' ? sinceDate.trim() : ''; setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true); try { await DownloadConfirmations(service, addNotFound, since); } catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); } } function viewResults() { setShowLog(false); if (logAction === 'upload') selectRequired(); } return (
{/* Search toolbar */}
{uploadCall && (
{uploadCall}
)} {service === 'pota' ? ( <> Token in Settings → External services → POTA. ) : service === 'paper' ? ( <>
setPaperCall(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }} placeholder="e.g. DL1ABC" />
Find a callsign, then set QSL sent/received + via + date on the selection. ) : ( <>
)}
{service === 'pota' && potaRes && ( {potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched{potaRes.skipped_other_call > 0 ? ` · ${potaRes.skipped_other_call} other call` : ''} / {potaRes.fetched} )} {service === 'paper' && paperRows.length > 0 && ( {paperRows.length} QSO · {paperSel.size} selected )} {!showLog && viewMode === 'confirmations' && (
)} {logLines.length > 0 && ( )} {viewMode === 'confirmations' ? `${shownConfs.length} / ${confirmations.length} confirmation(s)` : `${rows.length} found · ${selectedCount} selected`}
{/* Content: log OR results grid */}
{error &&
{error}
} {service === 'paper' ? ( paperRows.length === 0 ? (
Search a callsign to list its QSOs, then set QSL status below.
) : ( {paperRows.map((r) => ( togglePaper(r.id)}> ))}
setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /> Date UTCCallsign BandMode QSL SentQSL RcvdVia
e.stopPropagation()}> togglePaper(r.id)} /> {fmtDate(r.qso_date)} {r.callsign} {r.band} {r.mode} {r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''} {r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''} {r.qsl_via}
) ) : service === 'pota' ? (
{potaErr &&
{potaErr}
} {!potaRes && !potaErr && !potaSyncing && (
Click “Sync hunter log” to fetch your pota.app log and stamp park references.
)} {potaSyncing &&
Syncing with pota.app…
} {potaRes && ( <>
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries). {potaRes.skipped_other_call > 0 && ( <> {potaRes.skipped_other_call} hunt(s) made under another callsign were skipped{potaRes.my_call ? ` (kept only ${potaRes.my_call})` : ''}. )} {' '}Rescan the POTA award to count the new references.
{potaRes.unmatched_list?.length > 0 && ( {potaRes.unmatched_list.map((u, i) => ( 0 && 'cursor-pointer hover:bg-accent/30')} onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)} title={u.qso_id > 0 ? 'Open this QSO to fix it' : ''}> ))}
ActivatorDate UTC BandPark Why unmatched
{u.activator} {u.date} {u.band} {u.reference} {u.reason} {u.qso_id > 0 && }
)} )}
) : showLog ? (
{logLines.length === 0 ? (
starting…
) : logLines.map((l, i) => (
{l}
))} {busy &&
working…
}
) : viewMode === 'confirmations' ? ( shownConfs.length === 0 ? (
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
) : ( {shownConfs.map((c, i) => ( ))}
Date UTCCallsign BandMode CountryNew?
{fmtDate(c.qso_date)} {c.callsign} {c.band} {c.mode} {c.country} {c.new_dxcc ? NEW DXCC : c.new_band ? NEW BAND : c.new_slot ? NEW SLOT : }
) ) : rows.length === 0 ? (
Pick a service + sent status, then “Select required”.
) : (
ref={gridRef} theme={qslTheme} rowData={rows} columnDefs={UPLOAD_COLS} defaultColDef={{ sortable: true, resizable: true, filter: true }} rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }} onSelectionChanged={onUploadSelChanged} onRowDataUpdated={onUploadRowsLoaded} animateRows={false} suppressCellFocus getRowId={(p) => String((p.data as any).id)} />
)}
{/* Paper-QSL apply form */} {service === 'paper' && (
setQslRcvdDate(e.target.value)} title="QSL received date" />
setQslSentDate(e.target.value)} title="QSL sent date" />
setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
{paperMsg && {paperMsg}}
)} {/* Action bar (upload/download — not for POTA / Paper QSL) */} {service !== 'pota' && service !== 'paper' && (
{/* Date window */} {sinceMode === 'date' && ( setSinceDate(e.target.value)} className="h-8 rounded-md border border-input bg-background px-2 text-xs" title={service === 'qrz' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'} /> )}
)}
); }