feat: status bar added

This commit is contained in:
2026-05-30 01:35:50 +02:00
parent 8f1ad126ac
commit 806b39970b
24 changed files with 1933 additions and 451 deletions
+173 -204
View File
@@ -1,8 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -28,7 +25,6 @@ const SERVICES = [
{ v: 'lotw', label: 'LoTW' },
];
// Sent-status filter values. Empty string = blank/none.
const SENT_STATUSES = [
{ v: 'R', label: 'Requested' },
{ v: 'N', label: 'No' },
@@ -46,7 +42,9 @@ function fmtDate(iso: string): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
// 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() {
const [service, setService] = useState('lotw');
const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]);
@@ -55,22 +53,20 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
// 'upload' shows the Select-required search results; 'confirmations' shows
// the rows returned by a Download.
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
const [logOpen, setLogOpen] = useState(false);
const [logTitle, setLogTitle] = useState('');
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
const [showLog, setShowLog] = useState(false);
const [logLines, setLogLines] = useState<string[]>([]);
const [uploadDone, setUploadDone] = useState(false);
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}`]);
setUploadDone(true);
setBusy(false);
});
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
@@ -81,16 +77,28 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
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]);
async function selectRequired() {
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))); // auto-select all found
setSelected(new Set(list.map((x) => x.id)));
setViewMode('upload');
setShowLog(false);
} catch (e: any) {
setError(String(e?.message ?? e));
setRows([]);
@@ -98,7 +106,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
} finally {
setSearching(false);
}
}
}, [service, sent]);
function toggle(id: number) {
setSelected((s) => {
@@ -114,209 +122,170 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
async function upload() {
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
if (ids.length === 0) return;
setLogLines([]);
setUploadDone(false);
setLogAction('upload');
setLogTitle('Uploading to ' + serviceLabel);
setLogOpen(true);
try {
await UploadQSOsManual(service, ids);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
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() {
setLogLines([]);
setUploadDone(false);
setLogAction('download');
setLogTitle('Downloading confirmations from ' + serviceLabel);
setLogOpen(true);
try {
await DownloadConfirmations(service, addNotFound);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
try { await DownloadConfirmations(service, addNotFound); }
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
function closeLog() {
setLogOpen(false);
// After an upload, refresh the search so uploaded QSOs drop out of the
// filter. After a download, leave the confirmations list on screen.
function viewResults() {
setShowLog(false);
if (logAction === 'upload') selectRequired();
}
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
return (
<>
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-4 pt-4">
<DialogTitle>QSL Manager</DialogTitle>
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription>
</DialogHeader>
{/* Search toolbar */}
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20">
<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>
<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}>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Select required
</Button>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">
{viewMode === 'confirmations'
? `${confirmations.length} confirmation(s) received`
: `${rows.length} found · ${selectedCount} selected`}
</span>
<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>
<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" />
{!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>
{/* Results grid */}
<div className="overflow-auto px-4 py-2 min-h-[200px]">
{error && <div className="text-xs text-rose-700 mb-2">{error}</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>}
{viewMode === 'confirmations' ? (
confirmations.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">No new confirmations.</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>
{confirmations.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>
<DialogFooter className="px-4 py-3 border-t border-border sm:justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={download} title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" />
Download confirmations
</Button>
<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>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
<UploadCloud className="size-3.5" />
Upload {selectedCount} to {serviceLabel}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Upload progress / log window */}
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{logTitle || 'Working…'}</DialogTitle>
<DialogDescription className="sr-only">Progress log.</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
{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(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div>
<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>
<DialogFooter>
<Button size="sm" onClick={closeLog} disabled={!uploadDone}>
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading</>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
) : 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>
{/* Action bar */}
<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">
<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>
<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>
);
}