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
+59 -69
View File
@@ -1,17 +1,20 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, Star } from 'lucide-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 } from '@/types';
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]);
@@ -37,23 +40,19 @@ const hamlogTheme = themeQuartz.withParams({
iconSize: 12,
});
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number];
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;
};
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1';
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
function fmtDateTime(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())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
function fmtDate(s: any): string {
if (!s) return '';
const d = new Date(s);
@@ -62,52 +61,29 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
const bandPill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
}}>{p.value}</span>
: '';
const modePill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
}}>{p.value}</span>
: '';
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
const flagRenderer = (p: any) => {
if (p.value === 'Y') {
return <span style={{
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
fontSize: 10, fontWeight: 700, lineHeight: '16px',
}}>Y</span>;
}
return <span style={{ color: '#a8a29e' }}></span>;
};
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
const COL_CATALOG: ColEntry[] = [
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
];
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ }: 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;
@@ -123,19 +99,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
}), []);
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 });
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 isColVisible(colId: string): boolean {
@@ -218,6 +193,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
</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>
@@ -237,6 +216,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
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)}
@@ -244,6 +227,13 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
</div>
</div>
<QSOContextMenu
menu={menu}
onClose={() => setMenu(null)}
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
/>
{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)
@@ -251,19 +241,19 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-md">
<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="max-h-[60vh] overflow-y-auto py-2">
<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 mb-2">
<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">