333 lines
14 KiB
TypeScript
333 lines
14 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 } 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 = {
|
||
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;
|
||
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, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, 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), [t]);
|
||
|
||
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,
|
||
}), []);
|
||
|
||
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(() => {
|
||
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);
|
||
}, [awardCols]);
|
||
|
||
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>
|
||
|
||
<QSOContextMenu
|
||
menu={menu}
|
||
onClose={() => setMenu(null)}
|
||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||
onUpdateFromClublog={onUpdateFromClublog}
|
||
onSendTo={onSendTo}
|
||
onSendRecording={onSendRecording}
|
||
onSendEQSL={onSendEQSL}
|
||
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>
|
||
</>
|
||
);
|
||
}
|