593 lines
30 KiB
TypeScript
593 lines
30 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-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';
|
|
|
|
type UploadRow = {
|
|
id: number; qso_date: string; callsign: string;
|
|
band: string; mode: string; country: string; status: string;
|
|
};
|
|
|
|
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<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 [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<UploadRow[]>([]);
|
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
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');
|
|
|
|
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 selectedCount = selected.size;
|
|
const allSelected = rows.length > 0 && selected.size === rows.length;
|
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
|
|
|
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[];
|
|
setRows(list);
|
|
setSelected(new Set(list.map((x) => x.id)));
|
|
setViewMode('upload');
|
|
setShowLog(false);
|
|
} catch (e: any) {
|
|
setError(String(e?.message ?? e));
|
|
setRows([]);
|
|
setSelected(new Set());
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, [service, sent]);
|
|
|
|
function toggle(id: number) {
|
|
setSelected((s) => {
|
|
const n = new Set(s);
|
|
if (n.has(id)) n.delete(id); else n.add(id);
|
|
return n;
|
|
});
|
|
}
|
|
function toggleAll() {
|
|
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
|
}
|
|
|
|
async function upload() {
|
|
const ids = rows.filter((r) => selected.has(r.id)).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 (
|
|
<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">Service</label>
|
|
<Select value={service} onValueChange={setService}>
|
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
|
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={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">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="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
|
>
|
|
{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" />}
|
|
Sync hunter log
|
|
</Button>
|
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook">
|
|
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
|
|
Only my profile callsign
|
|
</label>
|
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
|
|
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
|
Add not-found QSOs to my log
|
|
</label>
|
|
<span className="text-[11px] text-muted-foreground self-center">Token in Settings → External services → POTA.</span>
|
|
</>
|
|
) : service === 'paper' ? (
|
|
<>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">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="e.g. DL1ABC" />
|
|
</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" />}
|
|
Search
|
|
</Button>
|
|
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</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}>{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" />}
|
|
Select required
|
|
</Button>
|
|
</>
|
|
)}
|
|
<div className="flex-1" />
|
|
{service === 'pota' && potaRes && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{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}
|
|
</span>
|
|
)}
|
|
{service === 'paper' && paperRows.length > 0 && (
|
|
<span className="text-xs text-muted-foreground">{paperRows.length} QSO · {paperSel.size} selected</span>
|
|
)}
|
|
{!showLog && viewMode === 'confirmations' && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
|
|
<Select value={confFilter} onValueChange={setConfFilter}>
|
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
<SelectItem value="new">New (any)</SelectItem>
|
|
<SelectItem value="dxcc">New DXCC</SelectItem>
|
|
<SelectItem value="band">New band</SelectItem>
|
|
<SelectItem value="slot">New slot</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" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
|
|
</Button>
|
|
)}
|
|
<span className="text-xs text-muted-foreground">
|
|
{viewMode === 'confirmations'
|
|
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
|
|
: `${rows.length} found · ${selectedCount} selected`}
|
|
</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-rose-700 mb-2">{error}</div>}
|
|
|
|
{service === 'paper' ? (
|
|
paperRows.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</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 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th>
|
|
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
|
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
|
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paperRows.map((r) => (
|
|
<tr key={r.id}
|
|
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
|
|
onClick={() => togglePaper(r.id)}>
|
|
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
|
|
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
|
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
|
<td className="py-1 px-2">{r.band}</td>
|
|
<td className="py-1 px-2">{r.mode}</td>
|
|
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
|
|
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
|
|
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)
|
|
) : 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">Click “Sync hunter log” to fetch your pota.app log and stamp park references.</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" /> Syncing with pota.app…</div>}
|
|
{potaRes && (
|
|
<>
|
|
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
|
|
{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.
|
|
</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">Activator</th><th className="py-1.5 px-2">Date UTC</th>
|
|
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Park</th>
|
|
<th className="py-1.5 px-2">Why unmatched</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 ? 'Open this QSO to fix it' : ''}>
|
|
<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" /> starting…</div>
|
|
) : logLines.map((l, i) => (
|
|
<div key={i} className={cn(
|
|
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
|
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
|
: 'text-foreground/90')}>{l}</div>
|
|
))}
|
|
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working…</div>}
|
|
</div>
|
|
) : viewMode === 'confirmations' ? (
|
|
shownConfs.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground py-10 text-center">
|
|
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
|
|
</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">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
|
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
|
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</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-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
|
|
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
|
|
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</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">Pick a service + sent status, then “Select required”.</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 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
|
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
|
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
|
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r) => (
|
|
<tr key={r.id}
|
|
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
|
onClick={() => toggle(r.id)}>
|
|
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
|
</td>
|
|
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
|
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
|
<td className="py-1 px-2">{r.band}</td>
|
|
<td className="py-1 px-2">{r.mode}</td>
|
|
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
|
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</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">QSL received</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}>{s.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title="QSL received date" />
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL sent</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}>{s.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title="QSL sent date" />
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
|
|
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
|
|
</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}>
|
|
Apply to {paperSel.size} selected
|
|
</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="Fetch confirmations from the service and update received status">
|
|
<DownloadCloud className="size-3.5" /> Download confirmations
|
|
</Button>
|
|
{/* Date window */}
|
|
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
|
<SelectTrigger className="h-8 w-[150px] text-xs" title="How far back to download">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="last">Since last download</SelectItem>
|
|
<SelectItem value="date">Since date…</SelectItem>
|
|
<SelectItem value="all">All</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' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'}
|
|
/>
|
|
)}
|
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
|
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
|
Add not-found
|
|
</label>
|
|
</div>
|
|
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
|
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|