273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Construction } from 'lucide-react';
|
|
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 } from '@/lib/maidenhead';
|
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
|
|
|
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;
|
|
srx?: number;
|
|
stx?: number;
|
|
email: 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;
|
|
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';
|
|
|
|
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
|
|
|
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 | 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 === 6 && 'col-span-6')}>
|
|
<Label className="mb-1">{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) {
|
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
|
|
|
// Bearing/distance from operator's home grid to the remote station.
|
|
// Recomputed only when either grid actually changes.
|
|
const path = useMemo(
|
|
() => pathBetween(operatorGrid, remoteGrid),
|
|
[operatorGrid, remoteGrid],
|
|
);
|
|
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="overflow-y-auto min-h-0">
|
|
{open === 'stats' && (
|
|
<div className="px-3 py-2.5">
|
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} />
|
|
</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>
|
|
{/* DXCC #, CQ zone, ITU zone, Continent and Azimuth SP live in the
|
|
main entry strip — visible without opening F2. F2 keeps the
|
|
less-needed long-path bearing and both distances. */}
|
|
<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-4 py-6 text-center text-xs text-muted-foreground">
|
|
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
|
|
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</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="Ant. path">
|
|
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
|
|
</Field>
|
|
<Field label="Propagation">
|
|
<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={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</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="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="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 type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
|
|
</Field>
|
|
<Field label="STX">
|
|
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
|
|
</Field>
|
|
<Field label="Contacted email" span={3}>
|
|
<Input value={details.email} placeholder="op@example.com" onChange={(e) => onChange({ email: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|