import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef, } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { QSOEditModal } from '@/components/QSOEditModal'; import type { QSOForm } from '@/types'; import { NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID, NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup, NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive, } from '@/../wailsjs/go/main/App'; import { netctl } from '@/../wailsjs/go/models'; 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 Net = netctl.Net; type Station = netctl.Station; function fmtTimeOn(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 `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`; } const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' }); type Props = { onLogged?: () => void; countries?: string[]; bands?: string[]; modes?: string[]; }; export function NetControlPanel({ onLogged, countries, bands, modes }: Props) { const [nets, setNets] = useState([]); const [selId, setSelId] = useState(''); const [openId, setOpenId] = useState(''); const [roster, setRoster] = useState([]); const [active, setActive] = useState([]); const [error, setError] = useState(''); // Full-QSO edit modal for an on-air draft (same one Recent QSOs uses). const [editingDraft, setEditingDraft] = useState(null); // Add/edit-contact dialog. const [contactOpen, setContactOpen] = useState(false); const [contact, setContact] = useState(emptyStation()); const [looking, setLooking] = useState(false); const activeGrid = useRef(null); const rosterGrid = useRef(null); const isOpen = openId !== '' && openId === selId; const refreshNets = useCallback(async () => { try { const [list, oid] = await Promise.all([NetList(), NetOpenID()]); const arr = (list ?? []) as Net[]; setNets(arr); setOpenId(oid ?? ''); setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? '')); } catch (e: any) { setError(String(e?.message ?? e)); } }, []); const refreshRoster = useCallback(async (id: string) => { if (!id) { setRoster([]); return; } try { setRoster(((await NetRoster(id)) ?? []) as Station[]); } catch (e: any) { setError(String(e?.message ?? e)); } }, []); const refreshActive = useCallback(async () => { try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); } catch { /* ignore */ } }, []); useEffect(() => { refreshNets(); }, [refreshNets]); useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]); useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]); // The roster side hides callsigns currently on the air (they live in the left // grid until logged), mirroring Log4OM's two-list behaviour. const activeCalls = useMemo( () => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())), [active], ); const rosterShown = useMemo( () => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())), [roster, activeCalls], ); async function newNet() { const name = window.prompt('New NET name:'); if (!name?.trim()) return; try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); } catch (e: any) { setError(String(e?.message ?? e)); } } async function renameNet() { if (!selId) return; const cur = nets.find((n) => n.id === selId); const name = window.prompt('Rename NET:', cur?.name ?? ''); if (!name?.trim()) return; try { await NetRename(selId, name.trim()); await refreshNets(); } catch (e: any) { setError(String(e?.message ?? e)); } } async function deleteNet() { if (!selId) return; const cur = nets.find((n) => n.id === selId); if (!window.confirm(`Delete NET "${cur?.name}" and its roster? This cannot be undone.`)) return; try { await NetDelete(selId); setSelId(''); await refreshNets(); } catch (e: any) { setError(String(e?.message ?? e)); } } async function toggleOpen() { try { if (isOpen) { if (active.length > 0 && !window.confirm(`${active.length} station(s) still on the air will be dropped WITHOUT logging. Close anyway?`)) return; await NetClose(); setOpenId(''); } else { await NetOpen(selId); setOpenId(selId); await refreshActive(); } } catch (e: any) { setError(String(e?.message ?? e)); } } // Roster → active (start QSO). async function activate(call: string) { if (!isOpen || !call) return; try { await NetActivate(call); await refreshActive(); } catch (e: any) { setError(String(e?.message ?? e)); } } // Active → logged (end QSO, removed from session, written to the logbook). async function deactivate(id?: number) { if (id == null) return; try { await NetDeactivate(id); await refreshActive(); onLogged?.(); } catch (e: any) { setError(String(e?.message ?? e)); } } // Edit-modal handlers (operate on the in-memory draft, not the DB). async function saveDraft(q: QSOForm) { try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); } catch (e: any) { setError(String(e?.message ?? e)); } } async function discardDraft(id: number) { try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); } catch (e: any) { setError(String(e?.message ?? e)); } } // Add-contact dialog. function openAddContact() { setContact(emptyStation()); setContactOpen(true); } async function lookupContact() { const call = (contact.callsign ?? '').trim(); if (!call) return; setLooking(true); try { const r = await NetLookup(call); setContact((c) => netctl.Station.createFrom({ ...c, callsign: (r.callsign || call).toUpperCase(), name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country, dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq, })); } catch (e: any) { setError(String(e?.message ?? e)); } finally { setLooking(false); } } async function saveContact() { const call = (contact.callsign ?? '').trim().toUpperCase(); if (!call || !selId) return; try { await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call })); setContactOpen(false); await refreshRoster(selId); } catch (e: any) { setError(String(e?.message ?? e)); } } async function removeSelectedRoster() { const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[]; if (sel.length === 0) return; if (!window.confirm(`Remove ${sel.length} station(s) from this NET's roster?`)) return; try { for (const s of sel) await NetRosterRemove(selId, s.callsign); await refreshRoster(selId); } catch (e: any) { setError(String(e?.message ?? e)); } } const activeCols = useMemo[]>(() => [ { headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, { headerName: 'Name', field: 'name', flex: 1 }, { headerName: 'QTH', field: 'qth', flex: 1 }, { headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' }, { headerName: 'Band', field: 'band', width: 70, cellClass: 'font-mono' }, { headerName: 'Mode', field: 'mode', width: 70, cellClass: 'font-mono' }, { headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' }, { headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' }, { headerName: 'Comment', field: 'comment', flex: 1.5 }, ], []); const rosterCols = useMemo[]>(() => [ { headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, { headerName: 'Name', field: 'name', flex: 1 }, { headerName: 'QTH', field: 'qth', flex: 1 }, { headerName: 'Country', field: 'country', width: 130 }, ], []); const defaultColDef = useMemo(() => ({ sortable: true, resizable: true, suppressMovable: false }), []); return (
{/* Toolbar */}
{isOpen && NET OPEN} On air: {active.length} · Roster: {roster.length} {error && {error}}
{/* ACTIVE USERS (left) */}
On air — active QSOs double-click → edit all fields · "Log & end" to save
ref={activeGrid} theme={hamlogTheme} rowData={active} columnDefs={activeCols} defaultColDef={defaultColDef} onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)} rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }} animateRows={false} getRowId={(p) => String((p.data as any).id)} />
{isOpen && active.length > 0 && (
)}
{/* NET USERS / roster (right) */}
NET users — roster double-click → put on air
ref={rosterGrid} theme={hamlogTheme} rowData={rosterShown} columnDefs={rosterCols} defaultColDef={defaultColDef} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)} animateRows={false} getRowId={(p) => String((p.data as any).callsign)} />
{isOpen && ( )}
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to the in-memory draft (NetUpdateActive); Delete cancels it (no log). */} {editingDraft && ( setEditingDraft(null)} countries={countries} bands={bands} modes={modes} /> )} {/* Add / edit contact dialog */} Add contact to NET Saved in this NET's roster (reused next time you open it).
setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))} onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
); }