import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react'; import { AllCommunityModule, ModuleRegistry, 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 { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { useI18n } from '@/lib/i18n'; ModuleRegistry.registerModules([AllCommunityModule]); 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' }, ]; // label holds an i18n key (resolved with t() at render time). const QSL_STATUSES = [ { v: '_', label: 'qslm.leave' }, { v: 'Y', label: 'qslm.yes' }, { v: 'N', label: 'qslm.no' }, { v: 'R', label: 'qslm.requested' }, { v: 'I', label: 'qslm.ignore' }, ]; // QSL routing methods for the paper-QSL "Via" dropdown (was free text). const QSL_VIA_OPTIONS = [ { v: '_', label: 'qslm.leave' }, { v: 'Bureau', label: 'qslm.viaBureau' }, { v: 'Direct', label: 'qslm.viaDirect' }, { v: 'Electronic', label: 'qslm.viaElectronic' }, ]; // Maps a service value → its i18n label key (only for services with // translatable names; brand names like QRZ.com stay as-is in SERVICES). const SERVICE_LABEL_KEYS: Record = { pota: 'qslm.svcPota', paper: 'qslm.svcPaper', }; 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 }; // label holds an i18n key. const SENT_STATUSES = [ { v: 'R', label: 'qslm.sentRequested' }, { v: 'N', label: 'qslm.sentNo' }, { v: 'Q', label: 'qslm.sentQueued' }, { v: 'Y', label: 'qslm.sentYes' }, { v: 'I', label: 'qslm.sentInvalid' }, { v: '_', label: 'qslm.sentBlank' }, ]; 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 { t } = useI18n(); 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 [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all const [paperBusy, setPaperBusy] = useState(false); const [paperMsg, setPaperMsg] = useState(''); const [qslRcvd, setQslRcvd] = useState('N'); const [qslSent, setQslSent] = useState('_'); const [qslRcvdDate, setQslRcvdDate] = useState(''); const [qslSentDate, setQslSentDate] = useState(''); const [qslVia, setQslVia] = useState('_'); const [qslNotes, setQslNotes] = useState(''); const [qslComment, setQslComment] = 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))); setPaperSelAllSig((n) => n + 1); // tell the grid to select every row } catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); } finally { setPaperBusy(false); } }, [paperCall]); 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 === '_' ? '' : qslVia, notes: qslNotes, comment: qslComment, } as any); setPaperMsg(t('qslm.qsoUpdated', { n })); await searchPaper(); } catch (e: any) { setPaperMsg(String(e?.message ?? e)); } finally { setPaperBusy(false); } } const [sent, setSent] = useState('R'); // Full QSO rows (so the upload view uses the same rich grid as Recent QSOs). const [rows, setRows] = useState([]); const [selectedCount, setSelectedCount] = useState(0); const [uploadSelIds, setUploadSelIds] = useState([]); // selected QSO ids → upload const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all 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 onUploadRowSelected(ids: number[]) { setUploadSelIds(ids); setSelectedCount(ids.length); } 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 any[]; setRows(list); setUploadSelIds(list.map((x) => x.id)); setSelectedCount(list.length); setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders 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 = uploadSelIds; 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' ? ( <> {t('qslm.potaToken')} ) : service === 'paper' ? ( <>
setPaperCall(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }} placeholder={t('qslm.callsignPlaceholder')} />
{t('qslm.paperHint')} ) : ( <>
)}
{service === 'pota' && potaRes && ( {t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched} )} {service === 'paper' && paperRows.length > 0 && ( {t('qslm.paperCount', { total: paperRows.length, selected: paperSel.size })} )} {!showLog && viewMode === 'confirmations' && (
)} {logLines.length > 0 && ( )} {viewMode === 'confirmations' ? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length }) : t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
{/* Content: log OR results grid */}
{error &&
{error}
} {service === 'paper' ? ( paperRows.length === 0 ? (
{t('qslm.paperEmpty')}
) : ( // Same grid as Recent QSOs (full columns + column picker). Selection // drives which QSOs the apply-form below updates; a search selects all.
setPaperSel(new Set(ids))} />
) ) : service === 'pota' ? (
{potaErr &&
{potaErr}
} {!potaRes && !potaErr && !potaSyncing && (
{t('qslm.potaEmpty')}
)} {potaSyncing &&
{t('qslm.potaSyncing')}
} {potaRes && ( <>
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })} {potaRes.skipped_other_call > 0 && ( <>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}. )} {' '}{t('qslm.potaRescan')}
{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 ? t('qslm.openToFix') : ''}> ))}
{t('qslm.thActivator')}{t('qslm.thDateUtc')} {t('qslm.thBand')}{t('qslm.thPark')} {t('qslm.thWhyUnmatched')}
{u.activator} {u.date} {u.band} {u.reference} {u.reason} {u.qso_id > 0 && }
)} )}
) : showLog ? (
{logLines.length === 0 ? (
{t('qslm.starting')}
) : logLines.map((l, i) => (
{l}
))} {busy &&
{t('qslm.working')}
}
) : viewMode === 'confirmations' ? ( shownConfs.length === 0 ? (
{confirmations.length === 0 ? t('qslm.noNewConf') : t('qslm.noConfMatch')}
) : ( {shownConfs.map((c, i) => ( ))}
{t('qslm.thDateUtc')}{t('qslm.thCallsign')} {t('qslm.thBand')}{t('qslm.thMode')} {t('qslm.thCountry')}{t('qslm.thNew')}
{fmtDate(c.qso_date)} {c.callsign} {c.band} {c.mode} {c.country} {c.new_dxcc ? {t('qslm.newDxcc')} : c.new_band ? {t('qslm.newBand')} : c.new_slot ? {t('qslm.newSlot')} : }
) ) : rows.length === 0 ? (
{t('qslm.uploadEmpty')}
) : (
)}
{/* Paper-QSL apply form */} {service === 'paper' && (
setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
setQslComment(e.target.value)} placeholder={t('qslm.commentPlaceholder')} />
{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' ? t('qslm.sinceDateTitleQrz') : t('qslm.sinceDateTitleLotw')} /> )}
)}
); }