feat: day batch — upload results surfaced as toasts (silent LoTW failures from the right-click send looked like nothing was sent); live selection count + Select all/Unselect all toggle in the grid toolbar; active filters bypass the row limit (all matches shown, 10k safety cap); NET Control: same right-click menu on worked-before + drag&drop roster<->on-air (drop on roster logs the QSO); compact mode restores previous window size/position; advanced-filter field renamed Station callsign; optional DXCC-style digital-mode grouping (Settings->General) for matrix badges + cluster colouring; changelog 0.20.9 entries (version NOT bumped)

This commit is contained in:
2026-07-22 15:38:08 +02:00
parent 5aac28f564
commit 4c75680689
10 changed files with 284 additions and 39 deletions
+47 -5
View File
@@ -5,7 +5,7 @@ import {
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX } from 'lucide-react';
import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import {
@@ -33,6 +33,11 @@ type Props = {
// Bump `seq` to programmatically select the single row with this id (e.g. NET
// Control auto-advancing to the next on-air station after logging one).
selectRowSignal?: { id: number; seq: number };
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
// row onto the roster to log it). Pair with onGridApi so the parent can
// register external drop zones via api.addRowDropZone.
rowDragCall?: boolean;
onGridApi?: (api: any) => void;
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config.
@@ -252,7 +257,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -260,6 +265,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
// (e.g. the Net panel) keeps its own layout independent of the main log.
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
const [menu, setMenu] = useState<QSOMenuState>(null);
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
@@ -319,7 +326,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
col.rowDrag = true;
}
return col;
});
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
@@ -333,7 +344,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols, COL_CATALOG, t, awardShown]);
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
// Enforce award-column visibility via the API after the columns (re)appear —
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
@@ -356,6 +367,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
}), []);
function onGridReady(e: GridReadyEvent) {
onGridApi?.(e.api);
const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty
@@ -371,6 +383,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
// null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api;
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]);
@@ -400,7 +413,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
}
function onSelectionChanged() {
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
const api = gridRef.current?.api;
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
setSelCount(sel.length);
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
}
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
@@ -480,6 +496,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
return (
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
{/* Live selection count — visible without opening the context menu. */}
{selCount > 0 && (
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
{t('rqg.selectedCount', { n: selCount })}
</span>
)}
{/* Select every loaded row that passes the active column filters — so a
filtered view can be selected in one click (then send to LoTW, bulk
edit, export…). Once everything is selected the same button flips to
"Unselect all". */}
{(() => {
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
return (
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
onClick={() => {
const api: any = gridRef.current?.api;
if (!api) return;
if (allSelected) api.deselectAll();
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
else api.selectAll('filtered');
}}>
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
</Button>
);
})()}
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title={t('rqg.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}