1. A language change rebuilt the column defs, which sets the anti-clobber guard, but the effect that clears it listened to only two of the rebuild's causes. Guard stuck on → every column change was silently discarded for the rest of the session. Now keyed on the rebuilt defs themselves, so any future cause is covered. 2. The cluster grid had no guard at all: the same rebuild wrote its defaults over the saved layout, in the cache AND the database. Unrecoverable. 3. Worked-before and cluster read their state before the active profile was known, so they looked up the unscoped cache key (always a miss) and then saved under the scoped one. They now wait for the scope and remount on a profile switch, like the main log. 4. GetUIPref returns an ERROR, not "", while the settings store is not yet scoped — deliberately distinct from "unset". The frontend read it as "no preference", rendered defaults, and the first column event overwrote the good copy. It now retries instead of giving up. 5. The QSL manager had no storageKey, so it shared the main log's layout and each rewrote the other. Also: the DB write is debounced (a resize drag fired dozens of writes) with a flush on close, and a rejected write is retried rather than dropped.
353 lines
16 KiB
TypeScript
353 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
AllCommunityModule, ModuleRegistry,
|
||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||
} from 'ag-grid-community';
|
||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||
import { AgGridReact } from 'ag-grid-react';
|
||
import { Columns3, FilterX, Star } from 'lucide-react';
|
||
import {
|
||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||
} from '@/components/ui/dialog';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||
import { useI18n } from '@/lib/i18n';
|
||
|
||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||
|
||
const hamlogTheme = hamlogGridTheme;
|
||
|
||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||
|
||
type Props = {
|
||
// Operator's CURRENT locator — fallback for the distance column (see catalog).
|
||
myGrid?: string;
|
||
wb: WorkedBeforeView | null;
|
||
busy: boolean;
|
||
currentCall: string;
|
||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||
onUpdateFromCty?: (ids: number[]) => void;
|
||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||
onUpdateFromClublog?: (ids: number[]) => void;
|
||
onSendTo?: (service: string, ids: number[]) => void;
|
||
onSendRecording?: (ids: number[]) => void;
|
||
onSendEQSL?: (ids: number[]) => void;
|
||
onBulkEdit?: (ids: number[]) => void;
|
||
onExportSelected?: (ids: number[]) => void;
|
||
onExportSelectedFields?: (ids: number[]) => void;
|
||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||
onDelete?: (ids: number[]) => void;
|
||
// One column per defined award (cell = the reference this QSO counts for).
|
||
awardCols?: { code: string; name: string }[];
|
||
};
|
||
|
||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
||
|
||
function fmtDate(s: any): string {
|
||
if (!s) return '';
|
||
const d = new Date(s);
|
||
if (isNaN(d.getTime())) return '';
|
||
const p = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||
}
|
||
|
||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||
const { t } = useI18n();
|
||
const gridRef = useRef<any>(null);
|
||
const [pickerOpen, setPickerOpen] = useState(false);
|
||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||
|
||
// Localized column catalog (shared with the Recent QSOs grid).
|
||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||
|
||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||
}
|
||
function onCellContextMenu(e: any) {
|
||
const ev = e.event as MouseEvent | undefined;
|
||
ev?.preventDefault();
|
||
const api = gridRef.current?.api;
|
||
if (!api) return;
|
||
if (e.node && !e.node.isSelected()) {
|
||
api.deselectAll();
|
||
e.node.setSelected(true);
|
||
}
|
||
const ids = (api.getSelectedRows() as WorkedEntry[])
|
||
.map((r) => r.id as number)
|
||
.filter((n) => !!n);
|
||
if (ids.length === 0) return;
|
||
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
|
||
}
|
||
|
||
const hasCall = currentCall.trim() !== '';
|
||
const count = wb?.count ?? 0;
|
||
const entries = wb?.entries ?? [];
|
||
|
||
// Suppress auto-save while AG Grid rebuilds columns (award columns load async),
|
||
// so the default visibility doesn't clobber the user's saved state. See the
|
||
// matching note in RecentQSOsGrid.
|
||
const restoringRef = useRef(true);
|
||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
||
restoringRef.current = true;
|
||
const base = COL_CATALOG.map((c) => {
|
||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||
return { ...rest, hide: !defaultVisible };
|
||
});
|
||
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
|
||
colId: `award_${a.code}`,
|
||
headerName: a.code,
|
||
headerTooltip: t('wbg.awardTip', { name: a.name }),
|
||
width: 110,
|
||
cellClass: 'text-[11px]',
|
||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||
}));
|
||
return [...base, ...awards];
|
||
}, [awardCols, COL_CATALOG, t]);
|
||
|
||
const defaultColDef = useMemo<ColDef>(() => ({
|
||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||
}), []);
|
||
|
||
// Restore AFTER the profile scope is known: this grid has no key= remount to
|
||
// save it from reading the wrong (unscoped) cache key at first paint, so it
|
||
// used to miss the cache every time and then save under the scoped key.
|
||
async function onGridReady(e: GridReadyEvent) {
|
||
await whenGridPrefsReady();
|
||
const local = loadLocal(COL_STATE_KEY);
|
||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||
const remote = await loadRemote(COL_STATE_KEY);
|
||
if (remote && !local) {
|
||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||
seedLocal(COL_STATE_KEY, remote);
|
||
}
|
||
}
|
||
const saveColumnState = useCallback(() => {
|
||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||
const state = gridRef.current?.api?.getColumnState();
|
||
if (state) saveState(COL_STATE_KEY, state);
|
||
}, []);
|
||
|
||
// Re-apply the saved column state after the award columns load (they rebuild
|
||
// the column set), so the user's visibility choices win over the defaults.
|
||
useEffect(() => {
|
||
const api = gridRef.current?.api;
|
||
const local = loadLocal(COL_STATE_KEY);
|
||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||
return () => window.clearTimeout(t);
|
||
// columnDefs itself, not the reasons it was rebuilt — see the same note in
|
||
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
|
||
}, [columnDefs]);
|
||
|
||
function isColVisible(colId: string): boolean {
|
||
const col = gridRef.current?.api?.getColumn(colId);
|
||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||
}
|
||
function setColVisible(colId: string, visible: boolean) {
|
||
const api = gridRef.current?.api;
|
||
if (!api) return;
|
||
api.setColumnsVisible([colId], visible);
|
||
saveColumnState();
|
||
}
|
||
function showAll(group?: string) {
|
||
const api = gridRef.current?.api;
|
||
if (!api) return;
|
||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||
api.setColumnsVisible(ids, true);
|
||
saveColumnState();
|
||
}
|
||
function hideAll(group?: string) {
|
||
const api = gridRef.current?.api;
|
||
if (!api) return;
|
||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||
api.setColumnsVisible(ids, false);
|
||
saveColumnState();
|
||
}
|
||
function resetDefaults() {
|
||
const api = gridRef.current?.api;
|
||
if (!api) return;
|
||
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
|
||
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
|
||
api.setColumnsVisible(visible, true);
|
||
api.setColumnsVisible(hidden, false);
|
||
saveColumnState();
|
||
}
|
||
|
||
// Empty / loading / no-call states.
|
||
if (!hasCall) {
|
||
return (
|
||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||
{t('wbg.typeCall')}
|
||
</div>
|
||
);
|
||
}
|
||
if (busy && count === 0) {
|
||
return (
|
||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||
{t('wbg.checking')}
|
||
</div>
|
||
);
|
||
}
|
||
if (count === 0) {
|
||
return (
|
||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||
<Star className="size-8 text-primary fill-current" />
|
||
<div className="text-2xl font-bold text-primary tracking-wider">{t('wbg.new')}</div>
|
||
<div>{t('wbg.noPriorPre')}<span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>{t('wbg.noPriorPost')}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.workedBefore')}</span>
|
||
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
|
||
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
|
||
</div>
|
||
<div className="text-[11px] text-muted-foreground">
|
||
{t('wbg.first')} <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||
{t('wbg.last')} <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||
</div>
|
||
{wb?.dxcc_name && (
|
||
<div className="text-[11px] text-muted-foreground">
|
||
{t('wbg.dxcc')} <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
|
||
<span> · {t('wbg.entityQsos', { n: wb.dxcc_count })}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex-1" />
|
||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||
title={t('wbg.clearFiltersTitle')}>
|
||
<FilterX className="size-3.5" /> {t('wbg.clearFilters')}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||
<Columns3 className="size-3.5" /> {t('wbg.columns')}
|
||
</Button>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||
<div style={{ position: 'absolute', inset: 0 }}>
|
||
<AgGridReact<WorkedEntry>
|
||
ref={gridRef}
|
||
theme={hamlogTheme}
|
||
rowData={entries}
|
||
columnDefs={columnDefs}
|
||
defaultColDef={defaultColDef}
|
||
onGridReady={onGridReady}
|
||
onColumnResized={saveColumnState}
|
||
onColumnMoved={saveColumnState}
|
||
onColumnPinned={saveColumnState}
|
||
onColumnVisible={saveColumnState}
|
||
onSortChanged={saveColumnState}
|
||
onRowDoubleClicked={handleRowDoubleClicked}
|
||
onCellContextMenu={onCellContextMenu}
|
||
preventDefaultOnContextMenu
|
||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||
animateRows={false}
|
||
suppressCellFocus
|
||
getRowId={(p) => String((p.data as any).id)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
|
||
mean "the whole logbook under the active column filters", but this grid
|
||
is a per-callsign view rather than a filter over the log, so the entry
|
||
would quietly export everything — not what it would appear to do here.
|
||
The selection-based exports and bulk edit apply unchanged. */}
|
||
<QSOContextMenu
|
||
menu={menu}
|
||
onClose={() => setMenu(null)}
|
||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||
onUpdateFromClublog={onUpdateFromClublog}
|
||
onSendTo={onSendTo}
|
||
onSendRecording={onSendRecording}
|
||
onSendEQSL={onSendEQSL}
|
||
onBulkEdit={onBulkEdit}
|
||
onExportSelected={onExportSelected}
|
||
onExportSelectedFields={onExportSelectedFields}
|
||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||
onDelete={onDelete}
|
||
/>
|
||
|
||
{count > entries.length && (
|
||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
||
{t('wbg.olderQsos', { n: count - entries.length })}
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('wbg.pickerTitle')}</DialogTitle>
|
||
<DialogDescription>
|
||
{t('wbg.pickerDesc')}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||
{GROUP_ORDER.map((group) => {
|
||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||
if (cols.length === 0) return null;
|
||
return (
|
||
<div key={group} className="rounded-md border border-border p-2.5">
|
||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||
<div className="flex gap-0.5">
|
||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('wbg.all')}</button>
|
||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('wbg.none')}</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{cols.map((c) => (
|
||
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||
<Checkbox
|
||
checked={isColVisible(c.colId!)}
|
||
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
|
||
/>
|
||
{c.label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{awardCols && awardCols.length > 0 && (
|
||
<div className="rounded-md border border-border p-2.5">
|
||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.grpAwards')}</span>
|
||
<div className="flex gap-0.5">
|
||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('wbg.all')}</button>
|
||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('wbg.none')}</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{awardCols.map((a) => (
|
||
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
|
||
<span className="font-mono font-semibold">{a.code}</span>
|
||
<span className="text-muted-foreground truncate">{a.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('wbg.resetDefaults')}</Button>
|
||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('wbg.done')}</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|