398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
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<Net[]>([]);
|
|
const [selId, setSelId] = useState<string>('');
|
|
const [openId, setOpenId] = useState<string>('');
|
|
const [roster, setRoster] = useState<Station[]>([]);
|
|
const [active, setActive] = useState<QSOForm[]>([]);
|
|
const [error, setError] = useState('');
|
|
|
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
|
|
|
// Add/edit-contact dialog.
|
|
const [contactOpen, setContactOpen] = useState(false);
|
|
const [contact, setContact] = useState<Station>(emptyStation());
|
|
const [looking, setLooking] = useState(false);
|
|
|
|
const activeGrid = useRef<any>(null);
|
|
const rosterGrid = useRef<any>(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<ColDef<QSOForm>[]>(() => [
|
|
{ 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<ColDef<Station>[]>(() => [
|
|
{ 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<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
|
|
|
return (
|
|
<div className="flex flex-col min-h-0 flex-1">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
|
<Plus className="size-3.5" /> New NET
|
|
</Button>
|
|
<select
|
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
|
value={selId}
|
|
disabled={isOpen}
|
|
onChange={(e) => setSelId(e.target.value)}
|
|
title={isOpen ? 'Close the NET to switch' : 'Select a NET'}
|
|
>
|
|
<option value="">— select a NET —</option>
|
|
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
|
</select>
|
|
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
|
<Radio className="size-3.5" /> {isOpen ? 'Close NET' : 'Open NET'}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>Rename</Button>
|
|
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
|
|
<Trash2 className="size-3.5" /> Delete
|
|
</Button>
|
|
|
|
<div className="flex-1" />
|
|
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">NET OPEN</Badge>}
|
|
<span className="text-[11px] text-muted-foreground">
|
|
On air: <strong className="text-foreground">{active.length}</strong> ·
|
|
Roster: <strong className="text-foreground">{roster.length}</strong>
|
|
</span>
|
|
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1">
|
|
{/* ACTIVE USERS (left) */}
|
|
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
|
|
On air — active QSOs
|
|
<span className="ml-auto font-normal normal-case opacity-90">double-click → edit all fields · "Log & end" to save</span>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
<div style={{ position: 'absolute', inset: 0 }}>
|
|
<AgGridReact<QSOForm>
|
|
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)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{isOpen && active.length > 0 && (
|
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
|
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
|
<MinusCircle className="size-3.5" /> Log & end selected
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* NET USERS / roster (right) */}
|
|
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
|
|
NET users — roster
|
|
<span className="ml-auto font-normal normal-case opacity-90">double-click → put on air</span>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
<div style={{ position: 'absolute', inset: 0 }}>
|
|
<AgGridReact<Station>
|
|
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)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
|
<UserPlus className="size-3.5" /> Add contact
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
|
|
<MinusCircle className="size-3.5" /> Remove
|
|
</Button>
|
|
{isOpen && (
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
|
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
|
<PlusCircle className="size-3.5" /> Put selected on air
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 && (
|
|
<QSOEditModal
|
|
qso={editingDraft}
|
|
onSave={saveDraft}
|
|
onDelete={discardDraft}
|
|
onClose={() => setEditingDraft(null)}
|
|
countries={countries}
|
|
bands={bands}
|
|
modes={modes}
|
|
/>
|
|
)}
|
|
|
|
{/* Add / edit contact dialog */}
|
|
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Add contact to NET</DialogTitle>
|
|
<DialogDescription>Saved in this NET's roster (reused next time you open it).</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-3 px-5 py-2">
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<Label className="text-[11px]">Callsign</Label>
|
|
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
|
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
|
</div>
|
|
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
|
<Search className="size-3.5" /> {looking ? '…' : 'Search'}
|
|
</Button>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">Name</Label>
|
|
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">QTH</Label>
|
|
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">Country</Label>
|
|
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>Cancel</Button>
|
|
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
|
<PlusCircle className="size-3.5" /> Save in NET
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|