feat: day batch — upload results surfaced as toasts (silent LoTW failures from the right-click send looked like nothing was sent); live selection count + Select all/Unselect all toggle in the grid toolbar; active filters bypass the row limit (all matches shown, 10k safety cap); NET Control: same right-click menu on worked-before + drag&drop roster<->on-air (drop on roster logs the QSO); compact mode restores previous window size/position; advanced-filter field renamed Station callsign; optional DXCC-style digital-mode grouping (Settings->General) for matrix badges + cluster colouring; changelog 0.20.9 entries (version NOT bumped)

This commit is contained in:
2026-07-22 15:38:08 +02:00
parent 5aac28f564
commit 4c75680689
10 changed files with 284 additions and 39 deletions
+55 -4
View File
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
// the worked-before grid here offers the SAME context menu on logged QSOs.
type QSOMenuHandlers = {
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
};
type Props = {
onLogged?: () => void;
countries?: string[];
bands?: string[];
modes?: string[];
qsoMenuHandlers?: QSOMenuHandlers;
};
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
const { t } = useI18n();
const [nets, setNets] = useState<Net[]>([]);
const [selId, setSelId] = useState<string>('');
@@ -70,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [looking, setLooking] = useState(false);
const rosterGrid = useRef<any>(null);
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
// external drop zones need each grid's api + the OTHER grid's container div.
const onAirApi = useRef<any>(null);
const onAirWrap = useRef<HTMLDivElement | null>(null);
const rosterWrap = useRef<HTMLDivElement | null>(null);
const dropZonesDone = useRef({ roster: false, onair: false });
// Callbacks live in refs so the drop-zone closures (registered once) always
// call the CURRENT activate/deactivate, not a stale first-render one.
const activateRef = useRef<(call: string) => void>(() => {});
const deactivateRef = useRef<(id?: number) => void>(() => {});
const wireDropZones = useCallback(() => {
const rApi = rosterGrid.current?.api;
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
dropZonesDone.current.roster = true;
rApi.addRowDropZone({
getContainer: () => onAirWrap.current!,
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
});
}
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
dropZonesDone.current.onair = true;
onAirApi.current.addRowDropZone({
getContainer: () => rosterWrap.current!,
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
});
}
}, []);
// Worked-before for the clicked station (see if/when we contacted it before).
const [wbCall, setWbCall] = useState('');
@@ -195,6 +237,10 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Keep the drop-zone closures pointed at the CURRENT handlers.
activateRef.current = activate;
deactivateRef.current = deactivate;
// Log EVERYONE still on air (end of net): each draft is written to the logbook
// exactly as the single-log button would.
async function logAll() {
@@ -258,7 +304,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
}
const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
@@ -307,12 +354,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div>
<div className="flex flex-col min-h-0 flex-1">
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid
rows={active}
total={active.length}
storageKey="net.onair"
selectRowSignal={selectRow}
rowDragCall
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds}
@@ -360,6 +409,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rows={(wb.entries ?? []) as any}
total={wb.count}
storageKey="net.wb"
{...qsoMenuHandlers}
/>
)}
</div>
@@ -373,7 +423,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.netUsersRoster')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Station>
ref={rosterGrid}
@@ -382,6 +432,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
columnDefs={rosterCols}
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={() => wireDropZones()}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false}