diff --git a/app.go b/app.go index 1aa256a..6fb474b 100644 --- a/app.go +++ b/app.go @@ -3356,6 +3356,8 @@ type QSLBulkUpdate struct { SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged Via string `json:"via"` // QSL_VIA — "" = unchanged + Notes string `json:"notes"` // NOTES — "" = unchanged + Comment string `json:"comment"` // COMMENT — "" = unchanged } // BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the @@ -3388,6 +3390,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) { if v := strings.TrimSpace(u.Via); v != "" { q.QSLVia, changed = v, true } + if v := strings.TrimSpace(u.Notes); v != "" { + q.Notes, changed = v, true + } + if v := strings.TrimSpace(u.Comment); v != "" { + q.Comment, changed = v, true + } if changed { if a.qso.Update(a.ctx, q) == nil { n++ @@ -4207,9 +4215,12 @@ func (a *App) saveQSORecording(q *qso.QSO) { return } applog.Printf("qso-rec: saved %s", path) - // Auto-send the recording once the file exists. Give the operator visible - // feedback either way — silently skipping (no e-mail known) was confusing. - if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend { + // Auto-send the recording once the file exists. Gated ONLY on its own + // "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's + // master "Enabled" flag, mirroring the eQSL-card auto-send. (Requiring + // Enabled silently skipped sending with no log when only this box was on.) + // Give the operator visible feedback either way. + if es, _ := a.GetEmailSettings(); es.AutoSend { if strings.TrimSpace(qc.Email) == "" { applog.Printf("qso-rec: %s not e-mailed — no recipient address known", qc.Callsign) a.toast(fmt.Sprintf("Recording saved — no e-mail for %s, not sent", qc.Callsign)) @@ -5669,7 +5680,9 @@ func uploadColumnFor(service string) string { // FindQSOsForUpload returns QSOs whose sent status for the given service // matches sentStatus ("" = blank). Powers the QSL Manager's Select required. -func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) { +// FindQSOsForUpload returns FULL QSO rows eligible for upload to a service, so +// the QSL Manager shows the same rich, column-pickable table as Recent QSOs. +func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) { if a.qso == nil { return nil, fmt.Errorf("db not initialized") } @@ -5677,7 +5690,7 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, er if col == "" { return nil, fmt.Errorf("unknown service %q", service) } - return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus))) + return a.qso.ListForUploadFull(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus))) } // UploadQSOsManual uploads the given QSO ids to a service on demand diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b515974..f632dbc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -117,7 +117,7 @@ const emptyDetails: DetailsState = { prop_mode: '', my_rig: '', my_antenna: '', tx_pwr: undefined, sat_name: '', sat_mode: '', - contest_id: '', srx: undefined, stx: undefined, + contest_id: '', srx_string: '', stx_string: '', email: '', award_refs: '', }; @@ -216,6 +216,14 @@ function estimateCwMs(resolved: string, wpm: number): number { const spaces = (resolved.match(/\s/g) || []).length; return ((chars * 10 + spaces * 4) * 1200) / w; } +// exchangeFields splits a contest exchange the operator typed as free text into +// the ADIF numeric field (SRX/STX, when it's all digits) or the string field +// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone). +function exchangeFields(raw?: string): { num?: number; str: string } { + const t = (raw || '').trim(); + if (t === '') return { str: '' }; + return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t }; +} // rstOptions returns the valid report choices for a mode from the user's // editable lists (Settings → Modes), with a tiny fallback before they load. function rstOptions(mode: string, lists: RSTLists): string[] { @@ -413,10 +421,12 @@ export default function App() { // User changed band/mode in the entry strip → push to the rig if CAT is up. // Both calls are fire-and-forget; CAT will reflect back via cat:state. + // When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do + // NOT drive the radio — the lock means "this value is decoupled from the rig". function onBandUserChange(v: string) { setBand(v); noteManualEdit(); - if (catState.enabled && catState.connected) { + if (catState.enabled && catState.connected && !locks.band && !locks.freq) { const hz = qsyFreqHz(v, mode); if (hz > 0) SetCATFrequency(hz).catch(() => {}); } @@ -425,7 +435,7 @@ export default function App() { setMode(v); applyModePreset(v); noteManualEdit(); - if (catState.enabled && catState.connected) { + if (catState.enabled && catState.connected && !locks.mode) { SetCATMode(v).catch(() => {}); } } @@ -1465,30 +1475,38 @@ export default function App() { // typing: only update when the field is empty, already shows this call, or // still shows the previous broadcast (i.e. the field content is ours, not // a different call the user typed). Returns true if it actually changed. - const applyUdpCall = (raw: string): boolean => { + // force = true means an EXPLICIT action (a panadapter/spot click, a remote + // "set call" command) that should always win, even over a call the operator + // already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber + // guard (it re-broadcasts constantly and must not fight what you typed). + const applyUdpCall = (raw: string, force = false): boolean => { const call = String(raw ?? '').trim(); if (!call) return false; const upper = call.toUpperCase(); const current = callsignValRef.current.trim().toUpperCase(); const prev = lastUdpCallRef.current; lastUdpCallRef.current = upper; // remember this broadcast either way - if (current === upper) return false; // already shown → no-op - if (current !== '' && current !== prev) return false; // user typed a different call → leave it + if (current === upper) return false; // already shown → no-op + if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it onCallsignInput(call, { force: true }); // programmatic → always look up return true; }; const unsubDX = EventsOn('udp:dx_call', (p: any) => { + // Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed + // over UDP…) is an explicit pick → force it over an existing call. + const force = String(p?.service ?? '').toLowerCase() !== 'wsjt'; // External app moved to a new station → fresh recording for the new target. - if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? '')); + if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? '')); }); const unsubRC = EventsOn('udp:remote_call', (raw: string) => { - if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? '')); + if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick }); // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call // (the radio already tuned via trigger_action=Tune, and CAT reads the freq). + // An explicit click always wins over whatever call is currently in the field. const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { const call = String(p?.call ?? ''); - if (applyUdpCall(call)) restartRecordingForNewTarget(call); + if (applyUdpCall(call, true)) restartRecordingForNewTarget(call); }); const unsubProg = EventsOn('import:progress', (p: any) => { setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); @@ -1585,7 +1603,7 @@ export default function App() { GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '', MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '', MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '', - CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '', + CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '', }; let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? ''); out = out.replace(/\*/g, myCall).replace(/!/g, his); @@ -1728,6 +1746,10 @@ export default function App() { // when you first entered the call (minutes early) and won't match LoTW. const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1'; const start = (startEqualsEnd && !locks.start) ? end : baseStart; + // Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an + // alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING. + const srxE = exchangeFields(details.srx_string); + const stxE = exchangeFields(details.stx_string); const payload: any = { callsign: callsign.trim().toUpperCase(), qso_date: start.toISOString(), @@ -1746,7 +1768,7 @@ export default function App() { tx_pwr: details.tx_pwr, sat_name: details.sat_name, sat_mode: details.sat_mode, contest_id: details.contest_id, - srx: details.srx, stx: details.stx, + srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str, email: details.email, }; applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index 3e3964d..d6e1bc2 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -35,8 +35,11 @@ export interface DetailsState { sat_name: string; sat_mode: string; contest_id: string; - srx?: number; - stx?: number; + // Contest exchanges as free text — most are serials (001) but some are + // alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when + // purely numeric, else to SRX_STRING/STX_STRING (handled in App on save). + srx_string?: string; + stx_string?: string; email: string; // Award references for the contacted station (set via the Awards tab picker). // Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064". @@ -371,10 +374,10 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai onChange({ contest_id: e.target.value })} /> - onChange({ srx: numOrUndef(e.target.value) })} /> + onChange({ srx_string: e.target.value })} /> - onChange({ stx: numOrUndef(e.target.value) })} /> + onChange({ stx_string: e.target.value })} /> onChange({ email: e.target.value })} /> diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index f80aebc..1678568 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -10,6 +10,7 @@ import { import { cn } from '@/lib/utils'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; +import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { EventsOn } from '../../wailsjs/runtime/runtime'; ModuleRegistry.registerModules([AllCommunityModule]); @@ -60,6 +61,14 @@ const QSL_STATUSES = [ { v: 'I', label: 'Ignore' }, ]; +// QSL routing methods for the paper-QSL "Via" dropdown (was free text). +const QSL_VIA_OPTIONS = [ + { v: '_', label: '— leave —' }, + { v: 'Bureau', label: 'Bureau' }, + { v: 'Direct', label: 'Direct' }, + { v: 'Electronic', label: 'Electronic' }, +]; + type LogQSO = { id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string; qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string; @@ -127,13 +136,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi const [paperCall, setPaperCall] = useState(''); const [paperRows, setPaperRows] = useState([]); const [paperSel, setPaperSel] = useState>(new Set()); + const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all const [paperBusy, setPaperBusy] = useState(false); const [paperMsg, setPaperMsg] = useState(''); - const [qslRcvd, setQslRcvd] = useState('Y'); + const [qslRcvd, setQslRcvd] = useState('N'); const [qslSent, setQslSent] = useState('_'); const [qslRcvdDate, setQslRcvdDate] = useState(''); const [qslSentDate, setQslSentDate] = useState(''); - const [qslVia, setQslVia] = useState(''); + const [qslVia, setQslVia] = useState('_'); + const [qslNotes, setQslNotes] = useState(''); + const [qslComment, setQslComment] = useState(''); const searchPaper = useCallback(async () => { const c = paperCall.trim().toUpperCase(); @@ -144,14 +156,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi const list = (r ?? []) as LogQSO[]; setPaperRows(list); setPaperSel(new Set(list.map((x) => x.id))); + setPaperSelAllSig((n) => n + 1); // tell the grid to select every row + } catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); } finally { setPaperBusy(false); } }, [paperCall]); - function togglePaper(id: number) { - setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); - } - const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length; async function applyPaper() { const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id); @@ -164,7 +174,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi rcvd_status: qslRcvd === '_' ? '' : qslRcvd, sent_date: ymd(qslSentDate), rcvd_date: ymd(qslRcvdDate), - via: qslVia, + via: qslVia === '_' ? '' : qslVia, + notes: qslNotes, + comment: qslComment, } as any); setPaperMsg(`${n} QSO updated.`); await searchPaper(); @@ -172,11 +184,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi finally { setPaperBusy(false); } } const [sent, setSent] = useState('R'); - const [rows, setRows] = useState([]); - // Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly. - const gridRef = useRef>(null); + // Full QSO rows (so the upload view uses the same rich grid as Recent QSOs). + const [rows, setRows] = useState([]); const [selectedCount, setSelectedCount] = useState(0); - const selectAllNext = useRef(false); // selectAll once after the next data load + const [uploadSelIds, setUploadSelIds] = useState([]); // selected QSO ids → upload + const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all const [searching, setSearching] = useState(false); const [error, setError] = useState(''); const [addNotFound, setAddNotFound] = useState(false); @@ -210,15 +222,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); // Grid selection → just track the count; ids are read from the grid at upload. - function onUploadSelChanged() { - setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0); - } - // After "Select required" loads new rows, select them all (the old default). - function onUploadRowsLoaded() { - if (selectAllNext.current) { - selectAllNext.current = false; - gridRef.current?.api?.selectAll(); - } + function onUploadRowSelected(ids: number[]) { + setUploadSelIds(ids); + setSelectedCount(ids.length); } const shownConfs = useMemo(() => confirmations.filter((c) => { @@ -236,10 +242,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi setError(''); try { const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); - const list = (r ?? []) as UploadRow[]; - selectAllNext.current = true; // pre-select everything once the grid renders + const list = (r ?? []) as any[]; setRows(list); + setUploadSelIds(list.map((x) => x.id)); setSelectedCount(list.length); + setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders setViewMode('upload'); setShowLog(false); } catch (e: any) { @@ -252,7 +259,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi }, [service, sent]); async function upload() { - const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id); + const ids = uploadSelIds; if (ids.length === 0) return; setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true); try { await UploadQSOsManual(service, ids); } @@ -387,32 +394,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi paperRows.length === 0 ? (
Search a callsign to list its QSOs, then set QSL status below.
) : ( - - - - - - - - - - - {paperRows.map((r) => ( - togglePaper(r.id)}> - - - - - - - - - - ))} - -
setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} />Date UTCCallsignBandModeQSL SentQSL RcvdVia
e.stopPropagation()}> togglePaper(r.id)} />{fmtDate(r.qso_date)}{r.callsign}{r.band}{r.mode}{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}{r.qsl_via}
+ // Same grid as Recent QSOs (full columns + column picker). Selection + // drives which QSOs the apply-form below updates; a search selects all. +
+ setPaperSel(new Set(ids))} + /> +
) ) : service === 'pota' ? (
@@ -509,19 +500,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi ) : rows.length === 0 ? (
Pick a service + sent status, then “Select required”.
) : ( -
- - ref={gridRef} - theme={qslTheme} - rowData={rows} - columnDefs={UPLOAD_COLS} - defaultColDef={{ sortable: true, resizable: true, filter: true }} - rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }} - onSelectionChanged={onUploadSelChanged} - onRowDataUpdated={onUploadRowsLoaded} - animateRows={false} - suppressCellFocus - getRowId={(p) => String((p.data as any).id)} +
+
)} @@ -552,7 +536,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
- setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" /> + +
+
+ + setQslNotes(e.target.value)} placeholder="e.g. paid 3€" /> +
+
+ + setQslComment(e.target.value)} placeholder="comment" />
{paperMsg && {paperMsg}} diff --git a/frontend/src/components/QSOEditModal.tsx b/frontend/src/components/QSOEditModal.tsx index f92904e..3103d4d 100644 --- a/frontend/src/components/QSOEditModal.tsx +++ b/frontend/src/components/QSOEditModal.tsx @@ -215,6 +215,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b return () => window.clearTimeout(t); }, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]); + // Contest exchange typed as free text → numeric SRX/STX when all-digits, else + // the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field + // accepts letters (sections/zones), not just numbers. + function setExchange(which: 'srx' | 'stx', raw: string) { + const t = raw.trim(); + const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined; + setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any)); + } function set(key: K, value: QSO[K]) { setDraft((d) => ({ ...d, [key]: value })); } @@ -561,7 +569,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b {CONFIRMATIONS.map((c) => ( - + {c.label} {c.rcvd ? : } @@ -578,10 +586,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
set('contest_id', e.target.value)} /> - set('srx', intOrUndef(e.target.value) as any)} /> - set('stx', intOrUndef(e.target.value) as any)} /> - set('srx_string', e.target.value)} /> - set('stx_string', e.target.value)} /> + setExchange('srx', e.target.value)} /> + setExchange('stx', e.target.value)} /> set('check', e.target.value)} /> set('precedence', e.target.value)} /> set('arrl_sect', e.target.value)} /> diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 3e4b3d5..24a4e1a 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -45,6 +45,9 @@ const hamlogTheme = themeQuartz.withParams({ type Props = { rows: QSOForm[]; total: number; + // Bump this number to programmatically select every row (e.g. after a search + // in the QSL Manager, where the default is "all selected"). + selectAllSignal?: number; onRowDoubleClicked?: (q: QSOForm) => void; onRowSelected?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void; @@ -225,7 +228,7 @@ export const GROUP_ORDER = [ 'Contest', 'Propagation', 'My station', 'Misc', ]; -export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) { +export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -310,6 +313,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); } + // Select every row when the caller bumps selectAllSignal (QSL Manager search). + useEffect(() => { + if (selectAllSignal === undefined) return; + gridRef.current?.api?.selectAll(); + }, [selectAllSignal]); // ── Column picker (visibility) ── // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 4834f70..342d898 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -130,7 +130,7 @@ export function ExportAwards():Promise; export function FilterFields():Promise>; -export function FindQSOsForUpload(arg1:string,arg2:string):Promise>; +export function FindQSOsForUpload(arg1:string,arg2:string):Promise>; export function FlexATUBypass():Promise; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index a021ca8..c5ffcb3 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1564,6 +1564,8 @@ export namespace main { sent_date: string; rcvd_date: string; via: string; + notes: string; + comment: string; static createFrom(source: any = {}) { return new QSLBulkUpdate(source); @@ -1576,6 +1578,8 @@ export namespace main { this.sent_date = source["sent_date"]; this.rcvd_date = source["rcvd_date"]; this.via = source["via"]; + this.notes = source["notes"]; + this.comment = source["comment"]; } } export class QSLDefaults { @@ -2897,30 +2901,6 @@ export namespace qso { return a; } } - export class UploadRow { - id: number; - qso_date: string; - callsign: string; - band: string; - mode: string; - country: string; - status: string; - - static createFrom(source: any = {}) { - return new UploadRow(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source["id"]; - this.qso_date = source["qso_date"]; - this.callsign = source["callsign"]; - this.band = source["band"]; - this.mode = source["mode"]; - this.country = source["country"]; - this.status = source["status"]; - } - } export class WorkedBefore { callsign: string; count: number; diff --git a/internal/qso/qso.go b/internal/qso/qso.go index e34bcad..6b545a6 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -497,6 +497,31 @@ var uploadStatusCols = map[string]bool{ // ListForUpload returns QSOs whose per-service sent-status column equals // value ("" matches blank/NULL). Used by the QSL Manager's "Select required". +// ListForUploadFull is like ListForUpload but returns FULL QSO rows so the UI +// can show the same rich, column-pickable table as Recent QSOs. column is an +// upload-status column (validated); value is the status to match ("" = not yet +// uploaded). +func (r *Repo) ListForUploadFull(ctx context.Context, column, value string) ([]QSO, error) { + if !uploadStatusCols[column] { + return nil, fmt.Errorf("invalid upload column %q", column) + } + rows, err := r.db.QueryContext(ctx, + `SELECT `+selectCols+` FROM qso WHERE COALESCE(`+column+`,'') = ? ORDER BY qso_date DESC, id DESC`, value) + if err != nil { + return nil, fmt.Errorf("list for upload: %w", err) + } + defer rows.Close() + out := make([]QSO, 0, 64) + for rows.Next() { + q, err := scanQSO(rows) + if err != nil { + return nil, err + } + out = append(out, q) + } + return out, rows.Err() +} + func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) { if !uploadStatusCols[column] { return nil, fmt.Errorf("invalid upload column %q", column)