This commit is contained in:
2026-06-05 17:22:38 +02:00
parent cf9dbf26f3
commit 88623f55df
21 changed files with 2123 additions and 50 deletions
+82 -34
View File
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X,
} from 'lucide-react';
import {
AddQSO, ListQSO, CountQSO,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
@@ -40,6 +40,8 @@ import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { MainMap } from '@/components/MainMap';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
@@ -435,8 +437,10 @@ export default function App() {
const [recording, setRecording] = useState(false);
const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState('');
const [filterBand, setFilterBand] = useState('');
const [filterMode, setFilterMode] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns).
const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
const [matchCount, setMatchCount] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState('recent');
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
const [qslTabOpen, setQslTabOpen] = useState(false);
@@ -667,20 +671,31 @@ export default function App() {
return () => window.clearInterval(id);
}, []);
// The full filter sent to the backend = the builder conditions + the quick
// callsign search box (always ANDed) + the on-screen row threshold.
const buildActiveFilter = useCallback((): QueryFilter => ({
quick_callsign: filterCallsign,
conditions: activeFilter.conditions ?? [],
match: activeFilter.match ?? 'AND',
limit: qsoLimit,
offset: 0,
}), [filterCallsign, activeFilter, qsoLimit]);
const refresh = useCallback(async () => {
try {
const list = await ListQSO({
callsign: filterCallsign, band: filterBand, mode: filterMode,
limit: qsoLimit, offset: 0,
} as any);
const f = buildActiveFilter();
const list = await ListQSOFiltered(f as any);
const n = await CountQSO();
const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
setQsos(list);
setTotal(n);
setMatchCount(matched);
setError('');
} catch (e: any) {
setError(String(e?.message ?? e));
}
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
}, [buildActiveFilter]);
// Refresh the Recent QSOs grid after external-service uploads stamp the
// sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
@@ -1181,6 +1196,27 @@ export default function App() {
try { await UploadQSOsManual(service, ids as any); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
// matches the current filter, bypassing the on-screen row threshold.
async function exportFilteredADIF() {
try {
const path = await SaveADIFFile();
if (!path) return;
const f = buildActiveFilter();
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any);
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Right-click "Export selected to ADIF": only the highlighted rows.
async function exportSelectedADIF(ids: number[]) {
if (ids.length === 0) return;
try {
const path = await SaveADIFFile();
if (!path) return;
const r = await ExportADIFSelected(path, false, ids as any);
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
function askDelete(id: number) {
const q = qsos.find((x) => x.id === id);
if (q) setDeletingQSO(q);
@@ -1394,7 +1430,7 @@ export default function App() {
case 'file.export': exportAdif(); break;
case 'file.deleteall': setShowDeleteAll(true); break;
case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setFilterBand(''); setFilterMode(''); break;
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
case 'edit.prefs': setShowSettings(true); break;
@@ -2190,20 +2226,6 @@ export default function App() {
value={filterCallsign}
onChange={(e) => setFilterCallsign(e.target.value)}
/>
<Select value={filterBand || '_'} onValueChange={(v) => setFilterBand(v === '_' ? '' : v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All bands</SelectItem>
{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
</SelectContent>
</Select>
<Select value={filterMode || '_'} onValueChange={(v) => setFilterMode(v === '_' ? '' : v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All modes</SelectItem>
{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="size-3.5" /> Refresh
</Button>
@@ -2264,14 +2286,36 @@ export default function App() {
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF}
onRowSelected={(id) => setSelectedId(id)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{total}</span>
{filterCallsign || filterBand || filterMode ? ' (filtered)' : ''}
</span>
<div className="flex items-center gap-3">
<Button
variant={(activeFilter.conditions?.length || filterCallsign) ? 'default' : 'outline'}
size="sm"
className="h-7 px-2 text-[11px] gap-1"
onClick={() => setFilterOpen(true)}
title="Build an advanced filter"
>
<SlidersHorizontal className="size-3.5" /> Filters
{activeFilter.conditions?.length ? (
<span className="ml-0.5 rounded-full bg-primary-foreground/20 px-1.5 font-mono">{activeFilter.conditions.length}</span>
) : null}
</Button>
{(activeFilter.conditions?.length || filterCallsign) ? (
<button
className="text-muted-foreground hover:text-foreground underline decoration-dotted"
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button>
) : null}
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span>
</div>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-amber-700">Limit reached raise Max to see more.</span>
@@ -2616,10 +2660,8 @@ export default function App() {
/>
</TabsContent>
<TabsContent value="awards" className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-base font-semibold text-foreground/70">Awards</div>
<div className="text-xs">Module coming soon.</div>
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel />
</TabsContent>
</Tabs>
</section>
@@ -2762,6 +2804,12 @@ export default function App() {
onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
/>
)}
<FilterBuilder
open={filterOpen}
initial={activeFilter}
onApply={(f) => { setActiveFilter(f); setFilterOpen(false); }}
onClose={() => setFilterOpen(false)}
/>
{showExportChoice && (
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
<DialogContent className="max-w-lg px-6">