329 lines
14 KiB
TypeScript
329 lines
14 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||
} from 'ag-grid-community';
|
||
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 { COL_CATALOG, GROUP_ORDER } from './RecentQSOsGrid';
|
||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||
|
||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||
|
||
const hamlogTheme = themeQuartz.withParams({
|
||
fontFamily: 'inherit',
|
||
fontSize: 12.5,
|
||
backgroundColor: '#faf6ea',
|
||
foregroundColor: '#2a2419',
|
||
headerBackgroundColor: '#e8dfc9',
|
||
headerTextColor: '#5a4f3a',
|
||
headerFontWeight: 600,
|
||
oddRowBackgroundColor: '#f5efe0',
|
||
rowHoverColor: '#ecdcb4',
|
||
selectedRowBackgroundColor: '#f0d9a8',
|
||
borderColor: '#c8b994',
|
||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||
cellHorizontalPadding: 10,
|
||
rowHeight: 30,
|
||
headerHeight: 32,
|
||
spacing: 4,
|
||
accentColor: '#b8410c',
|
||
iconSize: 12,
|
||
});
|
||
|
||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||
|
||
type Props = {
|
||
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;
|
||
// 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, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) {
|
||
const gridRef = useRef<any>(null);
|
||
const [pickerOpen, setPickerOpen] = useState(false);
|
||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||
|
||
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 ?? [];
|
||
|
||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
||
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: `${a.name} — reference this QSO counts for`,
|
||
width: 110,
|
||
cellClass: 'text-[11px]',
|
||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||
}));
|
||
return [...base, ...awards];
|
||
}, [awardCols]);
|
||
|
||
const defaultColDef = useMemo<ColDef>(() => ({
|
||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||
}), []);
|
||
|
||
function onGridReady(e: GridReadyEvent) {
|
||
const local = loadLocal(COL_STATE_KEY);
|
||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||
if (remote && !local) {
|
||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||
seedLocal(COL_STATE_KEY, remote);
|
||
}
|
||
});
|
||
}
|
||
const saveColumnState = useCallback(() => {
|
||
const state = gridRef.current?.api?.getColumnState();
|
||
if (state) saveState(COL_STATE_KEY, state);
|
||
}, []);
|
||
|
||
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">
|
||
Type a callsign in the entry strip to see prior contacts.
|
||
</div>
|
||
);
|
||
}
|
||
if (busy && count === 0) {
|
||
return (
|
||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||
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">NEW</div>
|
||
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</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">Worked before</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">
|
||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||
</div>
|
||
{wb?.dxcc_name && (
|
||
<div className="text-[11px] text-muted-foreground">
|
||
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
|
||
<span> · {wb.dxcc_count} entity QSOs</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex-1" />
|
||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||
title="Clear all column filters">
|
||
<FilterX className="size-3.5" /> Clear filters
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||
<Columns3 className="size-3.5" /> 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>
|
||
|
||
<QSOContextMenu
|
||
menu={menu}
|
||
onClose={() => setMenu(null)}
|
||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||
onUpdateFromClublog={onUpdateFromClublog}
|
||
onSendTo={onSendTo}
|
||
onSendRecording={onSendRecording}
|
||
onSendEQSL={onSendEQSL}
|
||
/>
|
||
|
||
{count > entries.length && (
|
||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
||
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Worked-before columns</DialogTitle>
|
||
<DialogDescription>
|
||
Pick the columns you want visible in the Worked-before table.
|
||
</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">{group}</span>
|
||
<div className="flex gap-0.5">
|
||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>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">Awards</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)); }}>all</button>
|
||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>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}>Reset to defaults</Button>
|
||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|