up
This commit is contained in:
+79
-17
@@ -15,9 +15,9 @@ import {
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat,
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
GetDBConnectionInfo,
|
||||
GetDBConnectionInfo, GetLogbookRevision,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
@@ -45,6 +45,7 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
import { MainMap } from '@/components/MainMap';
|
||||
@@ -435,7 +436,6 @@ export default function App() {
|
||||
const [qsos, setQsos] = useState<QSO[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [error, setError] = useState('');
|
||||
const [migratedBanner, setMigratedBanner] = useState(false);
|
||||
// Secret vault (encrypted passwords): prompt to unlock at launch when a
|
||||
// passphrase is configured but not yet entered this session.
|
||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||
@@ -667,6 +667,7 @@ export default function App() {
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||
|
||||
// === ADIF ===
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -732,6 +733,7 @@ export default function App() {
|
||||
callsign: '', operator: '',
|
||||
my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '',
|
||||
});
|
||||
const [showFirstRun, setShowFirstRun] = useState(false);
|
||||
myCallRef.current = (station.callsign || '').toUpperCase();
|
||||
|
||||
// Bearing/distance from operator's grid to the DX — used by the entry-strip
|
||||
@@ -864,6 +866,36 @@ export default function App() {
|
||||
// local SQLite file path).
|
||||
useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []);
|
||||
|
||||
// The logbook can switch at runtime when the active profile changes (each
|
||||
// profile can target its own SQLite/MySQL database). Refresh the grid and the
|
||||
// status-bar label when that happens.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('logbook:changed', () => {
|
||||
GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {});
|
||||
refresh();
|
||||
});
|
||||
return () => { off(); };
|
||||
}, [refresh]);
|
||||
|
||||
// Live sync for a SHARED MySQL logbook: poll a cheap revision fingerprint so
|
||||
// QSOs another operator's instance adds/removes show up here within a few
|
||||
// seconds, without a manual refresh. Pointless on local SQLite (single writer).
|
||||
useEffect(() => {
|
||||
if (dbConn?.backend !== 'mysql') return;
|
||||
let alive = true;
|
||||
let last = '';
|
||||
const tick = async () => {
|
||||
try {
|
||||
const rev = await GetLogbookRevision();
|
||||
if (alive && last && rev !== last) await refresh();
|
||||
if (alive) last = rev;
|
||||
} catch { /* logbook briefly unavailable — try again next tick */ }
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 2000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [dbConn, refresh]);
|
||||
|
||||
// RX freq mirrors TX freq on every TX change (unless the rig is in split,
|
||||
// where the RX freq is genuinely different). It stays editable by hand:
|
||||
// a manual RX edit sticks until the next TX-freq change re-syncs it.
|
||||
@@ -986,8 +1018,12 @@ export default function App() {
|
||||
// case its one-shot fetch ran during the startup race (before the
|
||||
// backend was determined) and grabbed the wrong/stale value.
|
||||
GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {});
|
||||
} else if (!ok && alive && tries++ < 30) {
|
||||
timer = window.setTimeout(attempt, 500);
|
||||
} else if (!ok && alive && tries++ < 360) {
|
||||
// Quick retries at first (normal startup connects in ~2 s); then keep
|
||||
// trying for several minutes, because the very first migration against a
|
||||
// slow remote MySQL can legitimately take that long before the logbook
|
||||
// is ready. Stays silent so no "db not available" flashes meanwhile.
|
||||
timer = window.setTimeout(attempt, tries < 20 ? 500 : 1000);
|
||||
} else if (!ok && alive) {
|
||||
refresh(); // give up quietly retrying; surface the error now
|
||||
}
|
||||
@@ -1000,7 +1036,12 @@ export default function App() {
|
||||
try {
|
||||
const st = await GetStartupStatus();
|
||||
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
||||
if ((st as any).migrated_from_app_data) setMigratedBanner(true);
|
||||
// First launch (or a never-configured profile): collect the mandatory
|
||||
// station identity before anything else.
|
||||
try {
|
||||
const ss = await GetStationSettings();
|
||||
if (!ss.callsign?.trim()) setShowFirstRun(true);
|
||||
} catch {}
|
||||
} catch {}
|
||||
// Prompt to unlock encrypted passwords if a passphrase is configured.
|
||||
try {
|
||||
@@ -1186,6 +1227,16 @@ export default function App() {
|
||||
setWkSendOnType(!!s.send_on_type);
|
||||
} catch { /* keyer not configured */ }
|
||||
}, []);
|
||||
|
||||
// Every setting is per-profile, so when the active profile changes the whole
|
||||
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||
// side reloads its managers; this keeps the React state in sync.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('profile:changed', () => {
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk();
|
||||
});
|
||||
return () => { off(); };
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await reloadWk();
|
||||
@@ -1723,11 +1774,12 @@ export default function App() {
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
||||
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
|
||||
]},
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
|
||||
]},
|
||||
], [total, selectedId, ctyRefreshing, exporting, wkEnabled, dvkEnabled]);
|
||||
], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -1744,6 +1796,21 @@ export default function App() {
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadRefs() {
|
||||
if (refsDownloading) return;
|
||||
setRefsDownloading(true);
|
||||
setError('');
|
||||
try {
|
||||
const summary = await DownloadAllReferenceLists();
|
||||
showToast(`Reference lists updated — ${summary}`);
|
||||
} catch (e: any) {
|
||||
setError(`Reference download failed: ${String(e?.message ?? e)}`);
|
||||
} finally {
|
||||
setRefsDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2366,18 +2433,13 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||
success toast; both auto-dismiss. */}
|
||||
{migratedBanner && (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[110] flex items-start gap-3 rounded-lg border border-emerald-400 bg-emerald-50 text-emerald-900 px-4 py-3 text-sm shadow-xl max-w-lg animate-in fade-in slide-in-from-top-2">
|
||||
<span className="flex-1">
|
||||
<strong>Migration complete.</strong> Your data has been copied to the data folder next to OpsLog.exe.
|
||||
Please <strong>restart OpsLog</strong> to use the new location.
|
||||
</span>
|
||||
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setMigratedBanner(false)}>×</button>
|
||||
</div>
|
||||
{/* First launch: mandatory station identity. Blocks until filled. */}
|
||||
{showFirstRun && (
|
||||
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
||||
)}
|
||||
|
||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||
success toast; both auto-dismiss. */}
|
||||
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
||||
skipping leaves lookups/uploads without their passwords until unlocked. */}
|
||||
{unlockOpen && (
|
||||
|
||||
@@ -63,13 +63,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
|
||||
// for a French call but not for others.
|
||||
const awards = useMemo(() => {
|
||||
return defs.filter((d) => {
|
||||
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
|
||||
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||
const m = metas[String(d.code).toUpperCase()];
|
||||
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
|
||||
if (!hasRefs) return false;
|
||||
const scope = d.dxcc_filter ?? [];
|
||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||
// Offer the award even when its reference list isn't loaded yet: a custom
|
||||
// award (e.g. "Worked All Provinces of China") has no built-in list, so
|
||||
// requiring one would make it impossible to assign references by hand. The
|
||||
// operator can pick from a loaded list when present, or add an unlisted
|
||||
// reference (the right-hand panel). Dynamic / list-backed awards behave as
|
||||
// before — they just always pass here now.
|
||||
return true;
|
||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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 { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
@@ -57,7 +58,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
||||
);
|
||||
}
|
||||
|
||||
type AwardListItem = { code: string; name: string; valid?: boolean };
|
||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[] };
|
||||
|
||||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||
@@ -108,7 +109,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
try {
|
||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||
const list: AwardListItem[] = defs
|
||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid }))
|
||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [] }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
setAwardList(list);
|
||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||
@@ -121,6 +122,17 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
|
||||
const current = byCode[selected];
|
||||
|
||||
// Band columns for the reference matrix: restrict to the award's own valid
|
||||
// bands (e.g. WAPC = 40/20/15/10m) so the grid isn't padded with bands the
|
||||
// award doesn't count. An award with no band restriction shows all bands.
|
||||
const gridBands = useMemo(() => {
|
||||
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
|
||||
if (vb.length === 0) return GRID_BANDS;
|
||||
const set = new Set(vb);
|
||||
const filtered = GRID_BANDS.filter((b) => set.has(b));
|
||||
return filtered.length ? filtered : GRID_BANDS;
|
||||
}, [awardList, selected]);
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!current) return [];
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
@@ -228,10 +240,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{/* Band breakdown */}
|
||||
{(current.bands ?? []).length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border/60">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(current.bands ?? []).map((b) => (
|
||||
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
||||
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||
@@ -245,9 +257,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs">
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setRefFilter(k)}
|
||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
@@ -255,10 +267,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||
<span className="text-xs 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"
|
||||
className="flex items-center gap-1 text-xs 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
|
||||
@@ -283,13 +295,13 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{statsLoading || !stats ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
||||
) : (
|
||||
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th>
|
||||
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)}
|
||||
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th>
|
||||
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
|
||||
{stats.bands.map((b) => <th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>)}
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -297,13 +309,13 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
|
||||
return (
|
||||
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
|
||||
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||
{row.cells.map((c, j) => (
|
||||
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||
<td key={j} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||
))}
|
||||
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -313,30 +325,30 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
</div>
|
||||
) : view === 'grid' ? (
|
||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th>
|
||||
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th>
|
||||
{GRID_BANDS.map((b) => (
|
||||
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
|
||||
{gridBands.map((b) => (
|
||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRefs.map((r) => (
|
||||
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30">
|
||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||||
</td>
|
||||
{GRID_BANDS.map((b) => {
|
||||
{gridBands.map((b) => {
|
||||
const s = cellStatus(r, b);
|
||||
return (
|
||||
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||
{s === 'none' ? <span className="block w-9 h-5" /> : (
|
||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||
<button
|
||||
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||
title={`${r.ref} · ${b} — click to view QSOs`}
|
||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||
>{CELL_LABEL[s]}</button>
|
||||
@@ -351,22 +363,22 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1 pr-2 font-medium w-24">Ref</th>
|
||||
<th className="py-1 pr-2 font-medium">Name</th>
|
||||
<th className="py-1 pr-2 font-medium w-40">Group</th>
|
||||
<th className="py-1 pr-2 font-medium w-24">Status</th>
|
||||
<th className="py-1 font-medium">Bands</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">Ref</th>
|
||||
<th className="py-1.5 pr-2 font-medium">Name</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
||||
<th className="py-1.5 font-medium">Bands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRefs.map((r) => (
|
||||
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
||||
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||
<td className="py-1 pr-2">
|
||||
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
||||
: r.validated ? <span className="text-emerald-600">validated</span>
|
||||
@@ -401,23 +413,91 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
// 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).
|
||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||
|
||||
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 [sel, setSel] = useState<Set<number>>(new Set());
|
||||
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
||||
const [assignRef, setAssignRef] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setSel(new Set());
|
||||
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).
|
||||
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
|
||||
useEffect(() => {
|
||||
ListAwardReferences(code)
|
||||
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
||||
.catch(() => setRefs([]));
|
||||
}, [code]);
|
||||
|
||||
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
const val = (q: any): string => {
|
||||
switch (sortKey) {
|
||||
case 'qso_date': return String(q.qso_date ?? '');
|
||||
case 'callsign': return String(q.callsign ?? '').toUpperCase();
|
||||
case 'band': return String(q.band ?? '');
|
||||
case 'mode': return String(q.mode ?? '');
|
||||
case 'country': return String(q.country ?? '').toUpperCase();
|
||||
case 'qth': return qthOf(q).toUpperCase();
|
||||
}
|
||||
};
|
||||
return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
|
||||
}, [qsos, sortKey, sortDir]);
|
||||
|
||||
const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
|
||||
const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
|
||||
const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
|
||||
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
const setSort = (k: MissingSortKey) => {
|
||||
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else { setSortKey(k); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
async function applyAssign() {
|
||||
if (!assignRef || sel.size === 0) return;
|
||||
setBusy(true); setMsg('');
|
||||
try {
|
||||
const assignedIds = Array.from(sel);
|
||||
const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
|
||||
// Optimistic: the assigned contacts now carry a reference, so drop them
|
||||
// from the list locally instead of re-running the slow whole-log scan.
|
||||
const done = new Set(assignedIds);
|
||||
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||
setSel(new Set());
|
||||
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
||||
} catch (e: any) {
|
||||
setMsg(String(e?.message ?? e));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
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', ' '); };
|
||||
|
||||
const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
|
||||
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
|
||||
<span className="inline-flex items-center gap-0.5">{label}
|
||||
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
|
||||
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="bg-card border border-border rounded-lg shadow-xl w-[860px] 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>
|
||||
@@ -432,8 +512,28 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
</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.'}
|
||||
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
||||
</div>
|
||||
|
||||
{/* Bulk-assign toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||
<span className="text-xs text-muted-foreground">{sel.size} selected →</span>
|
||||
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{refs.map((r) => (
|
||||
<SelectItem key={r.code} value={r.code}>
|
||||
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
|
||||
</Button>
|
||||
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
||||
</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>
|
||||
@@ -443,22 +543,35 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
</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 className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
||||
<tr>
|
||||
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
|
||||
<SortTh k="callsign" label="Callsign" />
|
||||
<SortTh k="band" label="Band" />
|
||||
<SortTh k="mode" label="Mode" />
|
||||
<SortTh k="country" label="Country" />
|
||||
<SortTh k="qth" label="QTH / Note" />
|
||||
</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>
|
||||
))}
|
||||
{sorted.map((q, i) => {
|
||||
const id = q.id as number;
|
||||
return (
|
||||
<tr key={id ?? i}
|
||||
className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
|
||||
<td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
|
||||
</td>
|
||||
<td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
|
||||
<td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{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-[140px]">{q.country}</td>
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
@@ -104,16 +104,25 @@ 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).
|
||||
const curClass = CLASSES.find((c) => classMatchesMode(c, currentMode));
|
||||
const statusKeys = useMemo(() => Array.from(statusMap.keys()), [statusMap]);
|
||||
const slotStatus = curClass ? statusMap.get(`${currentBand}|${curClass}`) : undefined;
|
||||
const bandWorked = statusKeys.some((k) => k.split('|')[0] === currentBand);
|
||||
const modeWorked = !!curClass && statusKeys.some((k) => k.split('|')[1] === curClass);
|
||||
const newBand = hasDxcc && !newOne && !bandWorked;
|
||||
const newMode = hasDxcc && !newOne && !!curClass && !modeWorked;
|
||||
const newBandMode = hasDxcc && !newOne && !!curClass && !slotStatus;
|
||||
// New slot for THIS call: worked the op before, but not on this band+mode.
|
||||
const newSlot = !newOne && callCount > 0 && !!curClass && slotStatus !== 'call_c' && slotStatus !== 'call_w';
|
||||
// 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.
|
||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||
const curMode = (currentMode || '').toUpperCase().trim();
|
||||
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);
|
||||
// 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.
|
||||
// New Band = the band is new (the mode was worked on another band).
|
||||
// New Mode = the mode is new (the band was worked in another mode).
|
||||
// New Slot = both band and mode already worked — just not together.
|
||||
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
|
||||
const newBandMode = slotNew && !bandWorked && !modeWorked;
|
||||
const newBand = slotNew && !bandWorked && modeWorked;
|
||||
const newMode = slotNew && bandWorked && !modeWorked;
|
||||
const newSlot = slotNew && bandWorked && modeWorked;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -152,9 +161,9 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</span>
|
||||
{(newBand || newMode || newBandMode || newSlot) && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||
{!newBand && !newMode && newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -113,6 +113,19 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
];
|
||||
}
|
||||
|
||||
// statusBadge maps a resolved spot status to a short labelled badge for the
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
}
|
||||
}
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
{
|
||||
group: 'Spot', label: 'Time', colId: 'time',
|
||||
@@ -136,6 +149,38 @@ const COL_CATALOG: ColEntry[] = [
|
||||
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Status', colId: 'status',
|
||||
headerName: 'Status', width: 96, sortable: true,
|
||||
defaultVisible: true,
|
||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||
// is obvious at the row level, not just a single coloured cell.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return 'NEW DXCC';
|
||||
if (s?.status === 'new-band') return 'NEW BAND';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||
return s?.worked_call ? 'WKD CALL' : '';
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const b = statusBadge(statusFor(p));
|
||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{
|
||||
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
||||
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{b.text}</span>
|
||||
);
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
||||
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
||||
if (s?.worked_call) return 'Already worked this call';
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'POTA', colId: 'pota',
|
||||
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
|
||||
@@ -205,8 +205,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</Field>
|
||||
{/* DXCC #, Continent and Azimuth SP live in the main entry strip /
|
||||
bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */}
|
||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||
bearing and distances move to the row below. */}
|
||||
<Field label="DXCC #">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={details.dxcc ?? ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Azimuth LP">
|
||||
<Input
|
||||
readOnly
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Radio } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
|
||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
|
||||
// FirstRunModal collects the mandatory station identity on the very first launch
|
||||
// (no callsign configured yet). It writes straight into the active profile, so
|
||||
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
const [p, setP] = useState<Profile | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [refsState, setRefsState] = useState<'idle' | 'loading' | 'done'>('idle');
|
||||
const [refsMsg, setRefsMsg] = useState('');
|
||||
|
||||
async function downloadRefs() {
|
||||
setRefsState('loading');
|
||||
try {
|
||||
const summary = await DownloadAllReferenceLists();
|
||||
setRefsMsg(summary);
|
||||
setRefsState('done');
|
||||
} catch (e: any) {
|
||||
setRefsMsg(String(e?.message ?? e));
|
||||
setRefsState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
GetActiveProfile().then((x) => setP(x as Profile)).catch(() => setP({} as Profile));
|
||||
}, []);
|
||||
|
||||
const set = (patch: Partial<Profile>) => setP((s: Profile | null) => ({ ...(s as Profile), ...patch }));
|
||||
|
||||
const callsign = (p?.callsign ?? '').trim().toUpperCase();
|
||||
const grid = (p?.my_grid ?? '').trim().toUpperCase();
|
||||
const operator = (p?.operator ?? '').trim().toUpperCase();
|
||||
const canSave = callsign.length >= 3 && grid.length >= 4;
|
||||
|
||||
async function save() {
|
||||
if (!p || !canSave) return;
|
||||
setSaving(true);
|
||||
setErr('');
|
||||
try {
|
||||
await SaveProfile({
|
||||
...p,
|
||||
callsign,
|
||||
my_grid: grid,
|
||||
operator: operator || callsign,
|
||||
owner_callsign: (p.owner_callsign ?? '').trim().toUpperCase() || callsign,
|
||||
op_name: (p.op_name ?? '').trim(),
|
||||
} as any);
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Radio className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
|
||||
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Operator</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Owner</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Name</Label>
|
||||
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium">Award reference lists</div>
|
||||
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
||||
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
|
||||
</Button>
|
||||
</div>
|
||||
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
|
||||
</div>
|
||||
|
||||
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
|
||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,13 +121,21 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||
if (from && beamAzimuths && beamAzimuths.length) {
|
||||
const half = (beamWidth ?? 30) / 2;
|
||||
const D = 5500; // lobe length (km) — short enough to rarely reach a pole
|
||||
const radial = (b: number): [number, number][] =>
|
||||
Array.from({ length: 14 }, (_, i) => {
|
||||
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
|
||||
return [d.lat, d.lon] as [number, number];
|
||||
});
|
||||
const edge = { color: '#dc2626', weight: 1.5, opacity: 0.6 };
|
||||
const D = 5500; // lobe length (km)
|
||||
// A great circle pointing poleward runs to lat ±90, where Mercator is
|
||||
// infinite — the line then snaps across the top of the map. Generate the
|
||||
// radial with plenty of points (smooth curve) and STOP it just before the
|
||||
// pole, so a north/south beam draws a clean line toward the edge instead.
|
||||
const radial = (b: number): [number, number][] => {
|
||||
const pts: [number, number][] = [];
|
||||
const N = 64;
|
||||
for (let i = 1; i <= N; i++) {
|
||||
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
|
||||
pts.push([d.lat, d.lon]);
|
||||
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
for (const az of beamAzimuths) {
|
||||
const arc: [number, number][] = [];
|
||||
for (let b = az - half; b <= az + half + 0.001; b += 2) {
|
||||
@@ -140,13 +148,10 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
||||
...arc,
|
||||
...radial(az + half).reverse(),
|
||||
]);
|
||||
// A geodesic lobe that reaches near a pole can't be filled on a
|
||||
// Mercator map without the polygon snapping across the whole world —
|
||||
// draw just the two edges in that case; otherwise the translucent lobe.
|
||||
if (ring.some(([la]) => Math.abs(la) > 82)) {
|
||||
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az - half)]) as L.LatLngExpression[], edge).addTo(wo);
|
||||
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az + half)]) as L.LatLngExpression[], edge).addTo(wo);
|
||||
} else {
|
||||
// Near a pole the lobe's two edges diverge wildly (one runs NW, the
|
||||
// other NE) and look broken on a Mercator map — so for a poleward beam
|
||||
// show ONLY the clean boresight (below). Otherwise draw the filled lobe.
|
||||
if (!ring.some(([la]) => Math.abs(la) > 78)) {
|
||||
L.polygon(ring as L.LatLngExpression[], {
|
||||
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
|
||||
}).addTo(wo);
|
||||
|
||||
@@ -94,6 +94,11 @@ function toLocalISO(d: any): string {
|
||||
if (!d) return '';
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
|
||||
// doesn't apply to a time struct), so a QSO with no end time arrives as a
|
||||
// year-1 date. Treat anything that old as unset — otherwise the datetime
|
||||
// field shows a garbage value and fights the user's typing.
|
||||
if (date.getUTCFullYear() <= 1) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
||||
}
|
||||
@@ -159,8 +164,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
||||
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
||||
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
||||
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
||||
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
|
||||
const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
|
||||
const [dateOff, setDateOff] = useState(dateOffISO);
|
||||
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
|
||||
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
||||
const [localErr, setLocalErr] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -428,7 +434,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||
<div>
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
|
||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
||||
const on = !!c;
|
||||
setEndEnabled(on);
|
||||
// Prefill an empty end with the start time so the user
|
||||
// only tweaks the minutes instead of typing a full date.
|
||||
if (on && !dateOff) setDateOff(dateOn);
|
||||
}} /> QSO End (UTC)
|
||||
</Label>
|
||||
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||
GetDataDir,
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
@@ -125,6 +127,7 @@ const emptyProfile = (): Profile => ({
|
||||
tx_pwr: undefined,
|
||||
is_active: false,
|
||||
sort_order: 0,
|
||||
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
|
||||
created_at: '' as any,
|
||||
updated_at: '' as any,
|
||||
});
|
||||
@@ -157,6 +160,7 @@ type SectionId =
|
||||
| 'cluster'
|
||||
| 'backup'
|
||||
| 'database'
|
||||
| 'autostart'
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'rotator'
|
||||
@@ -190,6 +194,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
||||
{ kind: 'item', label: 'Database', id: 'database' },
|
||||
{ kind: 'item', label: 'Autostart', id: 'autostart' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -216,6 +221,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
cluster: 'DX Cluster',
|
||||
backup: 'Database backup',
|
||||
database: 'Database',
|
||||
autostart: 'Autostart',
|
||||
udp: 'UDP integrations',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
@@ -316,6 +322,129 @@ function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: s
|
||||
);
|
||||
}
|
||||
|
||||
// AutostartPanelComponent manages the per-profile list of external programs to
|
||||
// launch when OpsLog starts. It's a self-contained component (its own state) so
|
||||
// it can use hooks — rendered via the `() => <AutostartPanelComponent/>` wrapper
|
||||
// in PANELS. Changes persist immediately (config is local SQLite, cheap writes).
|
||||
type AutostartProg = { id: string; name: string; path: string; args: string; enabled: boolean };
|
||||
|
||||
function AutostartPanelComponent() {
|
||||
const [progs, setProgs] = useState<AutostartProg[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [launchMsg, setLaunchMsg] = useState<Record<string, string>>({});
|
||||
|
||||
async function load() {
|
||||
try { setProgs(((await GetAutostartPrograms()) ?? []) as any); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setLoaded(true); }
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
const off = EventsOn('profile:changed', () => load());
|
||||
return () => { if (typeof off === 'function') off(); };
|
||||
}, []);
|
||||
|
||||
async function commit(next: AutostartProg[]) {
|
||||
setProgs(next);
|
||||
try { await SaveAutostartPrograms(next as any); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
const patch = (id: string, p: Partial<AutostartProg>) =>
|
||||
commit(progs.map((x) => (x.id === id ? { ...x, ...p } : x)));
|
||||
const remove = (id: string) => commit(progs.filter((x) => x.id !== id));
|
||||
|
||||
async function addProgram() {
|
||||
try {
|
||||
const path = await BrowseExecutable();
|
||||
if (!path) return;
|
||||
const base = path.split(/[\\/]/).pop() || path;
|
||||
const name = base.replace(/\.(exe|bat|cmd)$/i, '');
|
||||
const id = (crypto as any)?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
commit([...progs, { id, name, path, args: '', enabled: true }]);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function rebrowse(id: string) {
|
||||
try { const path = await BrowseExecutable(); if (path) patch(id, { path }); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function launchNow(id: string) {
|
||||
try {
|
||||
const r: any = await LaunchAutostartProgram(id);
|
||||
const txt = r?.status === 'launched' ? '✓ launched'
|
||||
: r?.status === 'already_running' ? 'already running — not started again'
|
||||
: r?.status === 'missing' ? '✗ executable not found'
|
||||
: (r?.message || r?.status || 'done');
|
||||
setLaunchMsg((m) => ({ ...m, [id]: txt }));
|
||||
} catch (e: any) { setLaunchMsg((m) => ({ ...m, [id]: String(e?.message ?? e) })); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Autostart"
|
||||
hint="Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile."
|
||||
/>
|
||||
<div className="space-y-2 max-w-3xl">
|
||||
{loaded && progs.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">No programs yet — add one below.</p>
|
||||
)}
|
||||
{progs.map((p) => (
|
||||
<div key={p.id} className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={p.enabled} onCheckedChange={(c) => patch(p.id, { enabled: !!c })} title="Launch at startup" />
|
||||
<Input className="h-8 flex-1 font-medium" value={p.name} placeholder="Name"
|
||||
onChange={(e) => patch(p.id, { name: e.target.value })} />
|
||||
<Button size="sm" variant="outline" onClick={() => launchNow(p.id)} title="Launch now">
|
||||
<Play className="size-3.5" /> Launch
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => remove(p.id)} title="Remove">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[78px_1fr] items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Program</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 flex-1 font-mono text-xs" value={p.path} readOnly title={p.path} />
|
||||
<Button size="sm" variant="outline" onClick={() => rebrowse(p.id)}>
|
||||
<FolderOpen className="size-3.5" /> Browse
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-xs text-muted-foreground">Arguments</Label>
|
||||
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
|
||||
onChange={(e) => patch(p.id, { args: e.target.value })} />
|
||||
</div>
|
||||
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" onClick={addProgram}>
|
||||
<Plus className="size-4" /> Add program…
|
||||
</Button>
|
||||
{err && <div className="text-xs text-destructive">{err}</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat
|
||||
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||
// can own its state; embedded inside GeneralPanel.
|
||||
function TelemetryToggle() {
|
||||
const [on, setOn] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
||||
}, []);
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={on} disabled={!loaded}
|
||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||
Send anonymous usage statistics
|
||||
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||
const label = SECTION_LABELS[id] ?? id;
|
||||
const IconCmp = Icon ?? Construction;
|
||||
@@ -656,6 +785,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Every setting is per-profile, so when the active profile changes WHILE this
|
||||
// dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
|
||||
// — otherwise they keep showing the previous profile's values until reopen.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('profile:changed', () => {
|
||||
(async () => {
|
||||
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||||
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
||||
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
||||
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||||
try {
|
||||
const ls: any = await GetListsSettings();
|
||||
setLists(ls);
|
||||
setRstText({
|
||||
phone: (ls.rst_phone ?? []).join(' '),
|
||||
cw: (ls.rst_cw ?? []).join(' '),
|
||||
digital: (ls.rst_digital ?? []).join(' '),
|
||||
});
|
||||
} catch {}
|
||||
})();
|
||||
});
|
||||
return () => { off(); };
|
||||
}, []);
|
||||
|
||||
// Auto-fill the active profile's MY_* DXCC metadata from the station
|
||||
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
|
||||
// are derived values, so they always recompute when the callsign or grid
|
||||
@@ -2700,23 +2863,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<SectionHeader
|
||||
title="Database"
|
||||
/>
|
||||
{/* Backend selector — top of the panel. SQLite (solo) vs MySQL (shared).
|
||||
The choice is persisted immediately (it lives in config.json, read
|
||||
before the DB opens) so switching to SQLite isn't lost when the MySQL
|
||||
panel below — which holds its own Save button — disappears. */}
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-3">
|
||||
{/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can
|
||||
target its own database; choosing here and Save switches the live
|
||||
logbook immediately (no restart). */}
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
||||
<Label className="text-sm">Backend</Label>
|
||||
<Select
|
||||
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||||
onValueChange={(v) => {
|
||||
const next = { ...mysqlCfg, enabled: v === 'mysql' };
|
||||
setMysqlCfg(next);
|
||||
SaveMySQLSettings(next as any)
|
||||
.then(() => setRestartMsg(next.enabled
|
||||
? 'MySQL selected — fill in the connection below, Test, then restart.'
|
||||
: 'Switched to local SQLite — restart OpsLog to apply.'))
|
||||
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||
}}
|
||||
onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2725,15 +2879,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">
|
||||
This is the logbook for the <strong>active profile</strong>. Different profiles can point at different databases — switching profile switches the logbook.
|
||||
</p>
|
||||
|
||||
{/* Restart prompt shown after any backend change (works in both states,
|
||||
unlike the MySQL panel's own Save which is hidden when SQLite). */}
|
||||
{restartMsg && (
|
||||
<div className="max-w-2xl mb-4 text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3">
|
||||
<span>{restartMsg}</span>
|
||||
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Save (always visible) applies the active profile's DB target live. */}
|
||||
<div className="max-w-2xl mb-4 flex items-center gap-3">
|
||||
<Button size="sm" className="h-8"
|
||||
onClick={() => {
|
||||
SaveMySQLSettings(mysqlCfg as any)
|
||||
.then(() => setRestartMsg(mysqlCfg.enabled
|
||||
? 'Logbook switched to MySQL ✓'
|
||||
: 'Logbook switched to local SQLite ✓'))
|
||||
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||
}}>
|
||||
Save & switch logbook
|
||||
</Button>
|
||||
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
|
||||
</div>
|
||||
|
||||
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
|
||||
{backendStatus && (
|
||||
@@ -2793,7 +2956,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
{mysqlCfg.enabled && (
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
Several OpsLog instances pointed at one MySQL server see each other's QSOs live. Test the connection, then <strong>Save</strong> — OpsLog switches to MySQL (and creates all tables) on the next launch.
|
||||
Several OpsLog instances pointed at one MySQL database see each other's QSOs live (refreshed every 2 s). <strong>Test & create</strong> the database, then <strong>Save & switch logbook</strong> above to start logging there.
|
||||
</div>
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">Host</Label>
|
||||
@@ -2812,10 +2975,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
onClick={() => { setMysqlMsg('Testing…'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected — database ready ✓')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}>
|
||||
Test & create database
|
||||
</Button>
|
||||
<Button size="sm" className="h-8"
|
||||
onClick={() => { SaveMySQLSettings(mysqlCfg as any).then(() => setRestartMsg('Saved — restart OpsLog to connect to MySQL.')).catch((e: any) => setErr(String(e?.message ?? e))); }}>
|
||||
Save
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3032,7 +3191,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
|
||||
<div className="space-y-3 max-w-lg">
|
||||
<div className="space-y-3 max-w-3xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
||||
Auto-focus "Worked before" for known stations
|
||||
@@ -3049,6 +3208,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
|
||||
</label>
|
||||
<TelemetryToggle />
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
||||
@@ -3221,6 +3381,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
udp: UDPIntegrationsPanelWrapper,
|
||||
backup: BackupPanel,
|
||||
database: DatabasePanel,
|
||||
autostart: () => <AutostartPanelComponent />,
|
||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||
cat: CATPanel,
|
||||
rotator: RotatorPanel,
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -33,12 +33,28 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
// applyAwardRefs writes picked references onto a QSO payload using each award's
|
||||
// scanned field. fieldOf maps an award CODE (uppercase) to its field name.
|
||||
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||
// The award engine honours these regardless of how the award matches, so a
|
||||
// reference can be assigned even for awards that scan a free-text field by
|
||||
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
|
||||
// the field wouldn't match.
|
||||
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
||||
|
||||
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
|
||||
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
|
||||
// awards backed by a standard ADIF column also get that column written so the
|
||||
// data lives in its conventional place and exports correctly. Awards that match
|
||||
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||
// on the override — we don't pollute the text field with a code.
|
||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
||||
const byCode = parseAwardRefs(awardRefs);
|
||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||
const overrides: string[] = [];
|
||||
for (const [code, ref] of Object.entries(byCode)) {
|
||||
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
overrides.push(`${code}@${r}`);
|
||||
}
|
||||
const field = fieldOf[code] || code.toLowerCase();
|
||||
switch (field) {
|
||||
case 'iota': payload.iota = ref; break;
|
||||
@@ -53,21 +69,24 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
|
||||
extras['SIG'] = 'WWFF';
|
||||
extras['SIG_INFO'] = ref;
|
||||
break;
|
||||
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM
|
||||
// scans the note for "D06"). Picking such a reference appends its code(s)
|
||||
// to that field so the matcher finds it.
|
||||
// QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
|
||||
// "D06" in the note): append the code so the in-field matcher finds it too.
|
||||
case 'note': case 'notes':
|
||||
payload.notes = appendTokens(payload.notes, ref);
|
||||
break;
|
||||
case 'comment':
|
||||
payload.comment = appendTokens(payload.comment, ref);
|
||||
break;
|
||||
// address / qth / name / custom: the override above is authoritative; do
|
||||
// not write a code into a free-text field the award matches by name.
|
||||
default:
|
||||
extras[field.toUpperCase()] = ref;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
|
||||
else delete extras[MANUAL_REFS_KEY];
|
||||
if (Object.keys(extras).length > 0) payload.extras = extras;
|
||||
else if (payload.extras) delete payload.extras;
|
||||
}
|
||||
|
||||
// awardRefValue reads a single award's stored reference from a QSO, inverse of
|
||||
|
||||
Reference in New Issue
Block a user