One row per digital mode would be the honest layout, and there is no height for it: the matrix sits in a fixed panel beside a dozen widgets. So the row keeps its place and changes what it answers — DIG, then each digital mode the operator's own list holds, in the order they put them in, then back to DIG. It costs no round trip. The query behind the matrix already grouped by band AND mode; only the collapse to a class threw that away, so the same cell is now published under the raw mode name too. Digital only: PH and CW have nothing to cycle through. On a specific mode the you-are-here mark follows THAT mode, or every FT4 entry would light whichever digital row the rotation happened to rest on. A four-letter mode drops to 9px rather than widen a column sized for three characters and push the whole matrix sideways. Opens 0.27.8.
542 lines
25 KiB
TypeScript
542 lines
25 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 { Combobox } from '@/components/ui/combobox';
|
|
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;
|
|
web?: 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
|
|
modes?: string[]; // configured modes, in order — the matrix cycles its digital row through them
|
|
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
|
|
// read here: the list lives in Preferences, and App already reloads it when
|
|
// Preferences close — a panel reading it once at mount would need a restart.
|
|
satellites?: string[];
|
|
// 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, modes, satellites = [], 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}
|
|
modes={modes}
|
|
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}>
|
|
{/* The station's own satellites, in alphabetical order, with the
|
|
box still open to anything typed: SAT_NAME is compared
|
|
character for character by the awards and by LoTW, so a
|
|
remembered spelling beats a fresh one every pass — but a bird
|
|
worked once and never added to the list must not be
|
|
impossible to log. */}
|
|
{/* showToggle: without the chevron this control is a text box that
|
|
happens to open on a keystroke, which nobody discovers — and
|
|
is exactly what "there is still no dropdown" meant.
|
|
allowFreeText: the list is the station's own, so a bird
|
|
worked once and never added to it must still be loggable. */}
|
|
<Combobox
|
|
value={details.sat_name}
|
|
options={satellites}
|
|
placeholder={t('detp.satName')}
|
|
showToggle
|
|
allowFreeText
|
|
onChange={(v) => onChange({ sat_name: v })}
|
|
/>
|
|
</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>
|
|
{/* No placeholder: a greyed-out sample address in an empty field reads
|
|
as a value the lookup found, and that is exactly the field an
|
|
operator checks to see whether it found one. */}
|
|
<Field label={t('detp.contactedEmail')} span={2}>
|
|
<Input value={details.email} onChange={(e) => onChange({ email: e.target.value })} />
|
|
</Field>
|
|
<Field label={t('detp.contactedWeb')}>
|
|
<Input value={details.web ?? ''} onChange={(e) => onChange({ web: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|