feat: Winkeyer

This commit is contained in:
2026-06-02 01:17:26 +02:00
parent 2eb77370e4
commit 2b4326b553
26 changed files with 3125 additions and 645 deletions
+56 -19
View File
@@ -4,13 +4,15 @@ import {
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Columns3 } from 'lucide-react';
import { Columns3, FilterX } from 'lucide-react';
import type { QSOForm } from '@/types';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
// Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
@@ -45,6 +47,8 @@ type Props = {
total: number;
onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (id: number | null) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
};
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
@@ -74,9 +78,11 @@ function fmtDateOnly(s: any): string {
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
// = shown out of the box; anything else stays hidden until the user toggles
// it in the Columns dialog.
type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
export type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
const COL_CATALOG: ColEntry[] = [
// Shared so the Worked-before grid (which now also shows full QSO records)
// can offer the exact same column choices without duplicating the catalog.
export const COL_CATALOG: ColEntry[] = [
// ── QSO basics ──
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
@@ -190,14 +196,33 @@ const COL_CATALOG: ColEntry[] = [
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
];
const GROUP_ORDER = [
export const GROUP_ORDER = [
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) {
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
// Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection.
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 QSOForm[])
.map((r) => r.id as number)
.filter((n) => !!n);
if (ids.length === 0) return;
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
}
// Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
@@ -215,21 +240,20 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
}), []);
function onGridReady(e: GridReadyEvent) {
try {
const raw = localStorage.getItem(COL_STATE_KEY);
if (raw) {
const state = JSON.parse(raw) as ColumnState[];
if (Array.isArray(state)) {
e.api.applyColumnState({ state, applyOrder: true });
}
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(COL_STATE_KEY).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
} catch {}
});
}
const saveColumnState = useCallback(() => {
try {
const state = gridRef.current?.api?.getColumnState();
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
@@ -281,6 +305,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
return (
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<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>
@@ -293,7 +321,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={onGridReady}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
@@ -302,6 +330,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
onSortChanged={saveColumnState}
onRowDoubleClicked={handleRowDoubleClicked}
onSelectionChanged={onSelectionChanged}
onCellContextMenu={onCellContextMenu}
preventDefaultOnContextMenu
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
@@ -309,6 +339,13 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
</div>
</div>
<QSOContextMenu
menu={menu}
onClose={() => setMenu(null)}
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
/>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
@@ -318,7 +355,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
Your selection is saved.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto py-2">
<div className="grid grid-cols-3 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;