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 } 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'; import { useI18n } from '@/lib/i18n'; // 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]); // Shared theme (follows the app palette); this table runs slightly taller rows. const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34 }); type Props = { rows: QSOForm[]; total: number; // Bump this number to programmatically select every row (e.g. after a search // in the QSL Manager, where the default is "all selected"). selectAllSignal?: number; onRowDoubleClicked?: (q: QSOForm) => void; onRowSelected?: (ids: number[]) => 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; onBulkEdit?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; onExportCabrilloSelected?: (ids: number[]) => void; onExportCabrilloFiltered?: () => void; onDelete?: (ids: number[]) => void; // One column per defined award; the cell shows the reference this QSO counts // for (from row.award_refs[CODE], attached by the parent). Hidden by default. awardCols?: { code: string; name: string }[]; }; 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 & { 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. // A factory taking the i18n `t()` so headerName/label strings are localized; // hooks can't run at module level, so the component calls this with its own t. type TFn = (key: string, vars?: Record) => string; export const makeColCatalog = (t: TFn): ColEntry[] => [ // ── QSO basics ── { group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) }, { group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.band'), colId: 'band', headerName: t('rqg.c.band'), field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.band_rx'), colId: 'band_rx', headerName: t('rqg.c.band_rx'), field: 'band_rx' as any, width: 75, cellClass: 'font-mono' }, { group: 'QSO', label: t('rqg.c.mode'), colId: 'mode', headerName: t('rqg.c.mode'), field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.submode'), colId: 'submode', headerName: t('rqg.c.submode'), field: 'submode' as any, width: 80, cellClass: 'font-mono' }, { group: 'QSO', label: t('rqg.c.freq_hz'), colId: 'freq_hz', headerName: t('rqg.h.freq_hz'), field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true }, { group: 'QSO', label: t('rqg.c.freq_rx_hz'), colId: 'freq_rx_hz', headerName: t('rqg.h.freq_rx_hz'), field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) }, { group: 'QSO', label: t('rqg.c.rst_sent'), colId: 'rst_sent', headerName: t('rqg.c.rst_sent'), field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.rst_rcvd'), colId: 'rst_rcvd', headerName: t('rqg.c.rst_rcvd'), field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.tx_pwr'), colId: 'tx_pwr', headerName: t('rqg.c.tx_pwr'), field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, // ── Contacted station ── { group: 'Contacted', label: t('rqg.c.name'), colId: 'name', headerName: t('rqg.c.name'), field: 'name' as any, width: 170, defaultVisible: true }, { group: 'Contacted', label: t('rqg.c.qth'), colId: 'qth', headerName: t('rqg.c.qth'), field: 'qth' as any, width: 200, defaultVisible: true }, { group: 'Contacted', label: t('rqg.c.address'), colId: 'address', headerName: t('rqg.c.address'), field: 'address' as any, width: 200 }, { group: 'Contacted', label: t('rqg.c.country'), colId: 'country', headerName: t('rqg.c.country'), field: 'country' as any, width: 150, defaultVisible: true }, { group: 'Contacted', label: t('rqg.c.state'), colId: 'state', headerName: t('rqg.c.state'), field: 'state' as any, width: 80 }, { group: 'Contacted', label: t('rqg.c.cnty'), colId: 'cnty', headerName: t('rqg.c.cnty'), field: 'cnty' as any, width: 130 }, { group: 'Contacted', label: t('rqg.c.cont'),colId: 'cont', headerName: t('rqg.h.cont'), field: 'cont' as any, width: 60 }, { group: 'Contacted', label: t('rqg.c.grid'), colId: 'grid', headerName: t('rqg.c.grid'), field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true }, { group: 'Contacted', label: t('rqg.c.gridsquare_ext'), colId: 'gridsquare_ext', headerName: t('rqg.h.gridsquare_ext'), field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.vucc_grids'),colId: 'vucc_grids', headerName: t('rqg.h.vucc_grids'), field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.dxcc'), colId: 'dxcc', headerName: t('rqg.c.dxcc'), field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.cqz'), colId: 'cqz', headerName: t('rqg.c.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.ituz'), colId: 'ituz', headerName: t('rqg.c.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.iota'), colId: 'iota', headerName: t('rqg.c.iota'), field: 'iota' as any, width: 80, cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.sota_ref'), colId: 'sota_ref', headerName: t('rqg.h.sota_ref'), field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.pota_ref'), colId: 'pota_ref', headerName: t('rqg.h.pota_ref'), field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' }, { group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 }, { group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 }, // ── QSL ── { group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 }, { group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 }, { group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 }, { group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 }, { group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 }, // ── LoTW ── { group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 }, { group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 }, { group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, // ── eQSL ── { group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 }, { group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 }, { group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc. { group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true }, // App-specific: when the QSO's audio recording was e-mailed to the station. { group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false }, // ── 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: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 }, { group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 }, { group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 }, { group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 }, { group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, // ── Contest ── { group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 }, { group: 'Contest', label: t('rqg.c.srx'), colId: 'srx', headerName: t('rqg.c.srx'), field: 'srx' as any, width: 60, type: 'rightAligned' }, { group: 'Contest', label: t('rqg.c.stx'), colId: 'stx', headerName: t('rqg.c.stx'), field: 'stx' as any, width: 60, type: 'rightAligned' }, { group: 'Contest', label: t('rqg.c.srx_string'), colId: 'srx_string', headerName: t('rqg.h.srx_string'), field: 'srx_string' as any, width: 100 }, { group: 'Contest', label: t('rqg.c.stx_string'), colId: 'stx_string', headerName: t('rqg.h.stx_string'), field: 'stx_string' as any, width: 100 }, { group: 'Contest', label: t('rqg.c.check'), colId: 'check', headerName: t('rqg.c.check'), field: 'check' as any, width: 70 }, { group: 'Contest', label: t('rqg.c.precedence'), colId: 'precedence', headerName: t('rqg.c.precedence'), field: 'precedence' as any, width: 90 }, { group: 'Contest', label: t('rqg.c.arrl_sect'),colId: 'arrl_sect', headerName: t('rqg.h.arrl_sect'), field: 'arrl_sect' as any, width: 90 }, // ── Propagation / antenna ── { group: 'Propagation', label: t('rqg.c.prop_mode'), colId: 'prop_mode', headerName: t('rqg.h.prop_mode'), field: 'prop_mode' as any, width: 80 }, { group: 'Propagation', label: t('rqg.c.sat_name'), colId: 'sat_name', headerName: t('rqg.h.sat_name'), field: 'sat_name' as any, width: 110 }, { group: 'Propagation', label: t('rqg.c.sat_mode'), colId: 'sat_mode', headerName: t('rqg.c.sat_mode'), field: 'sat_mode' as any, width: 80 }, { group: 'Propagation', label: t('rqg.c.ant_az'), colId: 'ant_az', headerName: t('rqg.h.ant_az'), field: 'ant_az' as any, width: 70, type: 'rightAligned' }, { group: 'Propagation', label: t('rqg.c.ant_el'), colId: 'ant_el', headerName: t('rqg.h.ant_el'), field: 'ant_el' as any, width: 70, type: 'rightAligned' }, { group: 'Propagation', label: t('rqg.c.ant_path'), colId: 'ant_path', headerName: t('rqg.h.ant_path'), field: 'ant_path' as any, width: 70 }, // ── My station (operator side) ── { group: 'My station', label: t('rqg.c.station_callsign'), colId: 'station_callsign', headerName: t('rqg.h.station_callsign'), field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true }, { group: 'My station', label: t('rqg.c.operator'), colId: 'operator', headerName: t('rqg.c.operator'),field: 'operator' as any, width: 100, cellClass: 'font-mono' }, { group: 'My station', label: t('rqg.c.my_grid'), colId: 'my_grid', headerName: t('rqg.c.my_grid'), field: 'my_grid' as any, width: 85, cellClass: 'font-mono' }, { group: 'My station', label: t('rqg.c.my_country'), colId: 'my_country', headerName: t('rqg.h.my_country'), field: 'my_country' as any, width: 130 }, { group: 'My station', label: t('rqg.c.my_state'), colId: 'my_state', headerName: t('rqg.c.my_state'),field: 'my_state' as any, width: 80 }, { group: 'My station', label: t('rqg.c.my_cnty'), colId: 'my_cnty', headerName: t('rqg.h.my_cnty'), field: 'my_cnty' as any, width: 110 }, { group: 'My station', label: t('rqg.c.my_iota'), colId: 'my_iota', headerName: t('rqg.c.my_iota'), field: 'my_iota' as any, width: 80, cellClass: 'font-mono' }, { group: 'My station', label: t('rqg.c.my_sota'), colId: 'my_sota_ref', headerName: t('rqg.c.my_sota'), field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' }, { group: 'My station', label: t('rqg.c.my_pota'), colId: 'my_pota_ref', headerName: t('rqg.c.my_pota'), field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' }, { group: 'My station', label: t('rqg.c.my_dxcc'), colId: 'my_dxcc', headerName: t('rqg.h.my_dxcc'),field: 'my_dxcc' as any, width: 80, type: 'rightAligned' }, { group: 'My station', label: t('rqg.c.my_cq_zone'), colId: 'my_cq_zone', headerName: t('rqg.h.my_cq_zone'), field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' }, { group: 'My station', label: t('rqg.c.my_itu_zone'), colId: 'my_itu_zone', headerName: t('rqg.h.my_itu_zone'), field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' }, { group: 'My station', label: t('rqg.c.my_lat'), colId: 'my_lat', headerName: t('rqg.c.my_lat'), field: 'my_lat' as any, width: 90, type: 'rightAligned' }, { group: 'My station', label: t('rqg.c.my_lon'), colId: 'my_lon', headerName: t('rqg.c.my_lon'), field: 'my_lon' as any, width: 90, type: 'rightAligned' }, { group: 'My station', label: t('rqg.c.my_street'), colId: 'my_street', headerName: t('rqg.h.my_street'), field: 'my_street' as any, width: 160 }, { group: 'My station', label: t('rqg.c.my_city'), colId: 'my_city', headerName: t('rqg.h.my_city'), field: 'my_city' as any, width: 130 }, { group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 }, { group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 }, { group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 }, // ── Misc ── { group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true }, { group: 'Misc', label: t('rqg.c.notes'), colId: 'notes', headerName: t('rqg.c.notes'), field: 'notes' as any, width: 240 }, { group: 'Misc', label: t('rqg.c.created'), colId: 'created_at', headerName: t('rqg.h.created'), field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) }, { group: 'Misc', label: t('rqg.c.updated'), colId: 'updated_at', headerName: t('rqg.h.updated'), 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', ]; // Localized display label for a column group (identifier stays English so it // keeps working as a filter key). const GRP_KEYS: Record = { QSO: 'rqg.grpQso', Contacted: 'rqg.grpContacted', QSL: 'rqg.grpQsl', LoTW: 'rqg.grpLotw', eQSL: 'rqg.grpEqsl', Uploads: 'rqg.grpUploads', Contest: 'rqg.grpContest', Propagation: 'rqg.grpProp', 'My station': 'rqg.grpMyStation', Misc: 'rqg.grpMisc', }; export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g); export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) { const { t } = useI18n(); const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); // Localized column catalog — rebuilt when the language changes. const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); // 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. // While AG Grid rebuilds columns (award columns load async), it fires column // events that would otherwise trigger a save of the DEFAULT visibility before // we re-apply the user's saved state — clobbering it. This flag suppresses the // auto-save during that window. Set in the memo (runs at render, before the // column events) and cleared by the re-apply effect below. const restoringRef = useRef(true); const columnDefs = useMemo[]>(() => { restoringRef.current = true; const base = COL_CATALOG.map((c) => { const { group: _g, label: _l, defaultVisible, ...rest } = c; return { ...rest, hide: !defaultVisible }; }); const awards: ColDef[] = (awardCols ?? []).map((a) => ({ colId: `award_${a.code}`, headerName: a.code, headerTooltip: t('rqg.awardTip', { name: a.name }), width: 110, cellClass: 'text-[11px]', // Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows // them whenever the saved column state doesn't cover them — a newly-added // award, an empty cache, or a rebuild that runs before the saved state is // re-applied — which is why "all award columns" kept reappearing. The user's // saved state (a column they explicitly showed) still overrides this. hide: true, valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '', })); return [...base, ...awards]; }, [awardCols, COL_CATALOG, t]); const defaultColDef = useMemo(() => ({ 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(() => { if (restoringRef.current) return; // ignore the events fired by a column rebuild const state = gridRef.current?.api?.getColumnState(); if (state) saveState(COL_STATE_KEY, state); }, []); // The award columns load asynchronously; when they arrive (or change) the // columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide` // default — wiping the user's saved visibility (award columns reappear, // manually-shown ones like LoTW sent vanish). Re-apply the saved state after // every rebuild so the user's choices win. No-op before the grid is ready. useEffect(() => { const api = gridRef.current?.api; const local = loadLocal(COL_STATE_KEY); if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); // Re-enable saving once AG Grid has settled the column events from the rebuild. const t = window.setTimeout(() => { restoringRef.current = false; }, 0); return () => window.clearTimeout(t); }, [awardCols]); function handleRowDoubleClicked(e: RowDoubleClickedEvent) { if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); } function onSelectionChanged() { const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); } // Select every row when the caller bumps selectAllSignal (QSL Manager search). useEffect(() => { if (selectAllSignal === undefined) return; gridRef.current?.api?.selectAll(); }, [selectAllSignal]); // ── 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 ( <>
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)} />
setMenu(null)} onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)} onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)} onUpdateFromClublog={onUpdateFromClublog} onSendTo={onSendTo} onSendRecording={onSendRecording} onSendEQSL={onSendEQSL} onBulkEdit={onBulkEdit} onExportSelected={onExportSelected} onExportFiltered={onExportFiltered} onExportCabrilloSelected={onExportCabrilloSelected} onExportCabrilloFiltered={onExportCabrilloFiltered} onDelete={onDelete} /> {t('rqg.columns')} {t('rqg.pickerDesc')}
{GROUP_ORDER.map((group) => { const cols = COL_CATALOG.filter((c) => c.group === group); if (cols.length === 0) return null; return (
{groupLabel(t, group)}
{cols.map((c) => ( ))}
); })} {awardCols && awardCols.length > 0 && (
{t('rqg.grpAwards')}
{awardCols.map((a) => ( ))}
)}
); }