482 lines
19 KiB
TypeScript
482 lines
19 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
|
} from 'ag-grid-community';
|
|
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';
|
|
|
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
|
|
|
const hamlogTheme = themeQuartz.withParams({
|
|
fontFamily: 'inherit',
|
|
fontSize: 12.5,
|
|
backgroundColor: '#faf6ea',
|
|
foregroundColor: '#2a2419',
|
|
headerBackgroundColor: '#e8dfc9',
|
|
headerTextColor: '#5a4f3a',
|
|
headerFontWeight: 600,
|
|
oddRowBackgroundColor: '#f5efe0',
|
|
rowHoverColor: '#ecdcb4',
|
|
selectedRowBackgroundColor: '#f0d9a8',
|
|
borderColor: '#c8b994',
|
|
rowBorder: { color: '#d8c9a8', width: 1 },
|
|
columnBorder: { color: '#d8c9a8', width: 1 },
|
|
cellHorizontalPadding: 10,
|
|
rowHeight: 30,
|
|
headerHeight: 32,
|
|
spacing: 4,
|
|
accentColor: '#b8410c',
|
|
iconSize: 12,
|
|
});
|
|
|
|
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;
|
|
};
|
|
|
|
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.
|
|
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
|
switch (s?.status) {
|
|
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
|
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
|
case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' };
|
|
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
|
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
|
}
|
|
}
|
|
|
|
const COL_CATALOG: ColEntry[] = [
|
|
{
|
|
group: 'Spot', label: 'Time', colId: 'time',
|
|
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
|
defaultVisible: true,
|
|
sort: 'desc',
|
|
cellStyle: { color: '#7a6b50' },
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Call', colId: 'call',
|
|
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
|
defaultVisible: true,
|
|
cellClass: 'font-mono',
|
|
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
|
|
// aligned with non-new rows). Text colour also flags worked-call vs new-call.
|
|
cellStyle: (p: any): any => (statusFor(p)?.status === 'new'
|
|
? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }
|
|
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
|
tooltipValueGetter: (p: any) => {
|
|
const s = statusFor(p);
|
|
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
|
},
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Status', colId: 'status',
|
|
headerName: 'Status', width: 96, 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.
|
|
valueGetter: (p: any) => {
|
|
const s = statusFor(p);
|
|
if (s?.status === 'new') return 'NEW DXCC';
|
|
if (s?.status === 'new-band') return 'NEW BAND';
|
|
if (s?.status === 'new-mode') return 'NEW MODE';
|
|
if (s?.status === 'new-slot') return 'NEW SLOT';
|
|
return s?.worked_call ? 'WKD CALL' : '';
|
|
},
|
|
cellRenderer: (p: any) => {
|
|
const b = statusBadge(statusFor(p));
|
|
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
|
return (
|
|
<span style={{
|
|
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
|
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
|
}}>{b.text}</span>
|
|
);
|
|
},
|
|
tooltipValueGetter: (p: any) => {
|
|
const s = statusFor(p);
|
|
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
|
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
|
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
|
if (s?.worked_call) return 'Already worked this call';
|
|
return undefined;
|
|
},
|
|
},
|
|
{
|
|
group: 'Spot', label: 'POTA', colId: 'pota',
|
|
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
|
defaultVisible: true,
|
|
cellStyle: { color: '#166534' },
|
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined),
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Freq', colId: 'freq',
|
|
headerName: '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: 'Band', colId: 'band',
|
|
headerName: 'Band', field: 'band' as any, width: 75,
|
|
defaultVisible: true,
|
|
cellClass: 'font-mono',
|
|
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
|
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
|
: undefined),
|
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined),
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Mode', colId: 'mode',
|
|
headerName: 'Mode', colSpan: undefined, width: 80,
|
|
defaultVisible: true,
|
|
cellClass: 'font-mono',
|
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
|
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
|
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
|
cellStyle: (p: any) => {
|
|
const st = statusFor(p)?.status;
|
|
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
|
// Status badge text tells them apart.
|
|
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
|
return undefined;
|
|
},
|
|
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
|
tooltipValueGetter: (p: any) => {
|
|
const st = statusFor(p)?.status;
|
|
if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)';
|
|
if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)';
|
|
return undefined;
|
|
},
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
|
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
|
|
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
|
cellStyle: { color: '#7a6b50' },
|
|
},
|
|
{
|
|
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
|
|
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
|
},
|
|
{
|
|
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
|
|
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
|
},
|
|
{
|
|
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
|
|
headerName: 'Dist 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: 'Short path (°)', colId: 'sp_deg',
|
|
headerName: 'SP°', 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: 'Long path (°)', colId: 'lp_deg',
|
|
headerName: 'LP°', 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: 'Country', colId: 'country',
|
|
headerName: '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: '#7a6b50' },
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Continent', colId: 'continent',
|
|
headerName: 'Cont', 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: '#7a6b50', fontSize: 10 },
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Spotter', colId: 'spotter',
|
|
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
|
defaultVisible: true,
|
|
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
|
cellStyle: { color: '#7a6b50' },
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Source', colId: 'source',
|
|
headerName: 'Source', field: 'source_name' as any, width: 100,
|
|
defaultVisible: true,
|
|
cellStyle: { color: '#9a8870', fontSize: 10 },
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Locator', colId: 'locator',
|
|
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
|
cellStyle: { color: '#7a6b50' },
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Comment', colId: 'comment',
|
|
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
|
|
defaultVisible: true,
|
|
cellStyle: { color: '#7a6b50' },
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Received at', colId: 'received_at',
|
|
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
|
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
|
},
|
|
{
|
|
group: 'Spot', label: 'Raw', colId: 'raw',
|
|
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
|
},
|
|
];
|
|
|
|
const GROUP_ORDER = ['Spot', 'Geo'];
|
|
|
|
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|
const gridRef = useRef<any>(null);
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
|
|
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
|
return { ...rest, hide: !defaultVisible };
|
|
}), []);
|
|
|
|
const defaultColDef = useMemo<ColDef>(() => ({
|
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
|
}), []);
|
|
|
|
// 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="Clear all column filters">
|
|
<FilterX className="size-3.5" /> Clear filters
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
|
<Columns3 className="size-3.5" /> Columns
|
|
</Button>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
<div style={{ position: 'absolute', inset: 0 }}>
|
|
<AgGridReact<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>Cluster columns</DialogTitle>
|
|
<DialogDescription>
|
|
Pick the columns you want visible in the Cluster table.
|
|
</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">{group}</span>
|
|
<div className="flex gap-0.5">
|
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
|
</div>
|
|
</div>
|
|
<div className="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}>Reset to defaults</Button>
|
|
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|