theme: self-heal the persisted theme after mount. The synchronous boot read only looks at localStorage, which is empty when the WebView cleared its storage or when syncPortablePrefs ran before the backend wired its settings store (GetUIPref returned "" with no error) — so a restart could silently land on the default light theme. ThemeProvider now re-reads the portable pref from the DB once the backend is up (retrying briefly to ride out a slow startup) and applies it, without clobbering a manual pick. ClusterGrid: the Call cell now uses a danger pill for NEW DXCC, consistent with the NEW BAND / NEW MODE pills, instead of a full-cell red fill. cellChip inherits the column's font size (no fixed 9px / height) so a pill around a callsign stays the same size as the plain callsigns beside it. DvkPanel: the voice-keyer TX indicator (LED + label) is red while transmitting, not orange. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
519 lines
22 KiB
TypeScript
519 lines
22 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
AllCommunityModule, ModuleRegistry,
|
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
|
} from 'ag-grid-community';
|
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
|
import { AgGridReact } from 'ag-grid-react';
|
|
import { Columns3, FilterX } from 'lucide-react';
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
|
|
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
|
|
|
const hamlogTheme = hamlogGridTheme;
|
|
|
|
export type ClusterSpot = {
|
|
source_id: number;
|
|
source_name: string;
|
|
spotter: string;
|
|
dx_call: string;
|
|
freq_khz: number;
|
|
freq_hz: number;
|
|
band?: string;
|
|
comment?: string;
|
|
locator?: string;
|
|
time_utc?: string;
|
|
country?: string;
|
|
continent?: string;
|
|
cqz?: number;
|
|
ituz?: number;
|
|
distance_km?: number;
|
|
sp_deg?: number;
|
|
lp_deg?: number;
|
|
received_at: string;
|
|
raw: string;
|
|
repeats?: number;
|
|
pota_ref?: string;
|
|
pota_name?: string;
|
|
};
|
|
|
|
export type SpotStatusEntry = {
|
|
status?: string;
|
|
country?: string;
|
|
continent?: string;
|
|
worked_call?: boolean;
|
|
new_county?: boolean;
|
|
new_pota?: boolean;
|
|
};
|
|
|
|
type Props = {
|
|
rows: ClusterSpot[];
|
|
spotStatus: Record<string, SpotStatusEntry>;
|
|
onSpotClick?: (s: ClusterSpot) => void;
|
|
};
|
|
|
|
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
|
|
|
|
// Extracts the prefix from a callsign — drops portable suffixes (/P, /MM
|
|
// etc.), keeps a slashed prefix (HB0/DL2SBY → HB0), and trims the trailing
|
|
// digits after the last letter group (DL2SBY → DL2).
|
|
function fmtPfx(call: string): string {
|
|
if (!call) return '';
|
|
const c = call.trim().toUpperCase();
|
|
const base = c.includes('/') ? c.split('/')[0] : c;
|
|
// If "base" is a callsign rather than a bare prefix (like DL2SBY), cut
|
|
// at the last digit to get DL2.
|
|
let lastDigit = -1;
|
|
for (let i = 0; i < base.length; i++) {
|
|
if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
|
|
}
|
|
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
|
}
|
|
|
|
// Renders an ISO timestamp (RFC3339 with nanoseconds) as a compact UTC
|
|
// "YYYY-MM-DD HH:MM:SS" string — matches the rest of the app's date style.
|
|
function fmtDateTimeUTC(s: any): string {
|
|
if (!s) return '';
|
|
const d = new Date(s);
|
|
if (isNaN(d.getTime())) return String(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())}:${p(d.getUTCSeconds())}`;
|
|
}
|
|
|
|
type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVisible?: boolean };
|
|
|
|
// statusFor resolves the precomputed spot status (new / new-band / new-slot /
|
|
// worked-call) for an ag-Grid cell's row.
|
|
function statusFor(p: any): SpotStatusEntry | undefined {
|
|
return p?.context?.spotStatus?.[
|
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
|
];
|
|
}
|
|
|
|
// statusBadge maps a resolved spot status to a short labelled badge for the
|
|
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
|
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
|
// there's nothing notable to show.
|
|
type Badge = { text: string; fg: string; bg: string; bd: string };
|
|
|
|
// tok builds a badge from a semantic status token (success/warning/caution/
|
|
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
|
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
|
function tok(name: string, text: string): Badge {
|
|
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
|
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
|
}
|
|
|
|
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
|
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
|
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
|
// null → plain text, no pill.
|
|
function cellChip(value: any, name: string | null): any {
|
|
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
|
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
|
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
|
// callsign stays the same size as the plain callsigns next to it — just tinted
|
|
// and rounded, not shrunk.
|
|
return (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
|
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
|
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
|
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
|
}}>{txt}</span>
|
|
);
|
|
}
|
|
|
|
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
|
switch (s?.status) {
|
|
case 'new': return tok('danger', t('clg2.newDxcc'));
|
|
case 'new-band': return tok('warning', t('clg2.newBand'));
|
|
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
|
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
|
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
|
}
|
|
}
|
|
|
|
const makeColCatalog = (t: TFn): ColEntry[] => [
|
|
{
|
|
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
|
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
|
defaultVisible: true,
|
|
sort: 'desc',
|
|
cellStyle: { color: 'var(--muted-foreground)' },
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
|
defaultVisible: true,
|
|
cellClass: 'font-mono',
|
|
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
|
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
|
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
|
// with the rest of the row instead of always shouting a colour.
|
|
cellRenderer: (p: any) => {
|
|
const s = statusFor(p);
|
|
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
|
const color = s?.worked_call ? 'var(--info)' : undefined;
|
|
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
|
},
|
|
tooltipValueGetter: (p: any) => {
|
|
const s = statusFor(p);
|
|
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
|
},
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
|
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
|
defaultVisible: true,
|
|
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
|
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
|
// and NEW POTA are orthogonal, so they stack as extra badges.
|
|
valueGetter: (p: any) => {
|
|
const s = statusFor(p);
|
|
const parts: string[] = [];
|
|
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
|
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
|
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
|
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
|
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
|
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
|
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
|
return parts.join(' ');
|
|
},
|
|
cellRenderer: (p: any) => {
|
|
const s = statusFor(p);
|
|
const badges: Badge[] = [];
|
|
const b = statusBadge(t, s);
|
|
if (b) badges.push(b);
|
|
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
|
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
|
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
|
return (
|
|
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
|
{badges.map((bd, i) => (
|
|
<span key={i} style={{
|
|
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
|
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
|
fontWeight: 700, fontSize: 9,
|
|
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
|
}}>{bd.text}</span>
|
|
))}
|
|
</span>
|
|
);
|
|
},
|
|
tooltipValueGetter: (p: any) => {
|
|
const s = statusFor(p);
|
|
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
|
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
|
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
|
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
|
return undefined;
|
|
},
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
|
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
|
defaultVisible: true,
|
|
cellStyle: { color: 'var(--success)' },
|
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
|
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
|
defaultVisible: true,
|
|
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
|
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
|
defaultVisible: true,
|
|
cellClass: 'font-mono',
|
|
// NEW BAND for this entity → small warning pill around the band text.
|
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
|
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
|
|
defaultVisible: true,
|
|
cellClass: 'font-mono',
|
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
|
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
|
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
|
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
|
// that case is signalled by the Status badge alone.
|
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
|
tooltipValueGetter: (p: any) => {
|
|
const st = statusFor(p)?.status;
|
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
|
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
|
return undefined;
|
|
},
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
|
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
|
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
|
cellStyle: { color: 'var(--muted-foreground)' },
|
|
},
|
|
{
|
|
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
|
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
|
},
|
|
{
|
|
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
|
|
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
|
},
|
|
{
|
|
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
|
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
|
},
|
|
{
|
|
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
|
|
headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
|
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
|
},
|
|
{
|
|
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
|
|
headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
|
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
|
|
headerName: t('clg2.c.country'), width: 140,
|
|
defaultVisible: true,
|
|
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
|
]?.country ?? '',
|
|
cellStyle: { color: 'var(--muted-foreground)' },
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
|
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
|
|
defaultVisible: true,
|
|
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
|
]?.continent ?? '',
|
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
|
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
|
defaultVisible: true,
|
|
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
|
cellStyle: { color: 'var(--muted-foreground)' },
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
|
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
|
defaultVisible: true,
|
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
|
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
|
cellStyle: { color: 'var(--muted-foreground)' },
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
|
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
|
defaultVisible: true,
|
|
cellStyle: { color: 'var(--muted-foreground)' },
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
|
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
|
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
|
},
|
|
{
|
|
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
|
|
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
|
},
|
|
];
|
|
|
|
const GROUP_ORDER = ['Spot', 'Geo'];
|
|
|
|
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
|
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
|
|
|
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|
const { t } = useI18n();
|
|
const gridRef = useRef<any>(null);
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
|
|
// Localized column catalog — rebuilt when the language changes.
|
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
|
|
|
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
|
return { ...rest, hide: !defaultVisible };
|
|
}), [COL_CATALOG]);
|
|
|
|
const defaultColDef = useMemo<ColDef>(() => ({
|
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
|
}), []);
|
|
|
|
// Pass spotStatus through AG Grid's context so cell renderers can look up
|
|
// per-cell highlight without re-rendering the whole grid when the map
|
|
// updates. We refresh cells whose values depend on it after each prop
|
|
// change below.
|
|
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
|
|
|
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
|
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
|
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
|
// turns blue once its status loads.
|
|
useEffect(() => {
|
|
gridRef.current?.api?.refreshCells({ force: true });
|
|
}, [spotStatus]);
|
|
|
|
function onGridReady(e: GridReadyEvent) {
|
|
const local = loadLocal(COL_STATE_KEY);
|
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
|
loadRemote(COL_STATE_KEY).then((remote) => {
|
|
if (remote && !local) {
|
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
|
seedLocal(COL_STATE_KEY, remote);
|
|
}
|
|
});
|
|
}
|
|
const saveColumnState = useCallback(() => {
|
|
const state = gridRef.current?.api?.getColumnState();
|
|
if (state) saveState(COL_STATE_KEY, state);
|
|
}, []);
|
|
|
|
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
|
if (e.data && onSpotClick) onSpotClick(e.data);
|
|
}
|
|
|
|
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={t('clg2.clearFiltersTitle')}>
|
|
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
|
<Columns3 className="size-3.5" /> {t('clg2.columns')}
|
|
</Button>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
<div style={{ position: 'absolute', inset: 0 }}>
|
|
<AgGridReact<ClusterSpot>
|
|
ref={gridRef}
|
|
theme={hamlogTheme}
|
|
rowData={rows}
|
|
columnDefs={columnDefs}
|
|
defaultColDef={defaultColDef}
|
|
context={context}
|
|
onGridReady={onGridReady}
|
|
onColumnResized={saveColumnState}
|
|
onColumnMoved={saveColumnState}
|
|
onColumnPinned={saveColumnState}
|
|
onColumnVisible={saveColumnState}
|
|
onSortChanged={saveColumnState}
|
|
onRowClicked={handleRowClicked}
|
|
animateRows={false}
|
|
suppressCellFocus
|
|
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('clg2.pickerDesc')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="max-h-[60vh] overflow-y-auto py-2">
|
|
{GROUP_ORDER.map((group) => {
|
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
|
if (cols.length === 0) return null;
|
|
return (
|
|
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
|
<div 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('clg2.all')}</button>
|
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 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}>{t('clg2.resetDefaults')}</Button>
|
|
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|