391 lines
17 KiB
TypeScript
391 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { ComputeQSOAwardRefs } 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 { 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
|
|
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
|
|
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;
|
|
}
|
|
|
|
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: 'Aircraft Scatter' },
|
|
{ value: 'AUR', label: 'Aurora' },
|
|
{ value: 'AUE', label: 'Aurora-E' },
|
|
{ value: 'BS', label: 'Back Scatter' },
|
|
{ value: 'ECH', label: 'EchoLink' },
|
|
{ value: 'EME', label: 'Earth-Moon-Earth' },
|
|
{ value: 'ES', label: 'Sporadic E' },
|
|
{ value: 'FAI', label: 'Field Aligned Irregularities' },
|
|
{ value: 'F2', label: 'F2 Reflection' },
|
|
{ value: 'GWAVE', label: 'Ground Wave' },
|
|
{ value: 'INTERNET', label: 'Internet-assisted' },
|
|
{ value: 'ION', label: 'Ionoscatter' },
|
|
{ value: 'IRL', label: 'IRLP' },
|
|
{ value: 'LOS', label: 'Line of Sight' },
|
|
{ value: 'MS', label: 'Meteor Scatter' },
|
|
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
|
|
{ value: 'RS', label: 'Rain Scatter' },
|
|
{ value: 'SAT', label: 'Satellite' },
|
|
{ value: 'TEP', label: 'Trans-Equatorial' },
|
|
{ value: 'TR', label: 'Tropospheric Ducting' },
|
|
];
|
|
|
|
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
|
const ANT_PATHS: { value: string; label: string }[] = [
|
|
{ value: 'NONE', label: '—' },
|
|
{ value: 'S', label: 'Short Path' },
|
|
{ value: 'L', label: 'Long Path' },
|
|
{ value: 'G', label: 'Grayline' },
|
|
{ value: 'O', label: 'Other' },
|
|
];
|
|
|
|
function numOrUndef(v: string): number | undefined {
|
|
if (v === '') return undefined;
|
|
const n = parseFloat(v);
|
|
return isNaN(n) ? undefined : n;
|
|
}
|
|
|
|
// Compact field helper to keep the JSX dense.
|
|
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
|
return (
|
|
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
|
<Label className="mb-1">{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
|
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 + looked-up address/state/zones) so award references the
|
|
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — 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 }>>([]);
|
|
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 ?? '',
|
|
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) {
|
|
if (!r.pickable) 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, 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]);
|
|
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: `Stats (${fk}1)` },
|
|
{ key: 'info', label: `Info (${fk}2)` },
|
|
{ key: 'awards', label: `Awards (${fk}3)` },
|
|
{ key: 'my', label: `My (${fk}4)` },
|
|
{ key: 'extended', label: `Extended (${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={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
|
</div>
|
|
)}
|
|
|
|
{open === 'info' && (
|
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
|
<Field label="State / pref">
|
|
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
|
</Field>
|
|
<Field label="County">
|
|
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
|
</Field>
|
|
<Field label="Prefix">
|
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
|
</Field>
|
|
<Field label="CQ zone">
|
|
<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="ITU zone">
|
|
<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="DXCC #">
|
|
<Input
|
|
readOnly
|
|
tabIndex={-1}
|
|
className="font-mono bg-muted/40 cursor-default"
|
|
value={details.dxcc ?? ''}
|
|
placeholder="—"
|
|
/>
|
|
</Field>
|
|
<Field label="Azimuth LP">
|
|
<Input
|
|
readOnly
|
|
tabIndex={-1}
|
|
className="font-mono bg-muted/40 cursor-default"
|
|
value={path ? fmtDeg(path.bearingLong) : ''}
|
|
placeholder="—"
|
|
/>
|
|
</Field>
|
|
<Field label="Distance SP">
|
|
<Input
|
|
readOnly
|
|
tabIndex={-1}
|
|
className="font-mono bg-muted/40 cursor-default"
|
|
value={path ? fmtKm(path.distanceShort) : ''}
|
|
placeholder="—"
|
|
/>
|
|
</Field>
|
|
<Field label="Distance LP">
|
|
<Input
|
|
readOnly
|
|
tabIndex={-1}
|
|
className="font-mono bg-muted/40 cursor-default"
|
|
value={path ? fmtKm(path.distanceLong) : ''}
|
|
placeholder="—"
|
|
/>
|
|
</Field>
|
|
<Field label="Address" span={3}>
|
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
|
</Field>
|
|
<Field label="QSL message" span={3}>
|
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
|
</Field>
|
|
<Field label="QSL via" span={2}>
|
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: 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"
|
|
/>
|
|
{detected.length > 0 && (
|
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
|
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
|
{detected.map((r) => (
|
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
|
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{open === 'my' && (
|
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
|
<Field label="Azimuth (°)">
|
|
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
|
</Field>
|
|
<Field label="Elevation (°)">
|
|
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
|
</Field>
|
|
<Field label="TX power (W)">
|
|
<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)} />
|
|
Satellite mode
|
|
</label>
|
|
</div>
|
|
<Field label="Ant. path" 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}>{p.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
<Field label="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}>{p.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
<Field label="Rig" span={3}>
|
|
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
|
</Field>
|
|
<Field label="Antenna" span={3}>
|
|
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
|
</Field>
|
|
{satelliteMode && (
|
|
<>
|
|
<Field label="Satellite name" span={3}>
|
|
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
|
</Field>
|
|
<Field label="Satellite mode" span={3}>
|
|
<Input value={details.sat_mode} placeholder="U/V" 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="Contest ID" 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="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
|
|
</Field>
|
|
<Field label="STX">
|
|
<Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
|
|
</Field>
|
|
<Field label="Contacted email" span={3}>
|
|
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|