rigs completed
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
import { useCallback, 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 } 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';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
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,
|
||||
cellRenderer: (p: any) => {
|
||||
if (!p.value) return '';
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const isNew = status?.status === 'new';
|
||||
const workedCall = !!status?.worked_call;
|
||||
const style: any = {
|
||||
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
|
||||
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
|
||||
};
|
||||
if (isNew) {
|
||||
style.backgroundColor = '#ffe4e6';
|
||||
style.color = '#9f1239';
|
||||
style.border = '1px solid #fda4af';
|
||||
} else if (workedCall) {
|
||||
style.color = '#0369a1';
|
||||
} else {
|
||||
style.color = '#b8410c';
|
||||
}
|
||||
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'flex items-center',
|
||||
cellRenderer: (p: any) => {
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const newBand = status?.status === 'new-band';
|
||||
const bg = newBand ? '#fde68a' : '#f0d9a8';
|
||||
const fg = newBand ? '#92400e' : '#7a4a14';
|
||||
return p.value
|
||||
? <span
|
||||
style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
||||
border: newBand ? '1px solid #f59e0b' : undefined,
|
||||
}}
|
||||
title={newBand ? 'NEW BAND for this entity' : undefined}
|
||||
>{p.value}</span>
|
||||
: '';
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Mode', colId: 'mode',
|
||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
||||
defaultVisible: true,
|
||||
cellClass: 'flex items-center',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
cellRenderer: (p: any) => {
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const newSlot = status?.status === 'new-slot';
|
||||
const bg = newSlot ? '#fef08a' : '#d1fae5';
|
||||
const fg = newSlot ? '#854d0e' : '#047857';
|
||||
return p.value
|
||||
? <span
|
||||
style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
||||
border: newSlot ? '1px solid #eab308' : undefined,
|
||||
}}
|
||||
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
|
||||
>{p.value}</span>
|
||||
: <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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]);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
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={() => 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,12 +31,27 @@ export function Menubar({ menus, onAction }: Props) {
|
||||
key={menu.name}
|
||||
open={openMenu === menu.name}
|
||||
onOpenChange={(o) => setOpenMenu(o ? menu.name : null)}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
onMouseEnter={() => {
|
||||
// Only switch on hover if a menu is already open.
|
||||
if (openMenu !== null && openMenu !== menu.name) setOpenMenu(menu.name);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// Desktop-menubar behaviour: when another menu is already
|
||||
// open, a click on a different trigger should switch to it
|
||||
// in one click. Without this Radix consumes the click to
|
||||
// close the current menu first, requiring a second click
|
||||
// to open the new one. We pre-empt by setting open state
|
||||
// synchronously and stopping the event from reaching the
|
||||
// default Radix toggle.
|
||||
if (openMenu !== null && openMenu !== menu.name) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenMenu(menu.name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 text-sm rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
openMenu === menu.name && 'bg-muted text-primary',
|
||||
@@ -44,7 +59,17 @@ export function Menubar({ menus, onAction }: Props) {
|
||||
>
|
||||
{menu.label}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4} className="min-w-[240px]">
|
||||
<DropdownMenuContent
|
||||
align="start" sideOffset={4} className="min-w-[240px]"
|
||||
onCloseAutoFocus={(e) => {
|
||||
// Radix re-focuses the trigger after close. Combined with our
|
||||
// focus-visible:ring style this leaves an orange outline around
|
||||
// the previously-clicked menu — looks like a stuck "selected"
|
||||
// state. We swallow the auto-focus and let the next interaction
|
||||
// decide where focus belongs.
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{menu.items.map((item, i) =>
|
||||
item.type === 'separator' ? (
|
||||
<DropdownMenuSeparator key={i} />
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Antenna as AntennaIcon, Radio, Plus, Trash2, Star,
|
||||
ChevronRight, ChevronDown, Edit2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ListOperatingTree, SaveOperatingStation, DeleteOperatingStation,
|
||||
SaveOperatingAntenna, DeleteOperatingAntenna,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Band = { band: string; is_default: boolean };
|
||||
type Antenna = {
|
||||
id: number;
|
||||
station_id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
bands: Band[];
|
||||
};
|
||||
type Station = {
|
||||
id: number;
|
||||
profile_id: number;
|
||||
name: string;
|
||||
tx_pwr?: number;
|
||||
sort_order: number;
|
||||
antennas?: Antenna[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** ADIF bands available to toggle, in display order (from ListsSettings). */
|
||||
bands: string[];
|
||||
/** External error sink — parent shows it next to the Save button. */
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
export function OperatingPanel({ bands, onError }: Props) {
|
||||
const [tree, setTree] = useState<Station[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// expanded keeps which stations show their antennas; everything open by
|
||||
// default so the user sees the full setup at a glance.
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
// editingId tracks the row currently in edit mode. Use a string namespace
|
||||
// to keep station ids and antenna ids in the same Set.
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const t = await ListOperatingTree();
|
||||
const list = (t ?? []) as Station[];
|
||||
setTree(list);
|
||||
setExpanded((prev) => {
|
||||
if (prev.size > 0) return prev;
|
||||
return new Set(list.map((s) => s.id));
|
||||
});
|
||||
} catch (e: any) {
|
||||
onError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
function toggleExpanded(id: number) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function addStation() {
|
||||
try {
|
||||
const created = await SaveOperatingStation({
|
||||
id: 0, profile_id: 0, name: 'New rig', sort_order: tree.length,
|
||||
} as any);
|
||||
const c = created as Station;
|
||||
setTree((prev) => [...prev, { ...c, antennas: [] }]);
|
||||
setExpanded((prev) => new Set(prev).add(c.id));
|
||||
setEditing(`station:${c.id}`);
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateStation(s: Station) {
|
||||
try {
|
||||
const saved = await SaveOperatingStation(s as any) as Station;
|
||||
setTree((prev) => prev.map((x) => x.id === s.id ? { ...x, ...saved } : x));
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeStation(id: number) {
|
||||
if (!confirm('Delete this rig and all its antennas?')) return;
|
||||
try {
|
||||
await DeleteOperatingStation(id);
|
||||
setTree((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function addAntenna(stationId: number) {
|
||||
try {
|
||||
const created = await SaveOperatingAntenna({
|
||||
id: 0, station_id: stationId, name: 'New antenna', sort_order: 0, bands: [],
|
||||
} as any) as Antenna;
|
||||
setTree((prev) => prev.map((s) =>
|
||||
s.id === stationId
|
||||
? { ...s, antennas: [...(s.antennas ?? []), created] }
|
||||
: s
|
||||
));
|
||||
setEditing(`antenna:${created.id}`);
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateAntenna(a: Antenna) {
|
||||
try {
|
||||
const saved = await SaveOperatingAntenna(a as any) as Antenna;
|
||||
setTree((prev) => prev.map((s) => s.id === a.station_id
|
||||
? {
|
||||
...s,
|
||||
antennas: (s.antennas ?? []).map((x) => x.id === a.id ? saved : x),
|
||||
}
|
||||
: {
|
||||
// The save may have cleared is_default on antennas of OTHER
|
||||
// stations (one default per band per profile). Refresh those
|
||||
// by reloading the tree wholesale.
|
||||
...s,
|
||||
}
|
||||
));
|
||||
// Reload to pick up cross-station default flips.
|
||||
void reload();
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeAntenna(stationId: number, antId: number) {
|
||||
if (!confirm('Delete this antenna?')) return;
|
||||
try {
|
||||
await DeleteOperatingAntenna(antId);
|
||||
setTree((prev) => prev.map((s) =>
|
||||
s.id === stationId
|
||||
? { ...s, antennas: (s.antennas ?? []).filter((a) => a.id !== antId) }
|
||||
: s
|
||||
));
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-xs text-muted-foreground italic">Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
Define your rigs (stations) and the antennas connected to each one.
|
||||
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
|
||||
the default antenna for that band — when you change the band in the
|
||||
entry strip, the matching rig + antenna auto-fill the MY_RIG and
|
||||
MY_ANTENNA ADIF fields. Only one antenna can be the default per
|
||||
band; setting one clears the previous default.
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={addStation}>
|
||||
<Plus className="size-3.5" /> Add rig
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tree.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
|
||||
No rig configured yet. Click "Add rig" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tree.map((station) => (
|
||||
<StationRow
|
||||
key={station.id}
|
||||
station={station}
|
||||
bands={bands}
|
||||
expanded={expanded.has(station.id)}
|
||||
editing={editing}
|
||||
setEditing={setEditing}
|
||||
onToggleExpanded={() => toggleExpanded(station.id)}
|
||||
onUpdate={updateStation}
|
||||
onDelete={() => removeStation(station.id)}
|
||||
onAddAntenna={() => addAntenna(station.id)}
|
||||
onUpdateAntenna={updateAntenna}
|
||||
onDeleteAntenna={(antId) => removeAntenna(station.id, antId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Station row ────────────────────────────────────────────────────────
|
||||
|
||||
type StationRowProps = {
|
||||
station: Station;
|
||||
bands: string[];
|
||||
expanded: boolean;
|
||||
editing: string | null;
|
||||
setEditing: (id: string | null) => void;
|
||||
onToggleExpanded: () => void;
|
||||
onUpdate: (s: Station) => void;
|
||||
onDelete: () => void;
|
||||
onAddAntenna: () => void;
|
||||
onUpdateAntenna: (a: Antenna) => void;
|
||||
onDeleteAntenna: (antId: number) => void;
|
||||
};
|
||||
|
||||
function StationRow({
|
||||
station, bands, expanded, editing, setEditing,
|
||||
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
||||
onUpdateAntenna, onDeleteAntenna,
|
||||
}: StationRowProps) {
|
||||
const editKey = `station:${station.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({
|
||||
name: station.name,
|
||||
tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '',
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!isEditing) setDraft({
|
||||
name: station.name,
|
||||
tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '',
|
||||
});
|
||||
}, [isEditing, station.name, station.tx_pwr]);
|
||||
|
||||
function commit() {
|
||||
const pwrNum = draft.tx_pwr.trim() === '' ? undefined : parseFloat(draft.tx_pwr);
|
||||
onUpdate({
|
||||
...station,
|
||||
name: draft.name.trim() || station.name,
|
||||
tx_pwr: Number.isFinite(pwrNum as number) ? (pwrNum as number) : undefined,
|
||||
});
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border/60 bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpanded}
|
||||
className="p-0.5 hover:bg-accent/40 rounded"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
<Radio className="size-4 text-muted-foreground" />
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Rig name (also stamped as MY_RIG)"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
className="h-7 text-sm w-20 font-mono"
|
||||
placeholder="100"
|
||||
value={draft.tx_pwr}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commit}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-sm">{station.name}</span>
|
||||
{station.tx_pwr != null && (
|
||||
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
|
||||
<Plus className="size-3" /> Antenna
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-2 space-y-2">
|
||||
{(station.antennas ?? []).length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground italic pl-6">
|
||||
No antenna yet — click "Antenna" above to add one.
|
||||
</div>
|
||||
) : (
|
||||
(station.antennas ?? []).map((a) => (
|
||||
<AntennaRow
|
||||
key={a.id}
|
||||
antenna={a}
|
||||
bands={bands}
|
||||
editing={editing}
|
||||
setEditing={setEditing}
|
||||
onUpdate={onUpdateAntenna}
|
||||
onDelete={() => onDeleteAntenna(a.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Antenna row ────────────────────────────────────────────────────────
|
||||
|
||||
type AntennaRowProps = {
|
||||
antenna: Antenna;
|
||||
bands: string[];
|
||||
editing: string | null;
|
||||
setEditing: (id: string | null) => void;
|
||||
onUpdate: (a: Antenna) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
||||
const editKey = `antenna:${antenna.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({ name: antenna.name });
|
||||
useEffect(() => {
|
||||
if (!isEditing) setDraft({ name: antenna.name });
|
||||
}, [isEditing, antenna.name]);
|
||||
|
||||
const enabledBands = new Map<string, Band>(
|
||||
(antenna.bands ?? []).map((b) => [b.band, b])
|
||||
);
|
||||
|
||||
function commitNames() {
|
||||
onUpdate({
|
||||
...antenna,
|
||||
name: draft.name.trim() || antenna.name,
|
||||
bands: antenna.bands ?? [],
|
||||
});
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function toggleBand(band: string, on: boolean) {
|
||||
let next = [...(antenna.bands ?? [])];
|
||||
if (on) {
|
||||
if (!next.find((b) => b.band === band)) {
|
||||
next.push({ band, is_default: false });
|
||||
}
|
||||
} else {
|
||||
next = next.filter((b) => b.band !== band);
|
||||
}
|
||||
onUpdate({ ...antenna, bands: next });
|
||||
}
|
||||
|
||||
function setDefault(band: string, isDefault: boolean) {
|
||||
const next = (antenna.bands ?? []).map((b) =>
|
||||
b.band === band ? { ...b, is_default: isDefault } : b
|
||||
);
|
||||
onUpdate({ ...antenna, bands: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border/70 bg-muted/10">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<AntennaIcon className="size-3.5 text-muted-foreground ml-3" />
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Antenna name (also stamped as MY_ANTENNA)"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commitNames}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-medium">{antenna.name}</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)}>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2 pl-8 flex flex-wrap gap-1.5">
|
||||
{bands.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground italic">No band configured in Settings → Bands.</span>
|
||||
) : bands.map((band) => {
|
||||
const entry = enabledBands.get(band);
|
||||
const enabled = !!entry;
|
||||
const isDefault = !!entry?.is_default;
|
||||
return (
|
||||
<div
|
||||
key={band}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
||||
isDefault
|
||||
? 'border-amber-400 bg-amber-50 shadow-sm'
|
||||
: enabled
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onCheckedChange={(c) => toggleBand(band, !!c)}
|
||||
className="size-3"
|
||||
/>
|
||||
<span className={isDefault ? 'font-semibold' : undefined}>{band}</span>
|
||||
{enabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefault(band, !isDefault)}
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
||||
isDefault
|
||||
? 'bg-amber-400 text-white'
|
||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
|
||||
)}
|
||||
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
|
||||
>
|
||||
<Star className={cn('size-3', isDefault && 'fill-current')} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider">
|
||||
{isDefault ? 'Default' : 'Set'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3 } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
// virtual-scroll — everything we want out of the box for a logbook table.
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Custom Quartz theme tuned to match HamLog's warm palette.
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 32,
|
||||
headerHeight: 34,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
|
||||
const badgeCellClass = 'flex items-center';
|
||||
|
||||
type Props = {
|
||||
rows: QSOForm[];
|
||||
total: number;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (id: number | null) => void;
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||
|
||||
function fmtMhzDots(hz?: number): string {
|
||||
if (!hz) return '';
|
||||
const mhz = (hz / 1_000_000).toFixed(6);
|
||||
const [i, f] = mhz.split('.');
|
||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
function fmtDateOnly(s: any): string {
|
||||
if (!s) return '';
|
||||
const 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())}`;
|
||||
}
|
||||
|
||||
const bandPill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
const modePill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
|
||||
// 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.
|
||||
type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
// ── QSO basics ──
|
||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: badgeCellClass, cellRenderer: modePill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
|
||||
// ── Contacted station ──
|
||||
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
|
||||
{ group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 },
|
||||
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
|
||||
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
|
||||
{ group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
|
||||
{ group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
|
||||
{ group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Uploads ──
|
||||
{ group: 'Uploads', label: 'ClubLog upload date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog date', field: 'clublog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
{ group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
|
||||
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
|
||||
{ group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 },
|
||||
|
||||
// ── Propagation / antenna ──
|
||||
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
|
||||
{ group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
||||
{ group: 'Propagation', label: 'Rig', colId: 'rig', headerName: 'Rig', field: 'rig' as any, width: 120 },
|
||||
{ group: 'Propagation', label: 'Antenna', colId: 'ant', headerName: 'Antenna', field: 'ant' as any, width: 140 },
|
||||
|
||||
// ── My station (operator side) ──
|
||||
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
|
||||
{ group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
|
||||
{ group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 },
|
||||
|
||||
// ── Misc ──
|
||||
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
{ group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
|
||||
{ group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
];
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Compute initial column defs: all columns defined, but those not marked
|
||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||
// overrides this so a previously toggled column wins.
|
||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
suppressMovable: false,
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) {
|
||||
e.api.applyColumnState({ state, applyOrder: true });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onSelectionChanged() {
|
||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
||||
}
|
||||
|
||||
// ── Column picker (visibility) ──
|
||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||
// state for "which columns are visible" — AG Grid's column state is the
|
||||
// source of truth, and saveColumnState persists it.
|
||||
function isColVisible(colId: string): boolean {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||
}
|
||||
function setColVisible(colId: string, visible: boolean) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.setColumnsVisible([colId], visible);
|
||||
saveColumnState();
|
||||
}
|
||||
function showAll(group?: string) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||||
api.setColumnsVisible(ids, true);
|
||||
saveColumnState();
|
||||
}
|
||||
function hideAll(group?: string) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
|
||||
api.setColumnsVisible(ids, false);
|
||||
saveColumnState();
|
||||
}
|
||||
function resetDefaults() {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
|
||||
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
|
||||
api.setColumnsVisible(visible, true);
|
||||
api.setColumnsVisible(hidden, false);
|
||||
saveColumnState();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<QSOForm>
|
||||
ref={gridRef}
|
||||
theme={hamlogTheme}
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||
onGridReady={onGridReady}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onSelectionChanged={onSelectionChanged}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Columns</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Recent QSOs table.
|
||||
Your selection is saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto 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">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{cols.map((c) => (
|
||||
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||
<Checkbox
|
||||
checked={isColVisible(c.colId!)}
|
||||
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2,
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction,
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||
|
||||
type LookupSettings = LookupSettingsForm;
|
||||
type StationSettings = StationSettingsForm;
|
||||
@@ -44,6 +46,55 @@ type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
||||
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
|
||||
// Catalog of all standard ADIF bands, in natural frequency order. The user
|
||||
// picks a subset on the right; everything else in the UI (entry strip,
|
||||
// band-slot grid, band-map switcher) iterates that subset.
|
||||
const BAND_CATALOG = [
|
||||
'2190m','630m','560m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
|
||||
'8m','6m','5m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm',
|
||||
'6mm','4mm','2.5mm','2mm','1mm',
|
||||
];
|
||||
|
||||
// Catalog of common ADIF modes with sensible RST defaults. When the user
|
||||
// picks one on the right, the RSTs are pre-filled but stay editable.
|
||||
const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
|
||||
{ name: 'SSB', sent: '59', rcvd: '59' },
|
||||
{ name: 'CW', sent: '599', rcvd: '599' },
|
||||
{ name: 'AM', sent: '59', rcvd: '59' },
|
||||
{ name: 'FM', sent: '59', rcvd: '59' },
|
||||
{ name: 'DIGITALVOICE', sent: '59', rcvd: '59' },
|
||||
{ name: 'FT8', sent: '-10', rcvd: '-10' },
|
||||
{ name: 'FT4', sent: '-10', rcvd: '-10' },
|
||||
{ name: 'JS8', sent: '-10', rcvd: '-10' },
|
||||
{ name: 'MSK144', sent: '+00', rcvd: '+00' },
|
||||
{ name: 'JT65', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'JT9', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'Q65', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'FST4', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'FST4W', sent: '-15', rcvd: '-15' },
|
||||
{ name: 'WSPR', sent: '-20', rcvd: '-20' },
|
||||
{ name: 'RTTY', sent: '599', rcvd: '599' },
|
||||
{ name: 'PSK31', sent: '599', rcvd: '599' },
|
||||
{ name: 'PSK63', sent: '599', rcvd: '599' },
|
||||
{ name: 'PSK125', sent: '599', rcvd: '599' },
|
||||
{ name: 'OLIVIA', sent: '599', rcvd: '599' },
|
||||
{ name: 'CONTESTI', sent: '599', rcvd: '599' },
|
||||
{ name: 'MFSK', sent: '599', rcvd: '599' },
|
||||
{ name: 'THROB', sent: '599', rcvd: '599' },
|
||||
{ name: 'HELL', sent: '599', rcvd: '599' },
|
||||
{ name: 'PACKET', sent: '599', rcvd: '599' },
|
||||
{ name: 'PACTOR', sent: '599', rcvd: '599' },
|
||||
{ name: 'VARA', sent: '599', rcvd: '599' },
|
||||
{ name: 'VARA HF', sent: '599', rcvd: '599' },
|
||||
{ name: 'ARDOP', sent: '599', rcvd: '599' },
|
||||
{ name: 'ATV', sent: '59', rcvd: '59' },
|
||||
{ name: 'SSTV', sent: '59', rcvd: '59' },
|
||||
{ name: 'C4FM', sent: '59', rcvd: '59' },
|
||||
{ name: 'DSTAR', sent: '59', rcvd: '59' },
|
||||
{ name: 'DMR', sent: '59', rcvd: '59' },
|
||||
{ name: 'FUSION', sent: '59', rcvd: '59' },
|
||||
];
|
||||
|
||||
const emptyProfile = (): Profile => ({
|
||||
id: 0,
|
||||
name: '',
|
||||
@@ -72,6 +123,7 @@ interface Props {
|
||||
type SectionId =
|
||||
| 'station'
|
||||
| 'profiles'
|
||||
| 'operating'
|
||||
| 'lookup'
|
||||
| 'lists-bands'
|
||||
| 'lists-modes'
|
||||
@@ -92,6 +144,7 @@ const TREE: TreeNode[] = [
|
||||
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: 'Station Information', id: 'station' },
|
||||
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
|
||||
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -102,7 +155,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
||||
]},
|
||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||
{ kind: 'item', label: 'Backup / Export', id: 'backup', disabled: true },
|
||||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||
],
|
||||
},
|
||||
@@ -120,11 +173,12 @@ const TREE: TreeNode[] = [
|
||||
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
station: 'Station Information',
|
||||
profiles: 'Profiles',
|
||||
operating: 'Operating conditions',
|
||||
lookup: 'Callsign Lookup',
|
||||
'lists-bands': 'Bands',
|
||||
'lists-modes': 'Modes & default RST',
|
||||
cluster: 'DX Cluster',
|
||||
backup: 'Backup / Export',
|
||||
backup: 'Database backup',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
rotator: 'Rotator',
|
||||
@@ -248,7 +302,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const updateActive = (patch: Partial<Profile>) =>
|
||||
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
|
||||
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
|
||||
const [bandsText, setBandsText] = useState('');
|
||||
// Custom band drafts (catalog covers ADIF spec but the user may have
|
||||
// exotic or experimental bands not listed).
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8',
|
||||
@@ -259,6 +316,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||||
enabled: false, folder: '', rotation: 5, zip: false,
|
||||
last_backup_at: '', default_folder: '',
|
||||
} as any);
|
||||
const [backupRunning, setBackupRunning] = useState(false);
|
||||
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||||
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
|
||||
@@ -281,14 +345,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
// click Connect/Disconnect inside the modal and see the pills change
|
||||
// without saving + reopening.
|
||||
useEffect(() => {
|
||||
EventsOn('cluster:state', async (st: any) => {
|
||||
const unsub = EventsOn('cluster:state', async (st: any) => {
|
||||
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
|
||||
try {
|
||||
const list = await ListClusterServers();
|
||||
setClusterServers((list ?? []) as ClusterServer[]);
|
||||
} catch {}
|
||||
});
|
||||
return () => { EventsOff('cluster:state'); };
|
||||
return () => { unsub?.(); };
|
||||
}, []);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
||||
@@ -325,18 +389,18 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [l, ls, c, ap, r] = await Promise.all([
|
||||
const [l, ls, c, ap, r, b] = await Promise.all([
|
||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||
GetRotatorSettings(),
|
||||
GetRotatorSettings(), GetBackupSettings(),
|
||||
]);
|
||||
setLookup(l);
|
||||
setActiveProfile(ap as Profile);
|
||||
setLists(ls);
|
||||
await reloadProfiles();
|
||||
await reloadClusterServers();
|
||||
setBandsText((ls.bands ?? []).join('\n'));
|
||||
setCatCfg(c);
|
||||
setRotator(r);
|
||||
setBackupCfg(b as any);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -345,12 +409,59 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ── Band selection helpers (dual-list shuttle) ──────────────────────────
|
||||
function addBand(tag: string) {
|
||||
const b = tag.trim().toLowerCase();
|
||||
if (!b) return;
|
||||
setLists((l) => {
|
||||
if ((l.bands ?? []).includes(b)) return l;
|
||||
return { ...l, bands: [...(l.bands ?? []), b] };
|
||||
});
|
||||
}
|
||||
function removeBand(i: number) {
|
||||
setLists((l) => {
|
||||
const next = [...(l.bands ?? [])];
|
||||
next.splice(i, 1);
|
||||
return { ...l, bands: next };
|
||||
});
|
||||
}
|
||||
function moveBand(i: number, dir: -1 | 1) {
|
||||
setLists((l) => {
|
||||
const next = [...(l.bands ?? [])];
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= next.length) return l;
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
return { ...l, bands: next };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mode helpers ────────────────────────────────────────────────────────
|
||||
function addMode() {
|
||||
setLists((l) => ({
|
||||
...l,
|
||||
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
|
||||
}));
|
||||
}
|
||||
function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) {
|
||||
setLists((l) => {
|
||||
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l;
|
||||
return {
|
||||
...l,
|
||||
modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }],
|
||||
};
|
||||
});
|
||||
}
|
||||
function addCustomMode(name: string) {
|
||||
const n = name.trim().toUpperCase();
|
||||
if (!n) return;
|
||||
setLists((l) => {
|
||||
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l;
|
||||
return {
|
||||
...l,
|
||||
modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }],
|
||||
};
|
||||
});
|
||||
}
|
||||
function removeMode(i: number) {
|
||||
setLists((l) => {
|
||||
const next = [...(l.modes ?? [])];
|
||||
@@ -378,11 +489,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
async function save() {
|
||||
setSaving(true); setErr(''); setMsg('');
|
||||
try {
|
||||
// Bands: dedup, lowercase, trim.
|
||||
// Bands: dedup, lowercase, trim. Order = user's drag order.
|
||||
const seen = new Set<string>();
|
||||
const bands: string[] = [];
|
||||
for (const line of bandsText.split('\n')) {
|
||||
const b = line.trim().toLowerCase();
|
||||
for (const raw of lists.bands ?? []) {
|
||||
const b = (raw ?? '').trim().toLowerCase();
|
||||
if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
|
||||
}
|
||||
const modes = (lists.modes ?? [])
|
||||
@@ -407,6 +518,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await SaveLookupSettings(lookup as any);
|
||||
await SaveCATSettings(catCfg as any);
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
await SetClusterAutoConnect(clusterAutoConnect);
|
||||
|
||||
setMsg('Settings saved.');
|
||||
@@ -506,23 +618,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<Label>POTA ref</Label>
|
||||
<Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Rig</Label>
|
||||
<Input value={p.my_rig ?? ''} onChange={(e) => updateActive({ my_rig: e.target.value })} placeholder="Flex 8600" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Antenna</Label>
|
||||
<Input value={p.my_antenna ?? ''} onChange={(e) => updateActive({ my_antenna: e.target.value })} placeholder="Ultrabeam UB40" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TX power (W)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={p.tx_pwr ?? ''}
|
||||
onChange={(e) => updateActive({ tx_pwr: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) })}
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -796,57 +891,212 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
}
|
||||
|
||||
function BandsPanel() {
|
||||
const selected = lists.bands ?? [];
|
||||
const selectedSet = new Set(selected.map((b) => (b ?? '').toLowerCase()));
|
||||
const available = BAND_CATALOG.filter((b) => !selectedSet.has(b.toLowerCase()));
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Bands" hint="One ADIF band per line (e.g. 20m, 2m, 70cm). Order = display order in the entry form and the band-slot grid." />
|
||||
<Textarea
|
||||
className="font-mono min-h-[260px] max-w-md"
|
||||
value={bandsText}
|
||||
onChange={(e) => setBandsText(e.target.value)}
|
||||
<SectionHeader
|
||||
title="Bands"
|
||||
hint="Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order."
|
||||
/>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] gap-3 max-w-3xl">
|
||||
{/* Left: available catalog */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
Available
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-y-auto divide-y divide-border">
|
||||
{available.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog bands selected.</div>
|
||||
) : (
|
||||
available.map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
onDoubleClick={() => addBand(b)}
|
||||
onClick={() => addBand(b)}
|
||||
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
|
||||
<Input
|
||||
value={bandDraft}
|
||||
onChange={(e) => setBandDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addBand(bandDraft);
|
||||
setBandDraft('');
|
||||
}
|
||||
}}
|
||||
placeholder="Custom band (e.g. 4m)"
|
||||
className="font-mono h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={() => { addBand(bandDraft); setBandDraft(''); }}
|
||||
disabled={!bandDraft.trim()}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: shuttle hint */}
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ArrowRight className="size-4" />
|
||||
<ArrowLeft className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Right: selected */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground flex items-center justify-between">
|
||||
<span>Selected ({selected.length})</span>
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||||
{selected.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||||
No band selected — pick from the left.
|
||||
</div>
|
||||
) : (
|
||||
selected.map((b, i) => (
|
||||
<div key={`${b}-${i}`} className="grid grid-cols-[auto_1fr_auto] items-center gap-1 px-2 py-1">
|
||||
<div className="flex gap-0.5">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, -1)} disabled={i === 0}>
|
||||
<ArrowUp className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, 1)} disabled={i === selected.length - 1}>
|
||||
<ArrowDown className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="font-mono text-sm">{b}</span>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeBand(i)}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModesPanel() {
|
||||
const selected = lists.modes ?? [];
|
||||
const selectedSet = new Set(selected.map((m) => (m.name ?? '').toUpperCase()));
|
||||
const available = MODE_CATALOG.filter((m) => !selectedSet.has(m.name));
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Modes & default RST"
|
||||
hint="When you pick a mode in the entry form, RST sent/rcvd auto-fill with these defaults (unless you've typed something)."
|
||||
hint="Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you've typed something)."
|
||||
/>
|
||||
<div className="rounded-md border border-border overflow-hidden max-w-2xl">
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-2 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
<span className="w-12">Order</span>
|
||||
<span>Mode (ADIF)</span>
|
||||
<span>RST sent</span>
|
||||
<span>RST rcvd</span>
|
||||
<span className="w-8"></span>
|
||||
<div className="grid grid-cols-[1fr_auto_1.5fr] gap-3 max-w-4xl">
|
||||
{/* Left: available catalog */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
Available
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||||
{available.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog modes selected.</div>
|
||||
) : (
|
||||
available.map((m) => (
|
||||
<button
|
||||
key={m.name}
|
||||
type="button"
|
||||
onDoubleClick={() => addModeFromCatalog(m)}
|
||||
onClick={() => addModeFromCatalog(m)}
|
||||
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors flex items-center justify-between gap-2"
|
||||
title={`Default RST: ${m.sent} / ${m.rcvd}`}
|
||||
>
|
||||
<span>{m.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{m.sent}/{m.rcvd}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
|
||||
<Input
|
||||
value={modeDraft}
|
||||
onChange={(e) => setModeDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addCustomMode(modeDraft);
|
||||
setModeDraft('');
|
||||
}
|
||||
}}
|
||||
placeholder="Custom mode"
|
||||
className="font-mono uppercase h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={() => { addCustomMode(modeDraft); setModeDraft(''); }}
|
||||
disabled={!modeDraft.trim()}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(lists.modes ?? []).map((m, i) => (
|
||||
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-1.5 items-center">
|
||||
<div className="flex gap-0.5 w-12">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
|
||||
<ArrowUp className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === (lists.modes?.length ?? 0) - 1}>
|
||||
<ArrowDown className="size-3" />
|
||||
</Button>
|
||||
|
||||
{/* Center: shuttle hint */}
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ArrowRight className="size-4" />
|
||||
<ArrowLeft className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Right: selected with editable RST */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
<span className="w-12">Order</span>
|
||||
<span>Mode</span>
|
||||
<span>RST snt</span>
|
||||
<span>RST rcv</span>
|
||||
<span className="w-6"></span>
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||||
{selected.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||||
No mode selected — pick from the left.
|
||||
</div>
|
||||
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
|
||||
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
|
||||
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
selected.map((m, i) => (
|
||||
<div key={i} className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1 items-center">
|
||||
<div className="flex gap-0.5 w-12">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
|
||||
<ArrowUp className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === selected.length - 1}>
|
||||
<ArrowDown className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
|
||||
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
|
||||
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-border bg-muted/40">
|
||||
<Button variant="ghost" size="sm" onClick={addMode} className="h-6 text-xs">
|
||||
<Plus className="size-3" /> Add blank row
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addMode} className="mt-3">
|
||||
<Plus className="size-3.5" /> Add mode
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1152,15 +1402,150 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function OperatingPanelWrapper() {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Operating conditions"
|
||||
hint="Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on."
|
||||
/>
|
||||
<OperatingPanel
|
||||
bands={lists.bands ?? []}
|
||||
onError={(m) => setErr(m)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupPanel() {
|
||||
const fmtLast = (iso: string) => {
|
||||
if (!iso) return 'never';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
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())} UTC`;
|
||||
};
|
||||
const effectiveFolder = (backupCfg.folder || backupCfg.default_folder || '').replace(/\\/g, '/');
|
||||
async function backupNow() {
|
||||
setBackupRunning(true); setBackupResult(null);
|
||||
try {
|
||||
// Save current draft first so the backup runs with the values
|
||||
// the user just typed (folder, rotation, zip) — otherwise the
|
||||
// backend would use stale persisted config.
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
const path = await RunBackupNow();
|
||||
setBackupResult({ ok: true, msg: 'Backup written to ' + path });
|
||||
const refreshed = await GetBackupSettings();
|
||||
setBackupCfg(refreshed as any);
|
||||
} catch (e: any) {
|
||||
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally { setBackupRunning(false); }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Database backup"
|
||||
hint="HamLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
|
||||
/>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!backupCfg.enabled}
|
||||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
|
||||
/>
|
||||
<span>Automatic backup when closing HamLog (max once per day)</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Backup folder</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="font-mono text-xs flex-1"
|
||||
placeholder={backupCfg.default_folder || 'leave empty for default'}
|
||||
value={backupCfg.folder ?? ''}
|
||||
onChange={(e) => setBackupCfg((b) => ({ ...b, folder: e.target.value }))}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const p = await PickBackupFolder();
|
||||
if (p) setBackupCfg((b) => ({ ...b, folder: p }));
|
||||
} catch (e: any) {
|
||||
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Browse…
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{backupCfg.folder
|
||||
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
|
||||
: <>If empty, HamLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Rotation (copies to keep)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
className="w-24 font-mono text-xs"
|
||||
value={backupCfg.rotation || 5}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isFinite(n) && n > 0) setBackupCfg((b) => ({ ...b, rotation: Math.floor(n) }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
|
||||
<Checkbox
|
||||
checked={!!backupCfg.zip}
|
||||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
|
||||
/>
|
||||
<span>ZIP backup (smaller file)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||||
<Button size="sm" onClick={backupNow} disabled={backupRunning}>
|
||||
{backupRunning ? 'Backing up…' : 'Backup now'}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last backup: <strong className="text-foreground">{fmtLast(backupCfg.last_backup_at)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{backupResult && (
|
||||
<div className={cn(
|
||||
'text-xs px-3 py-2 rounded-md border',
|
||||
backupResult.ok
|
||||
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
|
||||
: 'bg-rose-50 border-rose-300 text-rose-800',
|
||||
)}>
|
||||
{backupResult.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
station: StationPanel,
|
||||
profiles: ProfilesPanel,
|
||||
operating: OperatingPanelWrapper,
|
||||
lookup: LookupPanel,
|
||||
'lists-bands': BandsPanel,
|
||||
'lists-modes': ModesPanel,
|
||||
cluster: ClusterPanel,
|
||||
backup: () => <ComingSoon id="backup" icon={Database} />,
|
||||
backup: BackupPanel,
|
||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||
cat: CATPanel,
|
||||
rotator: RotatorPanel,
|
||||
@@ -1170,7 +1555,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-[960px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preferences</DialogTitle>
|
||||
<DialogDescription className="sr-only">Configure HamLog modules — station, lookup, hardware…</DialogDescription>
|
||||
@@ -1179,7 +1564,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
{loading ? (
|
||||
<div className="p-6 text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden">
|
||||
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
|
||||
{/* Left sidebar tree */}
|
||||
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
||||
<Tree selected={selected} onSelect={setSelected} />
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
type Step = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
// ShutdownProgress is a full-screen overlay that appears while HamLog is
|
||||
// running its close-time tasks (backup, future LoTW upload, ...). It
|
||||
// listens for `shutdown:start` / `shutdown:update` / `shutdown:done`
|
||||
// events from the backend and renders a checklist that updates as each
|
||||
// task completes. The backend triggers wruntime.Quit() once everything
|
||||
// is finished, so this component never has to dismiss itself.
|
||||
export function ShutdownProgress() {
|
||||
const [steps, setSteps] = useState<Step[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const u1 = EventsOn('shutdown:start', (s: Step[]) => setSteps(s ?? []));
|
||||
const u2 = EventsOn('shutdown:update', (s: Step[]) => setSteps(s ?? []));
|
||||
const u3 = EventsOn('shutdown:done', (s: Step[]) => setSteps(s ?? []));
|
||||
return () => { u1?.(); u2?.(); u3?.(); };
|
||||
}, []);
|
||||
|
||||
if (!steps) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-card border border-border rounded-lg shadow-xl p-6 min-w-[360px] max-w-[480px]">
|
||||
<div className="text-sm font-semibold mb-3 text-foreground">Closing HamLog…</div>
|
||||
<div className="space-y-2">
|
||||
{steps.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic">Nothing to do, exiting.</div>
|
||||
) : steps.map((s) => (
|
||||
<div key={s.id} className="flex items-start gap-2 text-sm">
|
||||
<div className="mt-0.5 w-4 flex items-center justify-center">
|
||||
{s.status === 'done' && <CheckCircle2 className="size-4 text-emerald-600" />}
|
||||
{s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />}
|
||||
{s.status === 'error' && <XCircle className="size-4 text-rose-600" />}
|
||||
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={
|
||||
s.status === 'done' ? 'text-foreground'
|
||||
: s.status === 'error' ? 'text-rose-700 font-medium'
|
||||
: s.status === 'pending' ? 'text-muted-foreground'
|
||||
: 'text-foreground font-medium'
|
||||
}>
|
||||
{s.label}
|
||||
</div>
|
||||
{s.detail && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 font-mono break-all">
|
||||
{s.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
type ColDef, type ColumnState, type GridReadyEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, Star } 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 { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number];
|
||||
|
||||
type Props = {
|
||||
wb: WorkedBeforeView | null;
|
||||
busy: boolean;
|
||||
currentCall: string;
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1';
|
||||
|
||||
function fmtDateTime(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
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 fmtDate(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
const bandPill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
const modePill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
|
||||
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
|
||||
const flagRenderer = (p: any) => {
|
||||
if (p.value === 'Y') {
|
||||
return <span style={{
|
||||
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
|
||||
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
|
||||
fontSize: 10, fontWeight: 700, lineHeight: '16px',
|
||||
}}>Y</span>;
|
||||
}
|
||||
return <span style={{ color: '#a8a29e' }}>—</span>;
|
||||
};
|
||||
|
||||
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
];
|
||||
|
||||
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const hasCall = currentCall.trim() !== '';
|
||||
const count = wb?.count ?? 0;
|
||||
const entries = wb?.entries ?? [];
|
||||
|
||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Empty / loading / no-call states.
|
||||
if (!hasCall) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
Type a callsign in the entry strip to see prior contacts.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (busy && count === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||
checking…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
<Star className="size-8 text-primary fill-current" />
|
||||
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
|
||||
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Worked before</span>
|
||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
|
||||
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
</div>
|
||||
{wb?.dxcc_name && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||||
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
|
||||
<span> · {wb.dxcc_count} entity QSOs</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<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<WorkedEntry>
|
||||
ref={gridRef}
|
||||
theme={hamlogTheme}
|
||||
rowData={entries}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
onGridReady={onGridReady}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{count > entries.length && (
|
||||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
||||
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Worked-before columns</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Worked-before 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="flex flex-col gap-1">
|
||||
{cols.map((c) => (
|
||||
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||
<Checkbox
|
||||
checked={isColVisible(c.colId!)}
|
||||
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user