414 lines
27 KiB
TypeScript
414 lines
27 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 } 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/
|
|
// virtual-scroll — everything we want out of the box for a logbook table.
|
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
|
|
|
// Custom Quartz theme tuned to match OpsLog's warm palette.
|
|
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: 32,
|
|
headerHeight: 34,
|
|
spacing: 4,
|
|
accentColor: '#b8410c',
|
|
iconSize: 12,
|
|
});
|
|
|
|
type Props = {
|
|
rows: QSOForm[];
|
|
total: number;
|
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
|
onRowSelected?: (id: number | null) => void;
|
|
onUpdateFromCty?: (ids: number[]) => void;
|
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
|
onUpdateFromClublog?: (ids: number[]) => void;
|
|
onSendTo?: (service: string, ids: number[]) => void;
|
|
onSendRecording?: (ids: number[]) => void;
|
|
onExportSelected?: (ids: number[]) => void;
|
|
onExportFiltered?: () => void;
|
|
};
|
|
|
|
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
|
|
|
function fmtMhzDots(hz?: number): string {
|
|
if (!hz) return '';
|
|
const mhz = (hz / 1_000_000).toFixed(6);
|
|
const [i, f] = mhz.split('.');
|
|
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
|
}
|
|
|
|
function fmtDateUTC(s: any): string {
|
|
if (!s) return '';
|
|
const d = new Date(s);
|
|
if (isNaN(d.getTime())) return s;
|
|
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 fmtDateOnly(s: any): string {
|
|
if (!s) return '';
|
|
const t = String(s).trim();
|
|
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
|
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
const d = new Date(t);
|
|
if (isNaN(d.getTime())) return t;
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
|
}
|
|
|
|
// 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.
|
|
export type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
|
|
|
// 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) },
|
|
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
|
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
|
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
|
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
|
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
|
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
|
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
|
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
|
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
|
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
|
|
|
// ── Contacted station ──
|
|
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
|
|
{ group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true },
|
|
{ group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
|
|
{ group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true },
|
|
{ group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 },
|
|
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
|
|
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
|
|
{ group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
|
{ group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
|
|
{ group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
|
{ group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
|
|
{ group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 },
|
|
|
|
// ── QSL ──
|
|
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
|
|
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 },
|
|
{ group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
{ group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
{ group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
|
|
{ group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
|
|
{ group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 },
|
|
|
|
// ── LoTW ──
|
|
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
|
|
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 },
|
|
{ group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
{ group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
|
|
// ── eQSL ──
|
|
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
|
|
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
|
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
|
|
// ── Uploads (online logbooks) ──
|
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
|
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
|
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
|
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
|
{ group: 'Uploads', label: 'ClubLog sent', colId: 'clublog_qso_upload_status', headerName: 'ClubLog sent', field: 'clublog_qso_upload_status' as any, width: 100 },
|
|
{ group: 'Uploads', label: 'ClubLog sent date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog S date', field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
{ group: 'Uploads', label: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
|
{ group: 'Uploads', label: 'HRDLog sent date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog S date', field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
{ group: 'Uploads', label: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
|
{ group: 'Uploads', label: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
|
{ group: 'Uploads', label: 'QRZ.com sent date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com S date', field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
{ group: 'Uploads', label: 'QRZ.com rcvd date', colId: 'qrzcom_qso_download_date', headerName: 'QRZ.com R date', field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
|
|
|
// ── Contest ──
|
|
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
|
{ group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
|
|
{ group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' },
|
|
{ group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
|
|
{ group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 },
|
|
{ group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
|
|
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
|
|
{ group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 },
|
|
|
|
// ── Propagation / antenna ──
|
|
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
|
|
{ group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
|
|
{ group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
|
|
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
|
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
|
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
|
|
|
// ── My station (operator side) ──
|
|
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
|
{ group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
|
{ group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
|
{ group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
|
|
{ group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
|
|
{ group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
|
|
{ group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
|
{ group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
|
{ group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
|
{ group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
|
{ group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
|
{ group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
|
{ group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
|
{ group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
|
{ group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
|
|
{ group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
|
|
{ group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
|
|
{ group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
|
|
{ group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 },
|
|
|
|
// ── Misc ──
|
|
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
|
{ group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
|
|
{ group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
|
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
|
];
|
|
|
|
export const GROUP_ORDER = [
|
|
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
|
|
'Contest', 'Propagation', 'My station', 'Misc',
|
|
];
|
|
|
|
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: 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)
|
|
// overrides this so a previously toggled column wins.
|
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
|
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
|
return { ...rest, hide: !defaultVisible };
|
|
}), []);
|
|
|
|
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 });
|
|
// 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);
|
|
}
|
|
});
|
|
}
|
|
const saveColumnState = useCallback(() => {
|
|
const state = gridRef.current?.api?.getColumnState();
|
|
if (state) saveState(COL_STATE_KEY, state);
|
|
}, []);
|
|
|
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
|
}
|
|
function onSelectionChanged() {
|
|
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
|
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
|
}
|
|
|
|
// ── Column picker (visibility) ──
|
|
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
|
// state for "which columns are visible" — AG Grid's column state is the
|
|
// source of truth, and saveColumnState persists it.
|
|
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();
|
|
}
|
|
|
|
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>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
<div style={{ position: 'absolute', inset: 0 }}>
|
|
<AgGridReact<QSOForm>
|
|
ref={gridRef}
|
|
theme={hamlogTheme}
|
|
rowData={rows}
|
|
columnDefs={columnDefs}
|
|
defaultColDef={defaultColDef}
|
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
|
onGridReady={onGridReady}
|
|
onColumnResized={saveColumnState}
|
|
onColumnMoved={saveColumnState}
|
|
onColumnPinned={saveColumnState}
|
|
onColumnVisible={saveColumnState}
|
|
onSortChanged={saveColumnState}
|
|
onRowDoubleClicked={handleRowDoubleClicked}
|
|
onSelectionChanged={onSelectionChanged}
|
|
onCellContextMenu={onCellContextMenu}
|
|
preventDefaultOnContextMenu
|
|
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}
|
|
onExportSelected={onExportSelected}
|
|
onExportFiltered={onExportFiltered}
|
|
/>
|
|
|
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Columns</DialogTitle>
|
|
<DialogDescription>
|
|
Pick the columns you want visible in the Recent QSOs table.
|
|
Your selection is saved.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<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;
|
|
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>
|
|
);
|
|
})}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
|
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|