This commit is contained in:
2026-06-07 01:11:37 +02:00
parent 17f7a00bd7
commit 16c04fc12b
13 changed files with 418 additions and 52 deletions
+38 -7
View File
@@ -44,7 +44,11 @@ type AwardRef = {
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
const AWARD_TYPES = ['QSOFIELDS', 'REFERENCE', 'DXCC', 'GRID'];
// Award types mirror Log4OM: REFERENCE (we supply a list), QSOFIELDS (scan any
// QSO field — handles state/grid4/zones/etc.), CALLSIGN. The type is
// organizational only; matching is driven by the field/pattern/dynamic options,
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
@@ -141,6 +145,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [updating, setUpdating] = useState<string | null>(null);
const [err, setErr] = useState('');
// The err banner doubles as a success/notice area (export path, import counts,
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
// forever; the longer text (export path) gets a bit more time.
useEffect(() => {
if (!err) return;
const t = window.setTimeout(() => setErr(''), 8000);
return () => window.clearTimeout(t);
}, [err]);
const loadMeta = () => GetAwardReferenceMeta()
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
.catch(() => {});
@@ -214,7 +227,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const filtered = useMemo(() => {
const q = search.trim().toUpperCase();
return defs.map((d, i) => ({ d, i })).filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q));
// Keep the original index `i` (used for selection/patch) but display the
// list sorted alphabetically by code — scales as the catalogue grows.
return defs.map((d, i) => ({ d, i }))
.filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q))
.sort((a, b) => a.d.code.localeCompare(b.d.code));
}, [defs, search]);
return (
@@ -251,7 +268,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden">
{err && <div className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all">{err}</div>}
{err && <div onClick={() => setErr('')} title="Click to dismiss" className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
) : (
@@ -293,7 +310,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Field2 label="Award type">
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
<SelectContent>{AWARD_TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}</SelectContent>
<SelectContent>{
// Keep a legacy type (DXCC/GRID) selectable if an existing
// award still uses it, so it isn't silently changed.
(cur.type && !AWARD_TYPES.includes(cur.type) ? [cur.type, ...AWARD_TYPES] : AWARD_TYPES)
.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)
}</SelectContent>
</Select>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
@@ -530,9 +552,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
<div className="grid grid-cols-3 gap-3">
<Field2 label="Score"><Input type="number" className="h-8 font-mono" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Bonus"><Input type="number" className="h-8 font-mono" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Grid"><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Score</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Bonus</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Grid</Label>
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
</div>
</div>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
</div>
+20 -5
View File
@@ -15,7 +15,7 @@ type Meta = { code: string; count: number; can_update: boolean };
// never picked. NB: 'state' and 'cnty' are NOT here — they're operator-settable
// QSO fields driving predefined-list awards (WAS/RAC/WAJA/JCC), so they ARE
// pickable (a lookup rarely fills the JA prefecture or VE province).
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid']);
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid', 'grid4']);
// If DXCC-filtered auto-results exceed this, require the user to type instead.
const AUTO_SHOW_MAX = 100;
@@ -86,15 +86,30 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
// For dynamic lists, restrict to the contacted entity; otherwise load all.
const refDxcc = isDynamic ? (dxcc ?? 0) : 0;
// Search helper with a DXCC fallback: try the entity-scoped query first, but
// if it finds nothing AND we were filtering by DXCC, retry unfiltered. This
// fixes awards whose references carry no per-ref DXCC (e.g. SOTA summits,
// where the country is in the summit prefix F/AB-001, not a DXCC column) —
// otherwise filtering by entity returns zero. POTA/IOTA keep entity filtering
// when their refs do match.
const searchRefs = async (query: string, limit: number): Promise<AwardRef[]> => {
let r = (await SearchAwardReferences(awardCode, query, refDxcc, limit)) as any as AwardRef[];
if ((!r || r.length === 0) && refDxcc > 0) {
r = (await SearchAwardReferences(awardCode, query, 0, limit)) as any as AwardRef[];
}
return r ?? [];
};
// Auto-load refs on award/dxcc change with empty query. Fetches AUTO_SHOW_MAX+1
// so we can distinguish "all results shown" from "too many to list".
useEffect(() => {
setAutoResults([]);
// Dynamic lists need an entity to scope to; predefined lists load regardless.
if (isDynamic && !dxcc) return;
SearchAwardReferences(awardCode, '', refDxcc, AUTO_SHOW_MAX + 1)
.then((r) => setAutoResults((r ?? []) as any))
searchRefs('', AUTO_SHOW_MAX + 1)
.then((r) => setAutoResults(r))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [awardCode, dxcc, isDynamic, refDxcc]);
// Typed search (2+ chars).
@@ -103,12 +118,12 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
const t = window.setTimeout(async () => {
setBusy(true);
try {
const r = await SearchAwardReferences(awardCode, q, refDxcc, 50);
setSearchResults((r ?? []) as any);
setSearchResults(await searchRefs(q, 50));
} catch { setSearchResults([]); }
finally { setBusy(false); }
}, 200);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [awardCode, q, refDxcc]);
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
+108 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3 } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats } from '../../wailsjs/go/main/App';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor';
@@ -58,7 +59,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
type AwardListItem = { code: string; name: string; valid?: boolean };
export function AwardsPanel() {
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
@@ -71,6 +72,7 @@ export function AwardsPanel() {
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
const [showMissing, setShowMissing] = useState(false);
const [stats, setStats] = useState<AwardStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
@@ -105,7 +107,9 @@ export function AwardsPanel() {
async function loadList() {
try {
const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs.map((d) => ({ code: d.code, name: d.name, valid: d.valid }));
const list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid }))
.sort((a, b) => a.code.localeCompare(b.code));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
if (first) compute(first.code);
@@ -147,6 +151,22 @@ export function AwardsPanel() {
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
{/* Quick selector — scales when there are many awards. */}
{awardList.length > 0 && (
<div className="px-2 py-2 border-b border-border/40">
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select an award…" /></SelectTrigger>
<SelectContent className="max-h-80">
{awardList.map((a) => (
<SelectItem key={a.code} value={a.code} className="text-xs">
<span className="font-semibold">{a.code}</span>
<span className="text-muted-foreground"> {a.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
{awardList.map((a) => {
@@ -236,6 +256,13 @@ export function AwardsPanel() {
))}
</div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<button
onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-[11px] text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
>
<AlertTriangle className="size-3" /> Missing refs
</button>
<div className="flex-1" />
{/* Legend */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
@@ -364,6 +391,83 @@ export function AwardsPanel() {
{cell && current && (
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
)}
{showMissing && current && (
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
)}
</div>
);
}
// MissingQSOModal lists contacts within an award's scope that carry NO
// reference — the silent gaps. Rows open the QSO editor so the operator can add
// the missing reference (e.g. a department for DDFM).
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
AwardMissingQSOs(code)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [code]);
// Distinct stations vs total contacts (a station may appear on several QSOs).
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[760px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-amber-600" />
<span className="font-semibold text-sm">{code} contacts missing a reference</span>
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
<div className="flex-1" />
<button onClick={load} disabled={loading}
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
title="Recompute now — contacts you've fixed drop off the list">
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
</button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
{onEditQSO && ' Click a row to open the QSO and add the reference.'}
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity e.g. DDFM, WAS, RAC, WAJA.)
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-2 font-medium">Country</th><th className="py-1 pr-3 font-medium">QTH / Note</th></tr>
</thead>
<tbody>
{qsos.map((q, i) => (
<tr key={q.id ?? i}
className={cn('border-b border-border/30', onEditQSO && 'cursor-pointer hover:bg-accent/40')}
onClick={() => onEditQSO && q.id && onEditQSO(q.id as number)}>
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
<td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[120px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[200px]">{q.qth || q.notes}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
<span className="font-semibold text-foreground">{stations}</span> station{stations > 1 ? 's' : ''} ·{' '}
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
</div>
</div>
</div>
);
}
+45 -6
View File
@@ -42,7 +42,7 @@ type LogQSO = {
};
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[] };
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string };
const SENT_STATUSES = [
{ v: 'R', label: 'Requested' },
@@ -81,10 +81,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
const [potaAddMissing, setPotaAddMissing] = useState(false);
// Only sync hunts made under the active profile's callsign (skip XV9Q/NQ2H…).
const [potaOnlyMyCall, setPotaOnlyMyCall] = useState(true);
async function syncPota() {
setPotaSyncing(true); setPotaErr(''); setPotaRes(null);
try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing)) as any as POTASync); }
try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing, potaOnlyMyCall)) as any as POTASync); }
catch (e: any) { setPotaErr(String(e?.message ?? e)); }
finally { setPotaSyncing(false); }
}
@@ -143,6 +145,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
// Download date window: 'last' = incremental since last pull, 'date' = from a
// chosen date, 'all' = everything.
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
const [sinceDate, setSinceDate] = useState('');
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
@@ -219,8 +225,13 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
}
async function download() {
// Resolve the date window into the backend's `since` argument:
// 'all' → "" (everything)
// 'last' → "last" (incremental since last successful pull)
// 'date' → "YYYY-MM-DD" (the chosen date; falls back to all if empty)
const since = sinceMode === 'last' ? 'last' : sinceMode === 'date' ? sinceDate.trim() : '';
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
try { await DownloadConfirmations(service, addNotFound); }
try { await DownloadConfirmations(service, addNotFound, since); }
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
@@ -246,6 +257,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
Sync hunter log
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook">
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
Only my profile callsign
</label>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
Add not-found QSOs to my log
@@ -285,7 +300,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<div className="flex-1" />
{service === 'pota' && potaRes && (
<span className="text-xs text-muted-foreground">
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched / {potaRes.fetched}
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched{potaRes.skipped_other_call > 0 ? ` · ${potaRes.skipped_other_call} other call` : ''} / {potaRes.fetched}
</span>
)}
{service === 'paper' && paperRows.length > 0 && (
@@ -363,7 +378,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{potaRes && (
<>
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries). Rescan the POTA award to count the new references.
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries).
{potaRes.skipped_other_call > 0 && (
<> {potaRes.skipped_other_call} hunt(s) made under another callsign were skipped{potaRes.my_call ? ` (kept only ${potaRes.my_call})` : ''}.</>
)}
{' '}Rescan the POTA award to count the new references.
</div>
{potaRes.unmatched_list?.length > 0 && (
<table className="w-full text-xs border-collapse">
@@ -512,11 +531,31 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{/* Action bar (upload/download — not for POTA / Paper QSL) */}
{service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={download} disabled={busy}
title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" /> Download confirmations
</Button>
{/* Date window */}
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
<SelectTrigger className="h-8 w-[150px] text-xs" title="How far back to download">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last">Since last download</SelectItem>
<SelectItem value="date">Since date</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
{sinceMode === 'date' && (
<input
type="date"
value={sinceDate}
onChange={(e) => setSinceDate(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
title={service === 'qrz' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'}
/>
)}
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
+10
View File
@@ -433,6 +433,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
<div className="flex flex-col gap-2.5">
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
<div>
<Label>Continent</Label>
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">—</SelectItem>
{['NA', 'SA', 'EU', 'AF', 'AS', 'OC', 'AN'].map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
</div>