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
+2 -2
View File
@@ -2706,7 +2706,7 @@ export default function App() {
</TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel />
<AwardsPanel onEditQSO={openEdit} />
</TabsContent>
</Tabs>
</section>
@@ -2940,7 +2940,7 @@ export default function App() {
<span>
Fix country &amp; zones (cty.dat + ClubLog)
<span className="block text-xs text-muted-foreground mt-0.5">
Recompute Country, DXCC &amp; CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). If ClubLog exceptions are enabled, its date-ranged DXpedition overrides are applied on top (per QSO date). Everything else in the ADIF is kept as-is.
Recompute Country, DXCC &amp; CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF Reunion, TO2A 2012 French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use <strong>Update duplicates</strong> to re-fix QSOs already in your log.
</span>
</span>
</label>
+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>