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>
+30 -7
View File
@@ -37,6 +37,11 @@ type Props = {
// row onto the roster to log it). Pair with onGridApi so the parent can
// register external drop zones via api.addRowDropZone.
rowDragCall?: boolean;
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
// is the display order — a pinned "#" column numbers it, and all column
// sorting is disabled (including saved sorts) so the queue can't be shuffled
// by a header click. The parent owns/reorders the array.
passOrder?: boolean;
onGridApi?: (api: any) => void;
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
@@ -257,7 +262,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -330,8 +335,20 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
col.rowDrag = true;
}
if (passOrder) {
delete (col as any).sort; // e.g. the date column's default 'desc'
col.sortable = false;
}
return col;
});
if (passOrder) {
base.unshift({
colId: '__order', headerName: '#', width: 46, pinned: 'left',
sortable: false, resizable: false, suppressMovable: true, filter: false,
cellClass: 'font-mono text-muted-foreground tabular-nums',
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
} as ColDef<QSOForm>);
}
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
headerName: a.code,
@@ -344,7 +361,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
// Enforce award-column visibility via the API after the columns (re)appear —
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
@@ -360,21 +377,27 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
}, [awardCols, awardShown]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true,
sortable: !passOrder,
resizable: true,
filter: true,
suppressMovable: false,
}), []);
}), [passOrder]);
// In pass-order mode a saved sort (from before the mode existed, or synced
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
const sanitizeState = useCallback((state: any[]) => (
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
), [passOrder]);
function onGridReady(e: GridReadyEvent) {
onGridApi?.(e.api);
const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
if (local) e.api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(colStateKey).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
seedLocal(colStateKey, remote);
}
});
@@ -403,7 +426,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(colStateKey);
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
if (api && local) api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);