This commit is contained in:
2026-06-15 23:45:14 +02:00
parent 29fd832bcd
commit 22e3bb4a18
32 changed files with 2531 additions and 362 deletions
+7 -4
View File
@@ -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));
+167 -54
View File
@@ -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>
)}
+20 -11
View File
@@ -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 &amp; 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 &amp; Mode</Badge>}
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
</div>
)}
+45
View File
@@ -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',
+12 -2
View File
@@ -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
+117
View File
@@ -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 &amp; 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>
);
}
+19 -14
View File
@@ -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);
+15 -3
View File
@@ -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>
+190 -29
View File
@@ -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 &amp; 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 &amp; create</strong> the database, then <strong>Save &amp; 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 &amp; 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,
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}