feat: Winkeyer
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user