fix: added functionality to net control

This commit is contained in:
2026-06-24 19:09:34 +02:00
parent 8b831145ad
commit d6626d96d0
6 changed files with 205 additions and 207 deletions
+64 -53
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type RowDoubleClickedEvent, type CellValueChangedEvent,
type ColDef,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
@@ -12,10 +12,12 @@ 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,
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
} from '@/../wailsjs/go/main/App';
import { netctl } from '@/../wailsjs/go/models';
@@ -45,10 +47,6 @@ const hamlogTheme = themeQuartz.withParams({
type Net = netctl.Net;
type Station = netctl.Station;
type Active = {
callsign: string; name: string; qth: string; country: string;
rst_sent: string; rst_rcvd: string; comment: string; time_on: any;
};
function fmtTimeOn(s: any): string {
if (!s) return '';
@@ -60,14 +58,24 @@ function fmtTimeOn(s: any): string {
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => void; rstChoices?: string[] }) {
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<Active[]>([]);
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());
@@ -95,7 +103,7 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
}, []);
const refreshActive = useCallback(async () => {
try { setActive(((await NetActiveList()) ?? []) as Active[]); }
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
catch { /* ignore */ }
}, []);
@@ -103,9 +111,12 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
// The roster side hides callsigns that are 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]);
// 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],
@@ -151,28 +162,20 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
async function deactivate(call: string) {
if (!call) return;
try { await NetDeactivate(call); await refreshActive(); onLogged?.(); }
async function deactivate(id?: number) {
if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
function onActiveDblClick(e: RowDoubleClickedEvent<Active>) {
// Double-clicking a non-editable area logs the QSO; editable cells open the
// editor instead (ag-grid handles that before this fires only for blanks),
// so we gate on the column being the callsign.
if (e.data && (e as any).column?.getColId?.() === 'callsign') deactivate(e.data.callsign);
// 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 onActiveCellChanged(e: CellValueChangedEvent<Active>) {
const d = e.data;
if (!d) return;
try {
await NetUpdateActive({
callsign: d.callsign, name: d.name ?? '', qth: d.qth ?? '', country: d.country ?? '',
rst_sent: d.rst_sent ?? '', rst_rcvd: d.rst_rcvd ?? '', comment: d.comment ?? '', time_on: d.time_on,
} as any);
} catch (err: any) { setError(String(err?.message ?? err)); }
async function discardDraft(id: number) {
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Add-contact dialog.
@@ -210,22 +213,17 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
} catch (e: any) { setError(String(e?.message ?? e)); }
}
const activeCols = useMemo<ColDef<Active>[]>(() => [
{ colId: 'callsign', headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: 'Name', field: 'name', flex: 1, editable: true },
{ headerName: 'QTH', field: 'qth', flex: 1, editable: true },
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn(p.data?.time_on), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: 'Country', field: 'country', width: 120 },
{
headerName: 'RST S', field: 'rst_sent', width: 80, editable: true, cellClass: 'font-mono',
cellEditor: 'agSelectCellEditor', cellEditorParams: { values: rstChoices ?? [] },
},
{
headerName: 'RST R', field: 'rst_rcvd', width: 80, editable: true, cellClass: 'font-mono',
cellEditor: 'agSelectCellEditor', cellEditorParams: { values: rstChoices ?? [] },
},
{ headerName: 'Comment', field: 'comment', flex: 1.5, editable: true },
], [rstChoices]);
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' },
@@ -275,28 +273,27 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
<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 callsign log &amp; end QSO</span>
<span className="ml-auto font-normal normal-case opacity-90">double-click edit all fields · "Log &amp; end" to save</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Active>
<AgGridReact<QSOForm>
ref={activeGrid}
theme={hamlogTheme}
rowData={active}
columnDefs={activeCols}
defaultColDef={defaultColDef}
onRowDoubleClicked={onActiveDblClick}
onCellValueChanged={onActiveCellChanged}
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
getRowId={(p) => String((p.data as any).callsign)}
stopEditingWhenCellsLoseFocus
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 Active; if (r) deactivate(r.callsign); }}>
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
<MinusCircle className="size-3.5" /> Log &amp; end selected
</Button>
</div>
@@ -341,6 +338,20 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
</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">