1. A language change rebuilt the column defs, which sets the anti-clobber guard, but the effect that clears it listened to only two of the rebuild's causes. Guard stuck on → every column change was silently discarded for the rest of the session. Now keyed on the rebuilt defs themselves, so any future cause is covered. 2. The cluster grid had no guard at all: the same rebuild wrote its defaults over the saved layout, in the cache AND the database. Unrecoverable. 3. Worked-before and cluster read their state before the active profile was known, so they looked up the unscoped cache key (always a miss) and then saved under the scoped one. They now wait for the scope and remount on a profile switch, like the main log. 4. GetUIPref returns an ERROR, not "", while the settings store is not yet scoped — deliberately distinct from "unset". The frontend read it as "no preference", rendered defaults, and the first column event overwrote the good copy. It now retries instead of giving up. 5. The QSL manager had no storageKey, so it shared the main log's layout and each rewrote the other. Also: the DB write is debounced (a resize drag fired dozens of writes) with a flush on close, and a rejected write is retried rather than dropped.
765 lines
50 KiB
TypeScript
765 lines
50 KiB
TypeScript
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, ListChecks } 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';
|
|
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
|
|
|
// 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;
|
|
// Operator's CURRENT locator — fallback for the distance column on older QSOs
|
|
// that carry no my_grid of their own.
|
|
myGrid?: string;
|
|
// 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;
|
|
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
|
// Control auto-advancing to the next on-air station after logging one).
|
|
selectRowSignal?: { id: number; seq: number };
|
|
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
|
// row onto the roster to log it). Pair with onGridApi so the parent can
|
|
// register external drop zones via api.addRowDropZone.
|
|
rowDragCall?: boolean;
|
|
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
|
|
// is the display order — a pinned "#" column numbers it, and all column
|
|
// sorting is disabled (including saved sorts) so the queue can't be shuffled
|
|
// by a header click. The parent owns/reorders the array.
|
|
passOrder?: boolean;
|
|
onGridApi?: (api: any) => void;
|
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
|
// reuse this grid elsewhere with its OWN independent column config.
|
|
storageKey?: string;
|
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
|
onRowClicked?: (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;
|
|
onExportSelectedFields?: (ids: number[]) => void;
|
|
onExportFiltered?: () => void;
|
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
|
onExportCabrilloFiltered?: () => void;
|
|
onDelete?: (ids: number[]) => void;
|
|
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
|
// icons), or null when no column filter is active — so the parent's "Showing X
|
|
// of Y" can reflect them. Fired on filter change and when the data updates.
|
|
onFilteredCountChange?: (count: number | null) => 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 BASE_COLSTATE_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.
|
|
// 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, string | number>) => string;
|
|
|
|
// qsoDistanceKm returns the great-circle distance for one row, in km.
|
|
//
|
|
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
|
|
// portable outings, so the station the QSO was made from is not necessarily the
|
|
// one configured today. `myGrid` (the current profile) is only the fallback for
|
|
// the many older records that carry no my_* fields at all.
|
|
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
|
if (!d) return undefined;
|
|
const here =
|
|
(d.my_grid && gridToLatLon(d.my_grid)) ||
|
|
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
|
|
(myGrid ? gridToLatLon(myGrid) : null);
|
|
const there =
|
|
(d.grid && gridToLatLon(d.grid)) ||
|
|
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
|
|
if (!here || !there) return undefined;
|
|
return Math.round(pathBetweenLatLon(here, there).distanceShort);
|
|
}
|
|
|
|
export const makeColCatalog = (t: TFn, myGrid?: string): 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' },
|
|
// Derived, not stored: computed from the two locations at display time, like
|
|
// the cluster grid's own distance column.
|
|
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
|
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
|
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
|
{ 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 },
|
|
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
|
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
|
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
|
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
|
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
|
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
|
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
|
|
|
// ── 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<string, string> = {
|
|
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);
|
|
|
|
// Award columns are governed SOLELY by the awardShown code-set, never by AG
|
|
// Grid's saved column state. Stripping them here (on both save and restore)
|
|
// stops a stale saved state from re-hiding a shown award column on every
|
|
// awardCols rebuild — the desync that made award columns vanish mid-session.
|
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
|
|
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
|
const { t } = useI18n();
|
|
const gridRef = useRef<any>(null);
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
// Column-layout persistence keys — scoped by storageKey so a reused instance
|
|
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
|
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
|
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
|
|
|
// Localized column catalog — rebuilt when the language changes.
|
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
|
|
|
// 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);
|
|
|
|
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
|
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
|
// race the state restore, and AG Grid preserves an existing column's visibility
|
|
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
|
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
|
// is deterministic: it drives `hide` directly and is re-enforced below.
|
|
const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
|
|
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
|
useEffect(() => {
|
|
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
|
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
|
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
|
if (remote && remote.length) {
|
|
seedLocal(AWARD_SHOWN_KEY, remote);
|
|
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
|
}
|
|
});
|
|
}, []);
|
|
const persistAwardShown = useCallback((next: Set<string>) => {
|
|
setAwardShown(next);
|
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
|
}, []);
|
|
|
|
// Award-column WIDTHS are persisted separately too, for the same reason as
|
|
// visibility: award columns are stripped from AG Grid's saved column-state
|
|
// round-trip, so their width would otherwise reset to the default on reopen.
|
|
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
|
|
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
|
|
const awardWidthsRef = useRef<Record<string, number>>({});
|
|
const awardWidthsInit = useRef(false);
|
|
if (!awardWidthsInit.current) {
|
|
awardWidthsInit.current = true;
|
|
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
|
|
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
|
}
|
|
}
|
|
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
|
|
// apply them to any award columns already on screen.
|
|
useEffect(() => {
|
|
if (loadLocal(AWARD_WIDTH_KEY)) return;
|
|
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
|
|
if (!remote || !remote.length) return;
|
|
for (const it of remote as any[]) {
|
|
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
|
}
|
|
seedLocal(AWARD_WIDTH_KEY, remote);
|
|
const api = gridRef.current?.api;
|
|
if (api && awardCols?.length) {
|
|
const ups = awardCols
|
|
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
|
|
.filter((u) => !!u.newWidth);
|
|
if (ups.length) api.setColumnWidths(ups);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
|
restoringRef.current = true;
|
|
const base = COL_CATALOG.map((c) => {
|
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
|
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
|
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
|
col.rowDrag = true;
|
|
}
|
|
if (passOrder) {
|
|
delete (col as any).sort; // e.g. the date column's default 'desc'
|
|
col.sortable = false;
|
|
}
|
|
return col;
|
|
});
|
|
if (passOrder) {
|
|
base.unshift({
|
|
colId: '__order', headerName: '#', width: 46, pinned: 'left',
|
|
sortable: false, resizable: false, suppressMovable: true, filter: false,
|
|
cellClass: 'font-mono text-muted-foreground tabular-nums',
|
|
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
|
|
} as ColDef<QSOForm>);
|
|
}
|
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
|
colId: `award_${a.code}`,
|
|
headerName: a.code,
|
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
|
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
|
|
cellClass: 'text-[11px]',
|
|
// Visibility comes from the persisted award-code set, so a column the user
|
|
// showed reappears on reopen and one they didn't stays hidden.
|
|
hide: !awardShown.has(a.code.toUpperCase()),
|
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
|
}));
|
|
return [...base, ...awards];
|
|
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
|
|
|
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
|
// so we set it explicitly whenever the award set or the loaded columns change.
|
|
useEffect(() => {
|
|
const api = gridRef.current?.api;
|
|
if (!api || !awardCols?.length) return;
|
|
const widthUps: { key: string; newWidth: number }[] = [];
|
|
for (const a of awardCols) {
|
|
const want = awardShown.has(a.code.toUpperCase());
|
|
const col = api.getColumn(`award_${a.code}`);
|
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
|
// Re-apply the saved width — AG Grid keeps an existing column's width across
|
|
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
|
|
const w = awardWidthsRef.current[a.code.toUpperCase()];
|
|
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
|
|
}
|
|
if (widthUps.length) api.setColumnWidths(widthUps);
|
|
}, [awardCols, awardShown]);
|
|
|
|
const defaultColDef = useMemo<ColDef>(() => ({
|
|
sortable: !passOrder,
|
|
resizable: true,
|
|
filter: true,
|
|
suppressMovable: false,
|
|
}), [passOrder]);
|
|
|
|
// In pass-order mode a saved sort (from before the mode existed, or synced
|
|
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
|
|
const sanitizeState = useCallback((state: any[]) => (
|
|
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
|
|
), [passOrder]);
|
|
|
|
function onGridReady(e: GridReadyEvent) {
|
|
onGridApi?.(e.api);
|
|
const local = loadLocal(colStateKey);
|
|
if (local) e.api.applyColumnState({ state: sanitizeState(stripAwardCols(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(colStateKey).then((remote) => {
|
|
if (remote && !local) {
|
|
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
|
|
seedLocal(colStateKey, remote);
|
|
}
|
|
});
|
|
}
|
|
// Report the post-column-filter row count (funnel filters) to the parent, or
|
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
|
const api = e?.api ?? gridRef.current?.api;
|
|
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
|
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
|
|
// row node across a reorder (getRowId by id), so the number is cached and
|
|
// stale until we force it. Refresh it on every model update (a reorder
|
|
// fires one). force:true bypasses the value cache.
|
|
if (passOrder && api?.refreshCells) {
|
|
api.refreshCells({ columns: ['__order'], force: true });
|
|
}
|
|
if (!api || !onFilteredCountChange) return;
|
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
|
}, [onFilteredCountChange, passOrder]);
|
|
const saveColumnState = useCallback(() => {
|
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
|
const state = gridRef.current?.api?.getColumnState();
|
|
if (!state) return;
|
|
saveState(colStateKey, stripAwardCols(state));
|
|
// Award columns are stripped above, so persist their widths on the side.
|
|
let changed = false;
|
|
for (const s of state) {
|
|
const id = String((s as any)?.colId ?? '');
|
|
if (id.startsWith('award_') && (s as any).width) {
|
|
const code = id.slice('award_'.length).toUpperCase();
|
|
if (awardWidthsRef.current[code] !== (s as any).width) {
|
|
awardWidthsRef.current[code] = (s as any).width;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
|
|
}
|
|
}, [colStateKey, AWARD_WIDTH_KEY]);
|
|
|
|
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
|
// award column (both change the memo → restoringRef flips true at line 316). Each
|
|
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
|
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
|
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
|
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
|
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
|
useEffect(() => {
|
|
const api = gridRef.current?.api;
|
|
const local = loadLocal(colStateKey);
|
|
if (api && local) api.applyColumnState({ state: sanitizeState(stripAwardCols(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);
|
|
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
|
|
// causes here (awardCols/awardShown) missed the others — switching the UI
|
|
// language rebuilds the memo through `t`, which set restoringRef and left it
|
|
// set, so every column change for the rest of the session was silently
|
|
// discarded and the layout reverted on reload. Any future dependency of the
|
|
// memo is now covered for free.
|
|
}, [columnDefs]);
|
|
|
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
|
}
|
|
function onSelectionChanged() {
|
|
const api = gridRef.current?.api;
|
|
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
|
setSelCount(sel.length);
|
|
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
|
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]);
|
|
|
|
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
|
|
// tick so a row-data refresh in the same render settles first.
|
|
useEffect(() => {
|
|
if (!selectRowSignal || selectRowSignal.seq === 0) return;
|
|
const t = window.setTimeout(() => {
|
|
const api = gridRef.current?.api;
|
|
if (!api) return;
|
|
api.deselectAll();
|
|
api.forEachNode((n: any) => {
|
|
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
|
|
});
|
|
}, 0);
|
|
return () => window.clearTimeout(t);
|
|
}, [selectRowSignal?.seq]);
|
|
|
|
// ── 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 {
|
|
// Award columns: the persisted set is the source of truth (see awardShown).
|
|
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
|
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) {
|
|
// Award columns are driven by the persisted code set — update it and let the
|
|
// enforce effect apply visibility. This is what makes them stick across reopen.
|
|
if (colId.startsWith('award_')) {
|
|
const code = colId.slice('award_'.length).toUpperCase();
|
|
const next = new Set(awardShown);
|
|
if (visible) next.add(code); else next.delete(code);
|
|
persistAwardShown(next);
|
|
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
|
return;
|
|
}
|
|
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();
|
|
// Award columns are opt-in: reset hides them all.
|
|
persistAwardShown(new Set());
|
|
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
|
{/* Live selection count — visible without opening the context menu. */}
|
|
{selCount > 0 && (
|
|
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
|
{t('rqg.selectedCount', { n: selCount })}
|
|
</span>
|
|
)}
|
|
{/* Select every loaded row that passes the active column filters — so a
|
|
filtered view can be selected in one click (then send to LoTW, bulk
|
|
edit, export…). Once everything is selected the same button flips to
|
|
"Unselect all". */}
|
|
{(() => {
|
|
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
|
return (
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
|
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
|
onClick={() => {
|
|
const api: any = gridRef.current?.api;
|
|
if (!api) return;
|
|
if (allSelected) api.deselectAll();
|
|
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
|
else api.selectAll('filtered');
|
|
}}>
|
|
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
|
</Button>
|
|
);
|
|
})()}
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
|
title={t('rqg.clearFiltersTitle')}>
|
|
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
|
<Columns3 className="size-3.5" /> {t('rqg.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}
|
|
onFilterChanged={reportFilteredCount}
|
|
onModelUpdated={reportFilteredCount}
|
|
onColumnResized={saveColumnState}
|
|
onColumnMoved={saveColumnState}
|
|
onColumnPinned={saveColumnState}
|
|
onColumnVisible={saveColumnState}
|
|
onSortChanged={saveColumnState}
|
|
onRowDoubleClicked={handleRowDoubleClicked}
|
|
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
|
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}
|
|
onSendEQSL={onSendEQSL}
|
|
onBulkEdit={onBulkEdit}
|
|
onExportSelected={onExportSelected}
|
|
onExportSelectedFields={onExportSelectedFields}
|
|
onExportFiltered={onExportFiltered}
|
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
|
onDelete={onDelete}
|
|
/>
|
|
|
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('rqg.columns')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('rqg.pickerDesc')}
|
|
</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">{groupLabel(t, group)}</span>
|
|
<div className="flex gap-0.5">
|
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('rqg.all')}</button>
|
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('rqg.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>
|
|
);
|
|
})}
|
|
{awardCols && awardCols.length > 0 && (
|
|
<div 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">{t('rqg.grpAwards')}</span>
|
|
<div className="flex gap-0.5">
|
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
|
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
{awardCols.map((a) => (
|
|
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
|
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
|
|
<span className="font-mono font-semibold">{a.code}</span>
|
|
<span className="text-muted-foreground truncate">{a.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('rqg.resetDefaults')}</Button>
|
|
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('rqg.done')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|