From 4c756806898d496aa542ef4118950d92b4299b44 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 22 Jul 2026 15:38:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20day=20batch=20=E2=80=94=20upload=20resu?= =?UTF-8?q?lts=20surfaced=20as=20toasts=20(silent=20LoTW=20failures=20from?= =?UTF-8?q?=20the=20right-click=20send=20looked=20like=20nothing=20was=20s?= =?UTF-8?q?ent);=20live=20selection=20count=20+=20Select=20all/Unselect=20?= =?UTF-8?q?all=20toggle=20in=20the=20grid=20toolbar;=20active=20filters=20?= =?UTF-8?q?bypass=20the=20row=20limit=20(all=20matches=20shown,=2010k=20sa?= =?UTF-8?q?fety=20cap);=20NET=20Control:=20same=20right-click=20menu=20on?= =?UTF-8?q?=20worked-before=20+=20drag&drop=20roster<->on-air=20(drop=20on?= =?UTF-8?q?=20roster=20logs=20the=20QSO);=20compact=20mode=20restores=20pr?= =?UTF-8?q?evious=20window=20size/position;=20advanced-filter=20field=20re?= =?UTF-8?q?named=20Station=20callsign;=20optional=20DXCC-style=20digital-m?= =?UTF-8?q?ode=20grouping=20(Settings->General)=20for=20matrix=20badges=20?= =?UTF-8?q?+=20cluster=20colouring;=20changelog=200.20.9=20entries=20(vers?= =?UTF-8?q?ion=20NOT=20bumped)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.go | 78 +++++++++++++++++++-- changelog.json | 28 ++++++++ frontend/src/App.tsx | 49 +++++++++---- frontend/src/components/BandSlotGrid.tsx | 19 +++-- frontend/src/components/NetControlPanel.tsx | 59 ++++++++++++++-- frontend/src/components/RecentQSOsGrid.tsx | 52 ++++++++++++-- frontend/src/components/SettingsModal.tsx | 5 ++ frontend/src/lib/i18n.tsx | 12 ++-- frontend/src/lib/uiPref.ts | 1 + internal/qso/qso.go | 20 +++++- 10 files changed, 284 insertions(+), 39 deletions(-) diff --git a/app.go b/app.go index 96b41bf..156febe 100644 --- a/app.go +++ b/app.go @@ -530,6 +530,13 @@ type App struct { // must be allowed through. shuttingDown bool compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry) + // Geometry captured when ENTERING compact mode, restored on the way back — + // otherwise leaving compact snapped the window to a hardcoded 1400×900 at + // wherever the compact strip had been dragged. + preCompactW, preCompactH int + preCompactX, preCompactY int + preCompactMax bool + preCompactValid bool // Cached operator location used to compute distance/bearing for // cluster spots. Refreshed on profile activation; zero means @@ -1811,6 +1818,17 @@ func (a *App) ResetDatabaseToDefault() error { // widths, sort…) in the DB settings table under a "ui." namespace, so they // travel with the logbook and survive a reinstall — unlike the WebView's // localStorage. Values are opaque JSON blobs owned by the frontend. +// groupDigitalSlots reports the "digital modes count as one" preference +// (Settings → General; stored as a portable ui.* pref so the frontend matrix +// and this backend cluster logic read the same switch). +func (a *App) groupDigitalSlots() bool { + if a.settings == nil { + return false + } + v, _ := a.settings.Get(a.ctx, "ui.opslog.groupDigitalSlots") + return v == "1" +} + func (a *App) GetUIPref(key string) (string, error) { if a.settings == nil { return "", nil @@ -5204,6 +5222,19 @@ func (a *App) SetCompactMode(on bool) { if a.ctx == nil { return } + if on && !a.compact { + // Capture the normal geometry BEFORE shrinking, so leaving compact puts + // the window back exactly where and how big it was. + a.preCompactMax = wruntime.WindowIsMaximised(a.ctx) + if a.preCompactMax { + // Unmaximise first: the size/position of a maximised window are the + // monitor's, not the user's chosen geometry. + wruntime.WindowUnmaximise(a.ctx) + } + a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx) + a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx) + a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH + } a.compact = on if on { // Lock the window to the compact size by pinning min == max. Without @@ -5218,7 +5249,16 @@ func (a *App) SetCompactMode(on bool) { // Release the lock first (raise the max) before growing back. wruntime.WindowSetMaxSize(a.ctx, maxW, maxH) wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH) - wruntime.WindowSetSize(a.ctx, normalW, normalH) + if a.preCompactValid { + wruntime.WindowSetSize(a.ctx, a.preCompactW, a.preCompactH) + wruntime.WindowSetPosition(a.ctx, a.preCompactX, a.preCompactY) + if a.preCompactMax { + wruntime.WindowMaximise(a.ctx) + } + } else { + // Nothing captured (compact since launch) — fall back to the default. + wruntime.WindowSetSize(a.ctx, normalW, normalH) + } } } @@ -8160,6 +8200,12 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern msg = err.Error() } emit("LoTW upload failed: " + msg) + // The qslmgr:log console is only visible in the QSL Manager — a failure + // triggered from the Recent QSOs right-click was completely silent, which + // read as "send to LoTW does nothing". Surface it as a toast too. + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "toast", "LoTW upload failed: "+msg) + } } else { for _, id := range ids { a.markExtUploaded(svc, id, "") @@ -8314,6 +8360,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)}) + // Always end with a visible summary — the right-click "Send to …" runs in + // the background and the per-QSO log lives in the QSL Manager console. + label := map[extsvc.Service]string{ + extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog", + extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL", + }[svc] + if label == "" { + label = string(svc) + } + wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%s: %d/%d QSO uploaded", label, uploaded, len(ids))) } } @@ -13065,7 +13121,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { } return 0 } - entities, err := a.qso.EntitySlotMap(a.ctx, keyFor) + // Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/ + // RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was + // worked shows "worked", not "new-slot" — DXCC-style mode classes. + var normMode func(string) string + if a.groupDigitalSlots() { + normMode = qso.GroupDigitalMode + } + entities, err := a.qso.EntitySlotMap(a.ctx, keyFor, normMode) if err != nil { return out } @@ -13133,12 +13196,17 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { } // Band already worked. If this MODE was never worked on the entity (any // band) → new-mode. If the mode was worked elsewhere but not on THIS - // band+mode → new-slot. Otherwise → worked. - if _, m := e.Modes[out[i].Mode]; !m { + // band+mode → new-slot. Otherwise → worked. The check mode goes through + // the same normaliser as the slot map (digital grouping). + checkMode := out[i].Mode + if normMode != nil { + checkMode = normMode(checkMode) + } + if _, m := e.Modes[checkMode]; !m { out[i].Status = "new-mode" continue } - if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok { + if _, ok := e.Slots[out[i].Band][checkMode]; !ok { out[i].Status = "new-slot" continue } diff --git a/changelog.json b/changelog.json index 3036d25..abe173b 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,32 @@ [ + { + "version": "0.20.9", + "date": "2026-07-22", + "en": [ + "The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.", + "Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.", + "The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.", + "NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).", + "Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).", + "The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).", + "Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).", + "New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.", + "NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).", + "New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot." + ], + "fr": [ + "La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.", + "Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.", + "Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.", + "NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).", + "Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).", + "Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).", + "Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).", + "Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».", + "NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).", + "Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct." + ] + }, { "version": "0.20.8", "date": "2026-07-21", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ad7ab0..0542f69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -116,6 +116,9 @@ type ModePreset = ModePresetForm; type WB = WorkedBeforeView; type CATState = Omit; +// Safety cap on how many rows an ACTIVE filter may load (the normal row +// threshold is bypassed while filtering — see buildActiveFilter). +const FILTERED_LIMIT_CAP = 10000; const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm']; const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE']; // Modes the QSO recorder captures (phone only). Mirrors recordableMode() in @@ -1431,13 +1434,21 @@ export default function App() { // The full filter sent to the backend = the builder conditions + the quick // callsign search box (always ANDed) + the on-screen row threshold. - const buildActiveFilter = useCallback((): QueryFilter => ({ - quick_callsign: filterCallsign, - conditions: activeFilter.conditions ?? [], - match: activeFilter.match ?? 'AND', - limit: qsoLimit, - offset: 0, - }), [filterCallsign, activeFilter, qsoLimit]); + // When a filter is ACTIVE the row threshold is lifted to a high safety cap: + // the threshold exists to keep the UNFILTERED 30k-QSO log fast, but applying + // it to a filter meant "200 matches, only the first 100 shown". A filtered + // result set is almost always small, and the cap protects against a + // broad filter (e.g. band=20m) pulling half the log. + const buildActiveFilter = useCallback((): QueryFilter => { + const hasFilter = !!(filterCallsign || (activeFilter.conditions ?? []).length); + return { + quick_callsign: filterCallsign, + conditions: activeFilter.conditions ?? [], + match: activeFilter.match ?? 'AND', + limit: hasFilter ? FILTERED_LIMIT_CAP : qsoLimit, + offset: 0, + }; + }, [filterCallsign, activeFilter, qsoLimit]); // refresh reloads the grid. Returns false on failure. When silent, it doesn't // surface the error — used by the startup retry, since the logbook DB (a remote @@ -3729,7 +3740,12 @@ export default function App() { case 'netcontrol': return (
- + setEqslQsoId(ids[0] ?? null), + onDelete: (ids: number[]) => setDeletingIds(ids), + }} />
); case 'recent': @@ -4796,9 +4812,13 @@ export default function App() { )}
- {qsos.length >= qsoLimit && qsos.length < total && ( - Limit reached — raise Max to see more. - )} + {(activeFilter.conditions?.length || filterCallsign) + ? (matchCount != null && matchCount > FILTERED_LIMIT_CAP && ( + {matchCount.toLocaleString('en-US')} matches — only {FILTERED_LIMIT_CAP.toLocaleString('en-US')} shown, narrow the filter (or use the filtered ADIF export). + )) + : (qsos.length >= qsoLimit && qsos.length < total && ( + Limit reached — raise Max to see more. + ))} - + setEqslQsoId(ids[0] ?? null), + onDelete: (ids: number[]) => setDeletingIds(ids), + }} /> )} diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index 0702619..60f5bec 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -109,14 +109,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal // "Newness" of the current band+mode entry, for the award/DX-chase badges. // Derived straight from the entity's real band_status (all bands it was // worked on — not just the operator's configured column list). - // Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class: - // DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes - // lists every real (band, mode) the entity was worked on. + // By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a + // group, so FT4 after FT8 is genuinely a new mode. The operator can opt into + // DXCC-style grouping instead (Settings → General), where all digital modes + // count as ONE — then FT4 after FT8 is just "worked". + const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1'; + const normMode = (m: string): string => { + const u = (m || '').toUpperCase().trim(); + if (!groupDigital) return u; + return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG'; + }; const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[]; - const curMode = (currentMode || '').toUpperCase().trim(); + const curMode = normMode(currentMode); const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode) - const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band) - const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode); + const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band) + const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode); // Mutually-exclusive badges, shown only when the entity is worked but this // exact band+mode is NOT yet: // New Band & Mode = both the band AND the mode are new for this entity. diff --git a/frontend/src/components/NetControlPanel.tsx b/frontend/src/components/NetControlPanel.tsx index eb9d748..f747459 100644 --- a/frontend/src/components/NetControlPanel.tsx +++ b/frontend/src/components/NetControlPanel.tsx @@ -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([]); const [selId, setSelId] = useState(''); @@ -70,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) { const [looking, setLooking] = useState(false); const rosterGrid = useRef(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(null); + const onAirWrap = useRef(null); + const rosterWrap = useRef(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[]>(() => [ - { 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')} {t('ncp.activeHint')}
-
+
{ 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} /> )}
@@ -373,7 +423,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) { {t('ncp.netUsersRoster')} {t('ncp.rosterHint')}
-
+
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} diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index ba6d7b5..2d80729 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -5,7 +5,7 @@ import { } from 'ag-grid-community'; import { hamlogGridTheme } from '@/lib/gridTheme'; import { AgGridReact } from 'ag-grid-react'; -import { Columns3, FilterX } from 'lucide-react'; +import { Columns3, FilterX, ListChecks } from 'lucide-react'; import type { QSOForm } from '@/types'; import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu'; import { @@ -33,6 +33,11 @@ type Props = { // Bump `seq` to programmatically select the single row with this id (e.g. NET // Control auto-advancing to the next on-air station after logging one). selectRowSignal?: { id: number; seq: number }; + // Show a row-drag handle on the callsign column (NET Control: drag an on-air + // row onto the roster to log it). Pair with onGridApi so the parent can + // register external drop zones via api.addRowDropZone. + rowDragCall?: 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 // reuse this grid elsewhere with its OWN independent column config. @@ -252,7 +257,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, 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, 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(null); const [pickerOpen, setPickerOpen] = useState(false); @@ -260,6 +265,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage // (e.g. the Net panel) keeps its own layout independent of the main log. const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY; const [menu, setMenu] = useState(null); + const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar + const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all // Localized column catalog — rebuilt when the language changes. const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); @@ -319,7 +326,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage restoringRef.current = true; const base = COL_CATALOG.map((c) => { const { group: _g, label: _l, defaultVisible, ...rest } = c; - return { ...rest, hide: !defaultVisible }; + const col: ColDef = { ...rest, hide: !defaultVisible }; + if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) { + col.rowDrag = true; + } + return col; }); const awards: ColDef[] = (awardCols ?? []).map((a) => ({ colId: `award_${a.code}`, @@ -333,7 +344,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '', })); return [...base, ...awards]; - }, [awardCols, COL_CATALOG, t, awardShown]); + }, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]); // 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, @@ -356,6 +367,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage }), []); function onGridReady(e: GridReadyEvent) { + onGridApi?.(e.api); const local = loadLocal(colStateKey); if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true }); // Fall back to the portable DB copy when the local cache is empty @@ -371,6 +383,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage // null when no column filter is active, so "Showing X of Y" reflects them. const reportFilteredCount = useCallback((e: { api?: any }) => { const api = e?.api ?? gridRef.current?.api; + if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount()); if (!api || !onFilteredCountChange) return; onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null); }, [onFilteredCountChange]); @@ -400,7 +413,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); } function onSelectionChanged() { - const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; + const api = gridRef.current?.api; + const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? []; + setSelCount(sel.length); + setDispCount(api?.getDisplayedRowCount?.() ?? 0); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); } // Select every row when the caller bumps selectAllSignal (QSL Manager search). @@ -480,6 +496,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage return ( <>
+ {/* Live selection count — visible without opening the context menu. */} + {selCount > 0 && ( + + {t('rqg.selectedCount', { n: selCount })} + + )} + {/* Select every loaded row that passes the active column filters — so a + filtered view can be selected in one click (then send to LoTW, bulk + edit, export…). Once everything is selected the same button flips to + "Unselect all". */} + {(() => { + const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount; + return ( + + ); + })()}