diff --git a/app.go b/app.go index 997b02c..1acb5e4 100644 --- a/app.go +++ b/app.go @@ -405,8 +405,9 @@ type App struct { // session (in-memory only — active stations currently in QSO). netStore *netctl.Store netMu sync.Mutex - netOpenID string // id of the currently open net ("" = none) - netActive []*netActiveEntry // stations on the air right now, in check-in order + netOpenID string // id of the currently open net ("" = none) + netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order + netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …) cwMu sync.Mutex // guards the CW decoder lifecycle cwStop chan struct{} // stops the CW decoder capture loop; nil when off @@ -4284,18 +4285,6 @@ func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() } // the session. The session is RAM-only — closing the app mid-net drops any // active stations that were never logged. -// netActiveEntry is one station currently on the air in the open net. -type netActiveEntry struct { - Callsign string `json:"callsign"` - Name string `json:"name"` - QTH string `json:"qth"` - Country string `json:"country"` - RSTSent string `json:"rst_sent"` - RSTRcvd string `json:"rst_rcvd"` - Comment string `json:"comment"` - TimeOn time.Time `json:"time_on"` -} - // NetList returns all nets (with rosters), ordered by name. func (a *App) NetList() []netctl.Net { if a.netStore == nil { @@ -4410,97 +4399,26 @@ func (a *App) NetOpenID() string { } // NetActiveList returns the stations currently on the air, in check-in order. -func (a *App) NetActiveList() []netActiveEntry { +// Each is a full QSO *draft* (not yet in the DB) carrying a negative transient +// id so the same QSOEditModal as Recent QSOs can edit every field. +func (a *App) NetActiveList() []qso.QSO { a.netMu.Lock() defer a.netMu.Unlock() - out := make([]netActiveEntry, len(a.netActive)) + out := make([]qso.QSO, len(a.netActive)) for i, e := range a.netActive { out[i] = *e } return out } -// NetActivate puts a station on the air (records time_on, seeds defaults from -// the net + roster). No-op if already active. The net must be open. -func (a *App) NetActivate(callsign string) (netActiveEntry, error) { - call := strings.ToUpper(strings.TrimSpace(callsign)) - if call == "" { - return netActiveEntry{}, fmt.Errorf("callsign required") - } - a.netMu.Lock() - defer a.netMu.Unlock() - if a.netOpenID == "" { - return netActiveEntry{}, fmt.Errorf("no net open") - } - for _, e := range a.netActive { - if e.Callsign == call { - return *e, nil // already on the air - } - } - e := &netActiveEntry{Callsign: call, TimeOn: time.Now().UTC()} - if net, ok := a.netStore.Get(a.netOpenID); ok { - e.RSTSent, e.RSTRcvd, e.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment - for _, st := range net.Stations { - if strings.EqualFold(st.Callsign, call) { - e.Name, e.QTH, e.Country = st.Name, st.QTH, st.Country - break - } - } - } - if e.RSTSent == "" { - e.RSTSent = "59" - } - if e.RSTRcvd == "" { - e.RSTRcvd = "59" - } - a.netActive = append(a.netActive, e) - return *e, nil -} - -// NetUpdateActive edits the live fields (report/QTH/name/comment) of a station -// already on the air. TimeOn is preserved. -func (a *App) NetUpdateActive(e netActiveEntry) error { - call := strings.ToUpper(strings.TrimSpace(e.Callsign)) - a.netMu.Lock() - defer a.netMu.Unlock() - for _, cur := range a.netActive { - if cur.Callsign == call { - cur.Name, cur.QTH, cur.Country = e.Name, e.QTH, e.Country - cur.RSTSent, cur.RSTRcvd, cur.Comment = e.RSTSent, e.RSTRcvd, e.Comment - return nil - } - } - return fmt.Errorf("station not active") -} - -// NetDeactivate ends a station's QSO: it logs the contact to the active logbook -// (live CAT freq/mode, time_on→now) and removes it from the session. Returns -// the new QSO id. -func (a *App) NetDeactivate(callsign string) (int64, error) { - call := strings.ToUpper(strings.TrimSpace(callsign)) - a.netMu.Lock() - var entry *netActiveEntry - idx := -1 - for i, e := range a.netActive { - if e.Callsign == call { - entry, idx = e, i - break - } - } - if entry == nil { - a.netMu.Unlock() - return 0, fmt.Errorf("station not active") - } - a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...) - a.netMu.Unlock() - - // Frequency/mode come live from the rig; fall back to the last UI-reported - // values when CAT is off. +// netLiveFreq returns the rig's live freq/band/mode, falling back to the last +// UI-reported values when CAT is off. +func (a *App) netLiveFreq() (freq int64, band, mode string) { var st cat.RigState if a.cat != nil { st = a.cat.State() } - freq, band, mode := st.FreqHz, st.Band, st.Mode + freq, band, mode = st.FreqHz, st.Band, st.Mode if freq == 0 { a.liveActMu.Lock() freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode @@ -4509,22 +4427,130 @@ func (a *App) NetDeactivate(callsign string) (int64, error) { if band == "" && freq > 0 { band = bandForHz(freq) } - q := qso.QSO{ - Callsign: call, - QSODate: entry.TimeOn, - QSODateOff: time.Now().UTC(), - Band: band, - Mode: mode, - RSTSent: entry.RSTSent, - RSTRcvd: entry.RSTRcvd, - Name: entry.Name, - QTH: entry.QTH, - Comment: entry.Comment, + return +} + +// NetActivate puts a station on the air: it builds a QSO draft (time_on now, +// live freq/mode, defaults + roster info) with a transient negative id and +// returns it. No-op (returns the existing draft) if already active. +func (a *App) NetActivate(callsign string) (qso.QSO, error) { + call := strings.ToUpper(strings.TrimSpace(callsign)) + if call == "" { + return qso.QSO{}, fmt.Errorf("callsign required") } + a.netMu.Lock() + defer a.netMu.Unlock() + if a.netOpenID == "" { + return qso.QSO{}, fmt.Errorf("no net open") + } + for _, e := range a.netActive { + if strings.EqualFold(e.Callsign, call) { + return *e, nil // already on the air + } + } + a.netSeq-- + q := &qso.QSO{ID: a.netSeq, Callsign: call, QSODate: time.Now().UTC()} + if net, ok := a.netStore.Get(a.netOpenID); ok { + q.RSTSent, q.RSTRcvd, q.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment + for _, st := range net.Stations { + if strings.EqualFold(st.Callsign, call) { + q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country + if st.DXCC != 0 { + d := st.DXCC + q.DXCC = &d + } + if st.CQ != 0 { + c := st.CQ + q.CQZ = &c + } + if st.ITU != 0 { + i := st.ITU + q.ITUZ = &i + } + break + } + } + } + if q.RSTSent == "" { + q.RSTSent = "59" + } + if q.RSTRcvd == "" { + q.RSTRcvd = "59" + } + freq, band, mode := a.netLiveFreq() + q.Band, q.Mode = band, mode if freq > 0 { f := freq q.FreqHz = &f } + a.applyDXCCNumber(q) // fill country/dxcc/zones for display + a.refineDistrictZones(q) + a.netActive = append(a.netActive, q) + return *q, nil +} + +// NetUpdateActive replaces an on-air QSO draft (matched by its transient id) +// with the edited version from the QSOEditModal. Lets the operator change every +// field of a station before it's logged. +func (a *App) NetUpdateActive(q qso.QSO) error { + a.netMu.Lock() + defer a.netMu.Unlock() + for i, cur := range a.netActive { + if cur.ID == q.ID { + qq := q + a.netActive[i] = &qq + return nil + } + } + return fmt.Errorf("station not active") +} + +// NetDiscardActive removes an on-air draft (by transient id) WITHOUT logging it +// — i.e. cancel a station added by mistake (the modal's Delete button). +func (a *App) NetDiscardActive(id int64) error { + a.netMu.Lock() + defer a.netMu.Unlock() + for i, e := range a.netActive { + if e.ID == id { + a.netActive = append(a.netActive[:i], a.netActive[i+1:]...) + return nil + } + } + return nil +} + +// NetDeactivate ends a station's QSO (by transient id): it logs the draft to the +// active logbook (time_off = now; freq/mode refreshed from the rig only if the +// draft still has none, so manual edits are respected) and removes it from the +// session. Returns the new QSO id. +func (a *App) NetDeactivate(id int64) (int64, error) { + a.netMu.Lock() + var draft *qso.QSO + idx := -1 + for i, e := range a.netActive { + if e.ID == id { + draft, idx = e, i + break + } + } + if draft == nil { + a.netMu.Unlock() + return 0, fmt.Errorf("station not active") + } + a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...) + a.netMu.Unlock() + + q := *draft + q.ID = 0 // transient id must not reach the DB (AddQSO inserts a fresh row) + q.QSODateOff = time.Now().UTC() + if q.FreqHz == nil && q.Band == "" { + freq, band, mode := a.netLiveFreq() + q.Band, q.Mode = band, mode + if freq > 0 { + f := freq + q.FreqHz = &f + } + } return a.AddQSO(q) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b06bcf..0943cf6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3811,7 +3811,7 @@ export default function App() { tune the rig. */} {netEnabled && ( - + )} diff --git a/frontend/src/components/NetControlPanel.tsx b/frontend/src/components/NetControlPanel.tsx index 67433c1..f6efff9 100644 --- a/frontend/src/components/NetControlPanel.tsx +++ b/frontend/src/components/NetControlPanel.tsx @@ -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([]); const [selId, setSelId] = useState(''); const [openId, setOpenId] = useState(''); const [roster, setRoster] = useState([]); - const [active, setActive] = 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()); @@ -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) { - // 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) { - 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[]>(() => [ - { 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[]>(() => [ + { 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' }, @@ -275,28 +273,27 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
On air — active QSOs - double-click callsign → log & end QSO + double-click → edit all fields · "Log & end" to save
- + 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)} />
{isOpen && active.length > 0 && (
@@ -341,6 +338,20 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
+ {/* 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 */} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 8cfcf21..0bb7a17 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -372,18 +372,20 @@ export function LookupCallsign(arg1:string):Promise; export function MoveDatabase(arg1:string):Promise; -export function NetActivate(arg1:string):Promise; +export function NetActivate(arg1:string):Promise; -export function NetActiveList():Promise>; +export function NetActiveList():Promise>; export function NetClose():Promise; export function NetCreate(arg1:string):Promise; -export function NetDeactivate(arg1:string):Promise; +export function NetDeactivate(arg1:number):Promise; export function NetDelete(arg1:string):Promise; +export function NetDiscardActive(arg1:number):Promise; + export function NetList():Promise>; export function NetLookup(arg1:string):Promise; @@ -402,7 +404,7 @@ export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise; export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise; -export function NetUpdateActive(arg1:main.netActiveEntry):Promise; +export function NetUpdateActive(arg1:qso.QSO):Promise; export function OpenADIFFile():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 50fff82..3b53248 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -734,6 +734,10 @@ export function NetDelete(arg1) { return window['go']['main']['App']['NetDelete'](arg1); } +export function NetDiscardActive(arg1) { + return window['go']['main']['App']['NetDiscardActive'](arg1); +} + export function NetList() { return window['go']['main']['App']['NetList'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ac4ef44..5518311 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2004,51 +2004,6 @@ export namespace main { return a; } } - export class netActiveEntry { - callsign: string; - name: string; - qth: string; - country: string; - rst_sent: string; - rst_rcvd: string; - comment: string; - // Go type: time - time_on: any; - - static createFrom(source: any = {}) { - return new netActiveEntry(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.callsign = source["callsign"]; - this.name = source["name"]; - this.qth = source["qth"]; - this.country = source["country"]; - this.rst_sent = source["rst_sent"]; - this.rst_rcvd = source["rst_rcvd"]; - this.comment = source["comment"]; - this.time_on = this.convertValues(source["time_on"], null); - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } }