Files
OpsLog/frontend/src/components/QSLManagerModal.tsx
T
rouggy 678c8821c2 fix: column layouts would not stick — five separate faults
1. A language change rebuilt the column defs, which sets the anti-clobber
   guard, but the effect that clears it listened to only two of the rebuild's
   causes. Guard stuck on → every column change was silently discarded for the
   rest of the session. Now keyed on the rebuilt defs themselves, so any future
   cause is covered.
2. The cluster grid had no guard at all: the same rebuild wrote its defaults
   over the saved layout, in the cache AND the database. Unrecoverable.
3. Worked-before and cluster read their state before the active profile was
   known, so they looked up the unscoped cache key (always a miss) and then
   saved under the scoped one. They now wait for the scope and remount on a
   profile switch, like the main log.
4. GetUIPref returns an ERROR, not "", while the settings store is not yet
   scoped — deliberately distinct from "unset". The frontend read it as "no
   preference", rendered defaults, and the first column event overwrote the
   good copy. It now retries instead of giving up.
5. The QSL manager had no storageKey, so it shared the main log's layout and
   each rewrote the other.

Also: the DB write is debounced (a resize drag fired dozens of writes) with a
flush on close, and a rejected write is retried rather than dropped.
2026-07-27 11:56:43 +02:00

636 lines
33 KiB
TypeScript

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, GetSlotStats } 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<UploadRow>[] = [
{ 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_mode: 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<string, string> = {
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<POTASync | null>(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<LogQSO[]>([]);
const [paperSel, setPaperSel] = useState<Set<number>>(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<any[]>([]);
const [selectedCount, setSelectedCount] = useState(0);
const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // 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<Confirmation[]>([]);
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
const [showLog, setShowLog] = useState(false);
const [logLines, setLogLines] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
const [stats, setStats] = useState<any>(
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0, ph_worked: 0, ph_confirmed: 0, cw_worked: 0, cw_confirmed: 0, dig_worked: 0, dig_confirmed: 0 });
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
useEffect(() => { refreshCounts(); }, [refreshCounts]);
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);
refreshCounts(); // a download may have added confirmations
});
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
setViewMode('confirmations');
refreshCounts();
});
return () => { offLog(); offDone(); offConf(); };
}, [refreshCounts]);
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_mode || c.new_slot;
case 'dxcc': return c.new_dxcc;
case 'band': return c.new_band;
case 'mode': return c.new_mode;
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 (
<div className="flex flex-col min-h-0 flex-1">
{/* Search toolbar */}
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.service')}</label>
<Select value={service} onValueChange={setService}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{SERVICE_LABEL_KEYS[s.v] ? t(SERVICE_LABEL_KEYS[s.v]) : s.label}</SelectItem>)}</SelectContent>
</Select>
</div>
{uploadCall && (
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
<span
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
title={t('qslm.callsignScopeTitle')}
>
{uploadCall}
</span>
</div>
)}
{service === 'pota' ? (
<>
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
{t('qslm.syncHunterLog')}
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.onlyMyCallTitle')}>
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
{t('qslm.onlyMyCall')}
</label>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.addMissingTitle')}>
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
{t('qslm.addMissing')}
</label>
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.potaToken')}</span>
</>
) : service === 'paper' ? (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
onChange={(e) => setPaperCall(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
placeholder={t('qslm.callsignPlaceholder')} />
</div>
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
{t('qslm.search')}
</Button>
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
</>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.sentStatus')}</label>
<Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
{t('qslm.selectRequired')}
</Button>
</>
)}
<div className="flex-1" />
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI), with the
per-class breakdown so the totals are checkable. */}
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
<span className="flex items-center gap-1">
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
<span className="font-semibold">{stats.slots_worked}</span>
<span className="text-muted-foreground">/</span>
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
</span>
<span className="flex items-center gap-1">
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
<span className="font-semibold">{stats.dxcc_worked}</span>
<span className="text-muted-foreground">/</span>
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
</span>
</div>
{service === 'pota' && potaRes && (
<span className="text-xs text-muted-foreground">
{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}
</span>
)}
{service === 'paper' && paperRows.length > 0 && (
<span className="text-xs text-muted-foreground">{t('qslm.paperCount', { total: paperRows.length, selected: paperSel.size })}</span>
)}
{!showLog && viewMode === 'confirmations' && (
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.filter')}</label>
<Select value={confFilter} onValueChange={setConfFilter}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('qslm.filterAll')}</SelectItem>
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
</SelectContent>
</Select>
</div>
)}
{logLines.length > 0 && (
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
{showLog ? <><ListChecks className="size-3.5" /> {t('qslm.results')}</> : <><ScrollText className="size-3.5" /> {t('qslm.log')}</>}
</Button>
)}
<span className="text-xs text-muted-foreground">
{viewMode === 'confirmations'
? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length })
: t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
</span>
</div>
{/* Content: log OR results grid */}
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
{error && <div className="text-xs text-danger mb-2">{error}</div>}
{service === 'paper' ? (
paperRows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.paperEmpty')}</div>
) : (
// Same grid as Recent QSOs (full columns + column picker). Selection
// drives which QSOs the apply-form below updates; a search selects all.
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
// Its OWN column layout. Without a storageKey this fell back to
// the main log's key, so every resize or hidden column here
// silently rewrote the Recent QSOs layout, and vice versa.
storageKey="qslmgr.paper"
rows={paperRows as any}
total={paperRows.length}
selectAllSignal={paperSelAllSig}
onRowSelected={(ids) => setPaperSel(new Set(ids))}
/>
</div>
)
) : service === 'pota' ? (
<div className="space-y-3">
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
{!potaRes && !potaErr && !potaSyncing && (
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.potaEmpty')}</div>
)}
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
{potaRes && (
<>
<div className="text-xs rounded-md px-3 py-2 border border-success-border bg-success-muted text-success-muted-foreground">
{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')}
</div>
{potaRes.unmatched_list?.length > 0 && (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">{t('qslm.thActivator')}</th><th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th>
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thPark')}</th>
<th className="py-1.5 px-2">{t('qslm.thWhyUnmatched')}</th>
</tr>
</thead>
<tbody>
{potaRes.unmatched_list.map((u, i) => (
<tr key={i}
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)}
title={u.qso_id > 0 ? t('qslm.openToFix') : ''}>
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
<td className="py-1 px-2 font-mono">{u.date}</td>
<td className="py-1 px-2">{u.band}</td>
<td className="py-1 px-2 font-mono">{u.reference}</td>
<td className="py-1 px-2 text-muted-foreground">
{u.reason}
{u.qso_id > 0 && <ExternalLink className="inline size-3 ml-1 opacity-60" />}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
) : showLog ? (
<div className="font-mono text-[11px] space-y-0.5 py-1">
{logLines.length === 0 ? (
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
) : logLines.map((l, i) => (
<div key={i} className={cn(
/FAIL|failed|error/i.test(l) ? 'text-danger'
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-success'
: 'text-foreground/90')}>{l}</div>
))}
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
</div>
) : viewMode === 'confirmations' ? (
shownConfs.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">
{confirmations.length === 0 ? t('qslm.noNewConf') : t('qslm.noConfMatch')}
</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
</tr>
</thead>
<tbody>
{shownConfs.map((c, i) => (
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2">
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
</tr>
))}
</tbody>
</table>
)
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.uploadEmpty')}</div>
) : (
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
storageKey="qslmgr.upload"
rows={rows as any}
total={rows.length}
selectAllSignal={uploadSelAllSig}
onRowSelected={onUploadRowSelected}
/>
</div>
)}
</div>
{/* Paper-QSL apply form */}
{service === 'paper' && (
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
<div className="flex gap-1.5">
<Select value={qslRcvd} onValueChange={setQslRcvd}>
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
</Select>
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslSent')}</label>
<div className="flex gap-1.5">
<Select value={qslSent} onValueChange={setQslSent}>
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
</Select>
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
<Select value={qslVia} onValueChange={setQslVia}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.comment')}</label>
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder={t('qslm.commentPlaceholder')} />
</div>
<div className="flex-1" />
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
{t('qslm.applyToSelected', { n: paperSel.size })}
</Button>
</div>
)}
{/* Action bar (upload/download — not for POTA / Paper QSL) */}
{service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={download} disabled={busy}
title={t('qslm.downloadTitle')}>
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
</Button>
{/* Date window */}
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last">{t('qslm.sinceLast')}</SelectItem>
<SelectItem value="date">{t('qslm.sinceDate')}</SelectItem>
<SelectItem value="all">{t('qslm.sinceAll')}</SelectItem>
</SelectContent>
</Select>
{sinceMode === 'date' && (
<input
type="date"
value={sinceDate}
onChange={(e) => 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')}
/>
)}
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.addNotFoundTitle')}>
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
{t('qslm.addNotFound')}
</label>
</div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
</Button>
</div>
)}
</div>
);
}