630 lines
32 KiB
TypeScript
630 lines
32 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<{ slots_worked: number; slots_confirmed: number; dxcc_worked: number; dxcc_confirmed: number }>(
|
|
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_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). */}
|
|
<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
|
|
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
|
|
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>
|
|
);
|
|
}
|