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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user