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) => 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 (
{children}
); } export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) { const [internalOpen, setInternalOpen] = useState('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>([]); 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 (
{open === 'stats' && (
)} {open === 'info' && (
onChange({ state: e.target.value })} /> onChange({ cnty: e.target.value })} /> { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} /> { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} /> {/* 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. */} onChange({ address: e.target.value })} /> onChange({ qsl_msg: e.target.value })} /> onChange({ qsl_via: e.target.value })} />
)} {open === 'awards' && (
onChange({ award_refs: v })} fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }} heightClass="flex-1 min-h-0" /> {detected.length > 0 && (
Detected — this contact will count for:{' '} {detected.map((r) => ( {r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? {r.name} : null} ))}
)}
)} {open === 'my' && (
onChange({ ant_az: numOrUndef(e.target.value) })} /> onChange({ ant_el: numOrUndef(e.target.value) })} /> onChange({ tx_pwr: numOrUndef(e.target.value) })} />
onChange({ my_rig: e.target.value })} /> onChange({ my_antenna: e.target.value })} /> {satelliteMode && ( <> onChange({ sat_name: e.target.value })} /> onChange({ sat_mode: e.target.value })} /> )}
)} {open === 'extended' && (
onChange({ contest_id: e.target.value })} /> onChange({ srx_string: e.target.value })} /> onChange({ stx_string: e.target.value })} /> onChange({ email: e.target.value })} />
)}
); }