Audit of every field a lookup returns against what OpsLog can store, prompted by a report that HamQTH was not fetching the email. The email was never broken: pinned now against a captured HamQTH answer, it parses and reaches the QSO. The reported callsign simply has no public address on HamQTH, which the empty field could not distinguish from a fault — so the "[email protected]" placeholder is gone. A greyed-out sample address in the one field an operator checks to see whether the lookup found one reads as a value. Real gaps found and closed: - web: the qso table has had the column since migration 0003 and no provider mapping ever read it. HamQTH sends <web>. - picture: Result.ImageURL was documented "QRZ only" because HamQTH's element is <picture>, not <image>. It was there all along. - zip: sent by both providers, read by neither. - adr_name: used only as a fallback when a record carries neither nick nor name. Deliberately NOT preferred over <nick> — a log wants the name the operator goes by on the air, "Igor", not "Igor Vladimirovich Getman". The parse tests use a real captured payload, including the stray <div> advert the server injects into its own XML.
517 lines
24 KiB
TypeScript
517 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;
|
|
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
|
|
// 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>
|
|
{/* 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>
|
|
);
|
|
}
|