import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AllCommunityModule, ModuleRegistry, type ColDef, } from 'ag-grid-community'; import { hamlogGridTheme } from '@/lib/gridTheme'; import { AgGridReact } from 'ag-grid-react'; import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } 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 { useI18n } from '@/lib/i18n'; import type { QSOForm } from '@/types'; import { NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID, NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup, NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive, WorkedBefore, } from '@/../wailsjs/go/main/App'; import { netctl } from '@/../wailsjs/go/models'; ModuleRegistry.registerModules([AllCommunityModule]); const hamlogTheme = hamlogGridTheme; 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 { t } = useI18n(); 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); // Worked-before for the clicked station (see if/when we contacted it before). const [wbCall, setWbCall] = useState(''); const [wb, setWb] = useState(null); const [wbBusy, setWbBusy] = useState(false); const wbReq = useRef(0); // Resizable height (drag the handle on top). Persisted. const [wbHeight, setWbHeight] = useState(() => { const v = parseInt(localStorage.getItem('opslog.netWbHeight') || '', 10); return v >= 80 && v <= 600 ? v : 176; }); useEffect(() => { localStorage.setItem('opslog.netWbHeight', String(wbHeight)); }, [wbHeight]); const startWbResize = (e: React.MouseEvent) => { e.preventDefault(); const startY = e.clientY, startH = wbHeight; const onMove = (ev: MouseEvent) => setWbHeight(Math.max(80, Math.min(600, startH - (ev.clientY - startY)))); const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; const showWorkedBefore = useCallback(async (call: string, dxcc = 0) => { const c = (call ?? '').trim().toUpperCase(); setWbCall(c); if (!c) { setWb(null); return; } const req = ++wbReq.current; setWbBusy(true); try { const r = await WorkedBefore(c, dxcc); if (wbReq.current === req) setWb(r); } catch { if (wbReq.current === req) setWb(null); } finally { if (wbReq.current === req) setWbBusy(false); } }, []); 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(t('ncp.newNetPrompt')); 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(t('ncp.renamePrompt'), 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(t('ncp.deleteConfirm', { name: cur?.name ?? '' }))) 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(t('ncp.closeConfirm', { n: active.length }))) 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, grid: r.grid || c.grid, address: r.address || c.address, state: r.state || c.state, cnty: r.cnty || c.cnty, cont: r.cont || c.cont, lat: r.lat || c.lat, lon: r.lon || c.lon, email: r.email || c.email, })); } 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(t('ncp.removeConfirm', { n: sel.length }))) 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: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, { headerName: t('ncp.colName'), field: 'name', flex: 1 }, { headerName: 'QTH', field: 'qth', flex: 1 }, { headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' }, { headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' }, { headerName: t('ncp.colMode'), 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: t('ncp.colComment'), field: 'comment', flex: 1.5 }, ], [t]); const rosterCols = useMemo[]>(() => [ { headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, { headerName: t('ncp.colName'), field: 'name', flex: 1 }, { headerName: 'QTH', field: 'qth', flex: 1 }, { headerName: t('ncp.colCountry'), field: 'country', width: 130 }, ], [t]); const defaultColDef = useMemo(() => ({ sortable: true, resizable: true, suppressMovable: false }), []); return (
{/* Toolbar */}
{isOpen && {t('ncp.netOpenBadge')}} {t('ncp.onAir')} {active.length} · {t('ncp.roster')} {roster.length} {error && {error}}
{/* ACTIVE USERS (left) */}
{t('ncp.onAirActive')} {t('ncp.activeHint')}
ref={activeGrid} theme={hamlogTheme} rowData={active} columnDefs={activeCols} defaultColDef={defaultColDef} onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)} 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 && (
)} {/* Worked-before for the clicked station — click a row (on air or roster) to see if/when we contacted it before. Lives INSIDE the on-air column so resizing only splits this column; the roster on the right keeps its full height. */}
{t('ncp.workedBefore')} {wbCall && {wbCall}} {wb && wb.count > 0 && ( {wb.count} QSO · {t('ncp.wbFirst')} {String(wb.first ?? '').slice(0, 10)} · {t('ncp.wbLast')} {String(wb.last ?? '').slice(0, 10)} {wb.dxcc_name ? ` · ${wb.dxcc_name} (${wb.dxcc_count})` : ''} )}
{!wbCall ? (
{t('ncp.wbHint')}
) : wbBusy ? (
) : !wb || wb.count === 0 ? (
{t('ncp.wbNone')} {wbCall}
) : ( {((wb.entries ?? []) as any[]).map((q, i) => ( ))}
{t('ncp.colDate')} {t('ncp.colTimeOn')} {t('ncp.colBand')} {t('ncp.colMode')} RST {t('ncp.colComment')}
{String(q.qso_date ?? '').slice(0, 10)} {String(q.time_on ?? '').slice(0, 5)} {q.band} {q.mode} {(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')} {q.comment}
)}
{/* NET USERS / roster (right) */}
{t('ncp.netUsersRoster')} {t('ncp.rosterHint')}
ref={rosterGrid} theme={hamlogTheme} rowData={rosterShown} columnDefs={rosterCols} defaultColDef={defaultColDef} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)} 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 */} {t('ncp.addContactTitle')} {t('ncp.addContactDesc')}
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 }))} />
); }