fix: added functionality to net control
This commit is contained in:
@@ -405,8 +405,9 @@ type App struct {
|
|||||||
// session (in-memory only — active stations currently in QSO).
|
// session (in-memory only — active stations currently in QSO).
|
||||||
netStore *netctl.Store
|
netStore *netctl.Store
|
||||||
netMu sync.Mutex
|
netMu sync.Mutex
|
||||||
netOpenID string // id of the currently open net ("" = none)
|
netOpenID string // id of the currently open net ("" = none)
|
||||||
netActive []*netActiveEntry // stations on the air right now, in check-in order
|
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
|
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
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
|
// the session. The session is RAM-only — closing the app mid-net drops any
|
||||||
// active stations that were never logged.
|
// 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.
|
// NetList returns all nets (with rosters), ordered by name.
|
||||||
func (a *App) NetList() []netctl.Net {
|
func (a *App) NetList() []netctl.Net {
|
||||||
if a.netStore == nil {
|
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.
|
// 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()
|
a.netMu.Lock()
|
||||||
defer a.netMu.Unlock()
|
defer a.netMu.Unlock()
|
||||||
out := make([]netActiveEntry, len(a.netActive))
|
out := make([]qso.QSO, len(a.netActive))
|
||||||
for i, e := range a.netActive {
|
for i, e := range a.netActive {
|
||||||
out[i] = *e
|
out[i] = *e
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetActivate puts a station on the air (records time_on, seeds defaults from
|
// netLiveFreq returns the rig's live freq/band/mode, falling back to the last
|
||||||
// the net + roster). No-op if already active. The net must be open.
|
// UI-reported values when CAT is off.
|
||||||
func (a *App) NetActivate(callsign string) (netActiveEntry, error) {
|
func (a *App) netLiveFreq() (freq int64, band, mode string) {
|
||||||
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.
|
|
||||||
var st cat.RigState
|
var st cat.RigState
|
||||||
if a.cat != nil {
|
if a.cat != nil {
|
||||||
st = a.cat.State()
|
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 {
|
if freq == 0 {
|
||||||
a.liveActMu.Lock()
|
a.liveActMu.Lock()
|
||||||
freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode
|
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 {
|
if band == "" && freq > 0 {
|
||||||
band = bandForHz(freq)
|
band = bandForHz(freq)
|
||||||
}
|
}
|
||||||
q := qso.QSO{
|
return
|
||||||
Callsign: call,
|
}
|
||||||
QSODate: entry.TimeOn,
|
|
||||||
QSODateOff: time.Now().UTC(),
|
// NetActivate puts a station on the air: it builds a QSO draft (time_on now,
|
||||||
Band: band,
|
// live freq/mode, defaults + roster info) with a transient negative id and
|
||||||
Mode: mode,
|
// returns it. No-op (returns the existing draft) if already active.
|
||||||
RSTSent: entry.RSTSent,
|
func (a *App) NetActivate(callsign string) (qso.QSO, error) {
|
||||||
RSTRcvd: entry.RSTRcvd,
|
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
Name: entry.Name,
|
if call == "" {
|
||||||
QTH: entry.QTH,
|
return qso.QSO{}, fmt.Errorf("callsign required")
|
||||||
Comment: entry.Comment,
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if freq > 0 {
|
||||||
f := freq
|
f := freq
|
||||||
q.FreqHz = &f
|
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)
|
return a.AddQSO(q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3811,7 +3811,7 @@ export default function App() {
|
|||||||
tune the rig. */}
|
tune the rig. */}
|
||||||
{netEnabled && (
|
{netEnabled && (
|
||||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<NetControlPanel onLogged={refresh} rstChoices={rstOptions(mode, rstLists)} />
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
type ColDef, type RowDoubleClickedEvent, type CellValueChangedEvent,
|
type ColDef,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
|
import type { QSOForm } from '@/types';
|
||||||
import {
|
import {
|
||||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||||
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||||
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive,
|
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||||
} from '@/../wailsjs/go/main/App';
|
} from '@/../wailsjs/go/main/App';
|
||||||
import { netctl } from '@/../wailsjs/go/models';
|
import { netctl } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
@@ -45,10 +47,6 @@ const hamlogTheme = themeQuartz.withParams({
|
|||||||
|
|
||||||
type Net = netctl.Net;
|
type Net = netctl.Net;
|
||||||
type Station = netctl.Station;
|
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 {
|
function fmtTimeOn(s: any): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
@@ -60,14 +58,24 @@ function fmtTimeOn(s: any): string {
|
|||||||
|
|
||||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
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 [nets, setNets] = useState<Net[]>([]);
|
||||||
const [selId, setSelId] = useState<string>('');
|
const [selId, setSelId] = useState<string>('');
|
||||||
const [openId, setOpenId] = useState<string>('');
|
const [openId, setOpenId] = useState<string>('');
|
||||||
const [roster, setRoster] = useState<Station[]>([]);
|
const [roster, setRoster] = useState<Station[]>([]);
|
||||||
const [active, setActive] = useState<Active[]>([]);
|
const [active, setActive] = useState<QSOForm[]>([]);
|
||||||
const [error, setError] = useState('');
|
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.
|
// Add/edit-contact dialog.
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
const [contact, setContact] = useState<Station>(emptyStation());
|
const [contact, setContact] = useState<Station>(emptyStation());
|
||||||
@@ -95,7 +103,7 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshActive = useCallback(async () => {
|
const refreshActive = useCallback(async () => {
|
||||||
try { setActive(((await NetActiveList()) ?? []) as Active[]); }
|
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -103,9 +111,12 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
|
|||||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||||
|
|
||||||
// The roster side hides callsigns that are currently on the air (they live in
|
// The roster side hides callsigns currently on the air (they live in the left
|
||||||
// the left grid until logged), mirroring Log4OM's two-list behaviour.
|
// grid until logged), mirroring Log4OM's two-list behaviour.
|
||||||
const activeCalls = useMemo(() => new Set(active.map((a) => a.callsign.toUpperCase())), [active]);
|
const activeCalls = useMemo(
|
||||||
|
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
|
||||||
|
[active],
|
||||||
|
);
|
||||||
const rosterShown = useMemo(
|
const rosterShown = useMemo(
|
||||||
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
|
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
|
||||||
[roster, activeCalls],
|
[roster, activeCalls],
|
||||||
@@ -151,28 +162,20 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
|
|||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||||
async function deactivate(call: string) {
|
async function deactivate(id?: number) {
|
||||||
if (!call) return;
|
if (id == null) return;
|
||||||
try { await NetDeactivate(call); await refreshActive(); onLogged?.(); }
|
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onActiveDblClick(e: RowDoubleClickedEvent<Active>) {
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||||
// Double-clicking a non-editable area logs the QSO; editable cells open the
|
async function saveDraft(q: QSOForm) {
|
||||||
// editor instead (ag-grid handles that before this fires only for blanks),
|
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
|
||||||
// so we gate on the column being the callsign.
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
if (e.data && (e as any).column?.getColId?.() === 'callsign') deactivate(e.data.callsign);
|
|
||||||
}
|
}
|
||||||
|
async function discardDraft(id: number) {
|
||||||
async function onActiveCellChanged(e: CellValueChangedEvent<Active>) {
|
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
|
||||||
const d = e.data;
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
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)); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add-contact dialog.
|
// Add-contact dialog.
|
||||||
@@ -210,22 +213,17 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
|
|||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCols = useMemo<ColDef<Active>[]>(() => [
|
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||||
{ colId: 'callsign', headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||||
{ headerName: 'Name', field: 'name', flex: 1, editable: true },
|
{ headerName: 'Name', field: 'name', flex: 1 },
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1, editable: true },
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn(p.data?.time_on), width: 90, cellClass: 'font-mono text-[11px]' },
|
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||||
{ headerName: 'Country', field: 'country', width: 120 },
|
{ 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: 80, editable: true, cellClass: 'font-mono',
|
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||||
cellEditor: 'agSelectCellEditor', cellEditorParams: { values: rstChoices ?? [] },
|
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||||
},
|
{ headerName: 'Comment', field: 'comment', flex: 1.5 },
|
||||||
{
|
], []);
|
||||||
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 rosterCols = useMemo<ColDef<Station>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
{ 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 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">
|
<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
|
On air — active QSOs
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">double-click callsign → log & end QSO</span>
|
<span className="ml-auto font-normal normal-case opacity-90">double-click → edit all fields · "Log & end" to save</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<AgGridReact<Active>
|
<AgGridReact<QSOForm>
|
||||||
ref={activeGrid}
|
ref={activeGrid}
|
||||||
theme={hamlogTheme}
|
theme={hamlogTheme}
|
||||||
rowData={active}
|
rowData={active}
|
||||||
columnDefs={activeCols}
|
columnDefs={activeCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
onRowDoubleClicked={onActiveDblClick}
|
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||||
onCellValueChanged={onActiveCellChanged}
|
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
getRowId={(p) => String((p.data as any).callsign)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
stopEditingWhenCellsLoseFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && active.length > 0 && (
|
{isOpen && active.length > 0 && (
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
<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]"
|
<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 & end selected
|
<MinusCircle className="size-3.5" /> Log & end selected
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,6 +338,20 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
|
|||||||
</div>
|
</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 */}
|
{/* Add / edit contact dialog */}
|
||||||
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
|
|||||||
Vendored
+6
-4
@@ -372,18 +372,20 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
|||||||
|
|
||||||
export function MoveDatabase(arg1:string):Promise<void>;
|
export function MoveDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function NetActivate(arg1:string):Promise<main.netActiveEntry>;
|
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||||
|
|
||||||
export function NetActiveList():Promise<Array<main.netActiveEntry>>;
|
export function NetActiveList():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function NetClose():Promise<void>;
|
export function NetClose():Promise<void>;
|
||||||
|
|
||||||
export function NetCreate(arg1:string):Promise<netctl.Net>;
|
export function NetCreate(arg1:string):Promise<netctl.Net>;
|
||||||
|
|
||||||
export function NetDeactivate(arg1:string):Promise<number>;
|
export function NetDeactivate(arg1:number):Promise<number>;
|
||||||
|
|
||||||
export function NetDelete(arg1:string):Promise<void>;
|
export function NetDelete(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetDiscardActive(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function NetList():Promise<Array<netctl.Net>>;
|
export function NetList():Promise<Array<netctl.Net>>;
|
||||||
|
|
||||||
export function NetLookup(arg1:string):Promise<netctl.Station>;
|
export function NetLookup(arg1:string):Promise<netctl.Station>;
|
||||||
@@ -402,7 +404,7 @@ export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
|
|||||||
|
|
||||||
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||||
|
|
||||||
export function NetUpdateActive(arg1:main.netActiveEntry):Promise<void>;
|
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
|||||||
@@ -734,6 +734,10 @@ export function NetDelete(arg1) {
|
|||||||
return window['go']['main']['App']['NetDelete'](arg1);
|
return window['go']['main']['App']['NetDelete'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NetDiscardActive(arg1) {
|
||||||
|
return window['go']['main']['App']['NetDiscardActive'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function NetList() {
|
export function NetList() {
|
||||||
return window['go']['main']['App']['NetList']();
|
return window['go']['main']['App']['NetList']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2004,51 +2004,6 @@ export namespace main {
|
|||||||
return a;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user