feat: NET Control mic-pass order for the on-air list

The on-air list was ordered by log time, but a net controller needs the order
the mic goes around. The on-air grid now renders in a controller-owned order:

- A pinned "#" column numbers the round; header sorting is disabled on this
  grid (new passOrder prop on RecentQSOsGrid) so a stray click can't reshuffle
  it, and any previously-saved sort is stripped on restore.
- ⬆⬇ buttons move the selected station up/down the queue.
- New check-ins append to the end; logged/cancelled stations drop out; the
  order is reconciled against the live session list and persisted per net
  (localStorage opslog.netOrder.<id>), so a tab switch keeps the round.
- "Log & end" now auto-advances to the NEXT station in pass order.
This commit is contained in:
2026-07-23 23:02:03 +02:00
parent d6a2e84eed
commit 5e09b6a796
4 changed files with 101 additions and 15 deletions
+67 -6
View File
@@ -5,7 +5,7 @@ import {
} 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 { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -166,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
catch { /* ignore */ }
}, []);
// ---- Mic pass order ------------------------------------------------------
// The on-air grid shows the stations in the order the mic goes around — NOT
// by log time. The order is owned here as a list of draft ids: new check-ins
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
// buttons move the selected station. Persisted per net so a panel remount
// (tab switch) keeps the round.
const [orderIds, setOrderIds] = useState<number[]>([]);
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
// Load the saved order when a net opens.
useEffect(() => {
if (!orderKey) { setOrderIds([]); return; }
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
catch { setOrderIds([]); }
}, [orderKey]);
// Reconcile with the live list: keep known ids in their order, append new
// ones, drop the gone. Persist.
useEffect(() => {
setOrderIds((cur) => {
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
const liveSet = new Set(liveIds);
const kept = cur.filter((id) => liveSet.has(id));
const keptSet = new Set(kept);
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
return next;
});
}, [active, orderKey]);
const activeOrdered = useMemo(() => {
const byId = new Map(active.map((a) => [a.id as number, a]));
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
}, [active, orderIds]);
// Move the selected on-air station up/down the queue.
const moveSelected = useCallback((dir: -1 | 1) => {
const id = selectedActiveIds[0];
if (id == null) return;
setOrderIds((cur) => {
const i = cur.indexOf(id);
const j = i + dir;
if (i < 0 || j < 0 || j >= cur.length) return cur;
const next = [...cur];
[next[i], next[j]] = [next[j], next[i]];
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
return next;
});
}, [selectedActiveIds, orderKey]);
useEffect(() => { refreshNets(); }, [refreshNets]);
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
@@ -221,11 +268,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
// Then auto-select the FIRST remaining on-air station, so the operator can
// chain "log → log → log" without re-clicking a row each time.
// Then auto-select the NEXT station in the mic-pass order, so the operator
// can chain "log → log → log" without re-clicking a row each time.
async function deactivate(id?: number) {
if (id == null) return;
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
try {
await NetDeactivate(id);
await refreshActive();
@@ -356,11 +403,12 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
</div>
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid
rows={active}
total={active.length}
rows={activeOrdered}
total={activeOrdered.length}
storageKey="net.onair"
selectRowSignal={selectRow}
rowDragCall
passOrder
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
@@ -373,6 +421,19 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button>
{/* Mic pass order: move the selected station up/down the round. */}
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
onClick={() => moveSelected(-1)}>
<ChevronUp className="size-4" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
onClick={() => moveSelected(1)}>
<ChevronDown className="size-4" />
</Button>
</div>
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
</Button>