Files
OpsLog/frontend/src/components/DetailsPanel.tsx
T
rouggy d4f23a52af fix(details): QSL via gets the width, QSL message gives it up
Width should follow use, and these two are nowhere near equal: a manager's
callsign is typed constantly, a QSL message almost never. The message held 7
columns of 12 for text most operators never write, while the field beside it —
often a long "via" instruction — was the one running out of room.

Swapped, so QSL via takes 7 and the message 5. Also puts them in the order they
are reached for.
2026-08-11 21:35:41 +02:00

510 lines
24 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { AwardRefSelector } from '@/components/AwardRefSelector';
export interface DetailsState {
state: string;
cnty: string;
address: string;
lat?: number;
lon?: number;
// DXCC entity number + zones (filled from QRZ/HamQTH or cty.dat fallback).
// Editable so the operator can correct an obviously wrong auto-fill.
dxcc?: number;
cqz?: number;
ituz?: number;
cont: string;
qsl_msg: string;
qsl_via: string;
ant_az?: number;
ant_el?: number;
ant_path: string;
prop_mode: string;
my_rig: string;
my_antenna: string;
tx_pwr?: number;
sat_name: string;
sat_mode: string;
contest_id: string;
// Contest exchanges as free text — most are serials (001) but some are
// alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
srx_string?: string;
stx_string?: string;
email: string;
// Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
award_refs?: string;
}
interface Props {
callsign: string;
prefix: string;
operatorGrid: string; // station.my_grid — origin for bearing/distance
remoteGrid: string; // entry-strip Grid value — destination
// Entry-strip text fields. Live award detection matches on these too — a DARC
// DOK keys off QTH, not the address — and sending only the address meant such
// an award stayed silent until the QSO was logged.
qth?: string;
name?: string;
country?: string;
comment?: string;
note?: string;
details: DetailsState;
onChange: (patch: Partial<DetailsState>) => void;
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
wb?: any;
wbBusy?: boolean;
band: string;
mode: string;
bands?: string[]; // configured bands for the worked-before matrix columns
// When the entry form is empty and a QSO is selected in the log grid, the
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
// Only the matrix is redirected — every other tab still edits the live entry.
slotCall?: string;
slotBand?: string;
slotMode?: string;
slotWb?: any;
slotWbBusy?: boolean;
imageUrl?: string;
onOpenImage?: () => void;
// Optional controlled active tab (so the app can switch it via keyboard).
tab?: TabName;
onTab?: (t: TabName) => void;
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
// shown as Ctrl+F1…F5 instead of F1…F5.
keyerActive?: boolean;
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
onEditQso?: (id: number) => void;
}
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
const PROP_MODES: { value: string; label: string }[] = [
{ value: 'NONE', label: '—' },
{ value: 'AS', label: 'detp.propAS' },
{ value: 'AUR', label: 'detp.propAUR' },
{ value: 'AUE', label: 'detp.propAUE' },
{ value: 'BS', label: 'detp.propBS' },
{ value: 'ECH', label: 'EchoLink' },
{ value: 'EME', label: 'detp.propEME' },
{ value: 'ES', label: 'detp.propES' },
{ value: 'FAI', label: 'detp.propFAI' },
{ value: 'F2', label: 'detp.propF2' },
{ value: 'GWAVE', label: 'detp.propGWAVE' },
{ value: 'INTERNET', label: 'detp.propINTERNET' },
{ value: 'ION', label: 'detp.propION' },
{ value: 'IRL', label: 'IRLP' },
{ value: 'LOS', label: 'detp.propLOS' },
{ value: 'MS', label: 'detp.propMS' },
{ value: 'RPT', label: 'detp.propRPT' },
{ value: 'RS', label: 'detp.propRS' },
{ value: 'SAT', label: 'detp.propSAT' },
{ value: 'TEP', label: 'detp.propTEP' },
{ value: 'TR', label: 'detp.propTR' },
];
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
const ANT_PATHS: { value: string; label: string }[] = [
{ value: 'NONE', label: '—' },
{ value: 'S', label: 'detp.pathShort' },
{ value: 'L', label: 'detp.pathLong' },
{ value: 'G', label: 'detp.pathGrayline' },
{ value: 'O', label: 'detp.pathOther' },
];
function numOrUndef(v: string): number | undefined {
if (v === '') return undefined;
const n = parseFloat(v);
return isNaN(n) ? undefined : n;
}
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
// for literal class names, so an interpolated one is never emitted.
const SPAN_CLASS: Record<number, string> = {
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
};
// Compact field helper to keep the JSX dense.
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
return (
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
<Label className="mb-1">{label}</Label>
{children}
</div>
);
}
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
// Live award detection: run the SAME engine used at log time over the current
// contact — callsign, the looked-up address/state/zones AND the entry-strip
// text (QTH, name, country, comment, note, grid). Sending only the address was
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
// while the award editor's Test tab, which passes the whole QSO, matched it.
// References the QSO will earn — WAPC matching "Beijing" inside the address,
// a DOK matching the town — are surfaced and auto-added the moment you enter a
// call or click a spot, instead of only appearing after logging. Pickable
// matches are merged into award_refs
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
// NEW badge on the county: the same question the cluster's NEW CTY badge
// answers, for the station being worked right now. Debounced because the
// county field is typed into as well as filled by the lookup.
const [newCounty, setNewCounty] = useState(false);
useEffect(() => {
const cnty = (details.cnty ?? '').trim();
if (!cnty) { setNewCounty(false); return; }
let alive = true;
const id = window.setTimeout(async () => {
try {
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
if (alive) setNewCounty(!!isNew);
} catch { if (alive) setNewCounty(false); }
}, 300);
return () => { alive = false; window.clearTimeout(id); };
}, [details.state, details.cnty]);
useEffect(() => {
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
const t = window.setTimeout(async () => {
try {
const q: any = {
callsign, band, mode,
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
qth: qth ?? '', name: name ?? '', country: country ?? '',
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
qso_date: new Date().toISOString(),
};
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
setDetected(all as any);
const cur = details.award_refs ?? '';
const have = new Set(cur.split(';').filter(Boolean));
let next = cur;
for (const r of all) {
// An ambiguous candidate is offered, never auto-added: adding one would
// be the guess the award's "one reference per QSO" setting exists to refuse.
if (!r.pickable || r.ambiguous) continue;
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
}
if (next !== cur) onChange({ award_refs: next });
} catch { /* leave detection empty on failure */ }
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
const path = useMemo(() => {
const byGrid = pathBetween(operatorGrid, remoteGrid);
if (byGrid) return byGrid;
// Fall back to lat/lon when the DX has coordinates but no grid (e.g. a
// cty.dat-only entity like Svalbard: no QRZ grid, but cty.dat coordinates).
const myLL = gridToLatLon(operatorGrid);
if (myLL && details.lat != null && details.lon != null) {
return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
}
return null;
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
// have one — it is what the operator can see and check — and the provider's
// raw coordinates cover entities that resolve through cty.dat with no grid.
const dxLL = useMemo(() => {
const byGrid = gridToLatLon(remoteGrid);
if (byGrid) return byGrid;
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
return null;
}, [remoteGrid, details.lat, details.lon]);
const fmtDeg = (n: number) => `${Math.round(n)}°`;
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
function toggle(t: TabName) { onTab ? onTab(t) : setInternalOpen(t); }
const fk = keyerActive ? 'Ctrl+F' : 'F';
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
function setSatellite(on: boolean) {
if (on) {
if (details.prop_mode !== 'SAT') onChange({ prop_mode: 'SAT' });
} else {
onChange({
sat_name: '', sat_mode: '',
...(details.prop_mode === 'SAT' ? { prop_mode: '' } : {}),
});
}
}
const tabs: { key: TabName; label: string }[] = [
{ key: 'stats', label: `${t('detp.tabStats')} (${fk}1)` },
{ key: 'info', label: `${t('detp.tabInfo')} (${fk}2)` },
{ key: 'awards', label: `${t('detp.tabAwards')} (${fk}3)` },
{ key: 'my', label: `${t('detp.tabMy')} (${fk}4)` },
{ key: 'extended', label: `${t('detp.tabExtended')} (${fk}5)` },
];
return (
<section className="bg-card shadow-sm border border-border rounded-lg flex flex-col flex-1 min-h-0 overflow-hidden">
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => toggle(t.key)}
className={cn(
'px-3 py-1.5 text-xs font-medium border-b-2 border-transparent -mb-px transition-colors',
open === t.key
? 'text-primary border-primary font-semibold'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t.label}
</button>
))}
</nav>
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid
wb={slotCall ? slotWb : wb}
busy={slotCall ? !!slotWbBusy : !!wbBusy}
currentBand={slotCall ? (slotBand ?? '') : band}
currentMode={slotCall ? (slotMode ?? '') : mode}
bands={bands}
hasCall={slotCall ? true : callsign.trim() !== ''}
forCall={slotCall}
onEditQso={onEditQso}
lat={dxLL?.lat} lon={dxLL?.lon} />
</div>
)}
{open === 'info' && (
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
{/* Twelve columns, not six: the fields hold very different things. A US
county name is long, a CQ zone is two digits — on an even six-column
grid they were the same width, so the county truncated while the
zones sat mostly empty. */}
<Field label={t('detp.statePref')} span={2}>
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
</Field>
<Field label={t('detp.county')} span={4} className="relative">
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
{newCounty && (
<span
title={t('detp.newCountyTip')}
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
>
{t('detp.newCounty')}
</span>
)}
</Field>
<Field label={t('detp.prefix')} span={2}>
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
</Field>
<Field label={t('detp.cqZone')}>
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
</Field>
<Field label={t('detp.ituZone')}>
<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 # 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={t('detp.dxcc')} span={2}>
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={details.dxcc ?? ''}
placeholder="—"
/>
</Field>
{/* The bearing and the two distances are read-only and hold at most
"12345 km", so they take a fixed width and the address — the one
field here that is never wide enough — gets everything left over. */}
<div className="col-span-12 flex gap-2">
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtDeg(path.bearingLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceShort) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.address')} className="flex-1 min-w-0">
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
</Field>
</div>
{/* QSL via gets the room, not the message. Width should follow use, and
these two are nowhere near equal: a manager's callsign is filled in
constantly and a QSL message almost never. The message had 7 columns
of 12 for text most operators never type. */}
<Field label={t('detp.qslVia')} span={7}>
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
</Field>
<Field label={t('detp.qslMessage')} span={5}>
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
</Field>
</div>
)}
{open === 'awards' && (
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
<AwardRefSelector
dxcc={details.dxcc}
value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })}
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
heightClass="flex-1 min-h-0"
/>
{/* Ambiguous candidates: the award allows one reference per QSO and
several matched, so none was kept. Click one to settle it while the
contact is still in front of you. */}
{detected.some((r) => r.ambiguous) && (
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
{detected.filter((r) => r.ambiguous).map((r) => (
<button
key={`amb-${r.code}@${r.ref}`}
type="button"
title={r.name ?? ''}
onClick={() => {
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
const cur = details.award_refs ?? '';
// Only one of the candidates can be right — replace any
// sibling already picked for this award rather than stacking.
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
onChange({ award_refs: [...kept, entry].join(';') });
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
}}
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
>
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
</button>
))}
</div>
)}
{detected.some((r) => !r.ambiguous) && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
{detected.filter((r) => !r.ambiguous).map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
</span>
))}
</div>
)}
</div>
)}
{open === 'my' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label={t('detp.azimuth')}>
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
</Field>
<Field label={t('detp.elevation')}>
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
</Field>
<Field label={t('detp.txPower')}>
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
</Field>
<div className="col-span-3 flex items-end pb-1.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
{t('detp.satelliteMode')}
</label>
</div>
<Field label={t('detp.antPath')} span={2}>
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label={t('detp.propagation')} span={4}>
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label={t('detp.rig')} span={3}>
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
</Field>
<Field label={t('detp.antenna')} span={3}>
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
</Field>
{satelliteMode && (
<>
<Field label={t('detp.satName')} span={3}>
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
</Field>
<Field label={t('detp.satelliteMode')} span={3}>
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
</Field>
</>
)}
</div>
)}
{open === 'extended' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label={t('detp.contestId')} span={2}>
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
</Field>
<Field label="SRX">
<Input value={details.srx_string ?? ''} placeholder={t('detp.rcvdExchangePh')} onChange={(e) => onChange({ srx_string: e.target.value })} />
</Field>
<Field label="STX">
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
</Field>
<Field label={t('detp.contactedEmail')} span={3}>
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
</Field>
</div>
)}
</div>
</section>
);
}