680 lines
42 KiB
TypeScript
680 lines
42 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Trash2, Search, Loader2 } from 'lucide-react';
|
|
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
} from '@/components/ui/select';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
import { cn } from '@/lib/utils';
|
|
import { flagURL } from '@/lib/flags';
|
|
import type { QSOForm } from '@/types';
|
|
|
|
type QSO = QSOForm;
|
|
|
|
// Quick prefix from a callsign (drops portable suffixes, keeps a slashed
|
|
// prefix). Read-only display, mirrors Log4OM's PFX box.
|
|
function pfxOf(call: string): string {
|
|
const c = (call || '').trim().toUpperCase();
|
|
if (!c) return '';
|
|
const base = c.includes('/') ? c.split('/')[0] : c;
|
|
let lastDigit = -1;
|
|
for (let i = 0; i < base.length; i++) if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
|
|
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
|
}
|
|
|
|
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
|
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
|
const QSL_STATUSES = [
|
|
{ value: '_', label: '—' },
|
|
{ value: 'Y', label: 'Yes' },
|
|
{ value: 'N', label: 'No' },
|
|
{ value: 'R', label: 'Requested' },
|
|
{ value: 'I', label: 'Ignore' },
|
|
];
|
|
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
|
|
|
// Confirmation channels — each maps to its QSO sent/received status, dates and
|
|
// (paper-only) via fields. Drives the "Manage Confirmation" editor and the
|
|
// live status grid (Log4OM style).
|
|
type ConfDef = {
|
|
key: string; label: string;
|
|
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
|
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
|
via?: keyof QSOForm;
|
|
};
|
|
const CONFIRMATIONS: ConfDef[] = [
|
|
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
|
|
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
|
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
|
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
|
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
|
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
|
];
|
|
|
|
// Colour-coded status cell for the confirmation grid.
|
|
function StatusCell({ value }: { value?: string }) {
|
|
const v = (value || '').toUpperCase();
|
|
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
|
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
|
if (v === '') {
|
|
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
|
}
|
|
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
|
|
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
|
: v === 'R' ? 'bg-orange-400 text-white'
|
|
: v === 'I' ? 'bg-stone-400 text-white'
|
|
: 'bg-amber-400 text-amber-950';
|
|
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
|
}
|
|
|
|
interface Props {
|
|
qso: QSO;
|
|
onSave: (q: QSO) => void;
|
|
onDelete: (id: number) => void;
|
|
onClose: () => void;
|
|
countries?: string[];
|
|
}
|
|
|
|
function toLocalISO(d: any): string {
|
|
if (!d) return '';
|
|
const date = new Date(d);
|
|
if (isNaN(date.getTime())) return '';
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
|
}
|
|
function parseLocalISO(s: string): string | null {
|
|
if (!s) return null;
|
|
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
|
if (!m) return null;
|
|
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
|
|
}
|
|
function numOrUndef(v: any): number | undefined {
|
|
if (v === '' || v === null || v === undefined) return undefined;
|
|
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
|
return isNaN(n) ? undefined : n;
|
|
}
|
|
function intOrUndef(v: any): number | undefined {
|
|
const n = numOrUndef(v);
|
|
return n === undefined ? undefined : Math.trunc(n);
|
|
}
|
|
|
|
function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
|
|
return (
|
|
<div className={cn('flex flex-col gap-1 min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
|
|
<Label>{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: Props) {
|
|
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
|
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
|
const splitHz = (hz?: number) => hz
|
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
|
: { khz: '', hz: '' };
|
|
const f0 = splitHz(draft.freq_hz);
|
|
const fr0 = splitHz(draft.freq_rx_hz);
|
|
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
|
const [freqHz, setFreqHz] = useState(f0.hz);
|
|
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
|
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
|
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
|
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
|
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
|
|
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
|
const [localErr, setLocalErr] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [looking, setLooking] = useState(false);
|
|
|
|
// === Award references (Log4OM-style tab) ===
|
|
// Manual refs are edited as a "CODE@REF;…" string; computed refs (DXCC, WAZ,
|
|
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
|
const awardFieldRef = useRef<Record<string, string>>({});
|
|
const [awardRefs, setAwardRefs] = useState('');
|
|
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
|
|
|
// Load award definitions once, then seed the editable manual refs from the QSO.
|
|
useEffect(() => {
|
|
GetAwardDefs()
|
|
.then(async (defs) => {
|
|
const list = (defs ?? []) as any[];
|
|
const fieldOf: Record<string, string> = {};
|
|
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
|
|
awardFieldRef.current = fieldOf;
|
|
// Seed the editable manual refs from the backend, which already matched
|
|
// each reference against its award's own list. Seeding from the raw QSO
|
|
// field instead would wrongly seed every state-award (WAS/RAC/WAJA) from
|
|
// the same `state` value — e.g. a US "CA" would seed RAC@CA too.
|
|
try {
|
|
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
|
const seed = all
|
|
.filter((r: any) => r.pickable)
|
|
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
|
.join(';');
|
|
setAwardRefs(seed);
|
|
} catch { /* leave manual refs empty on failure */ }
|
|
})
|
|
.catch(() => {});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Recompute the read-only computed refs whenever a source field changes.
|
|
useEffect(() => {
|
|
const t = window.setTimeout(async () => {
|
|
try {
|
|
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
|
setComputedRefs(all.filter((r: any) => !r.pickable).map((r: any) => ({ code: r.code, ref: r.ref, name: r.name })));
|
|
} catch { setComputedRefs([]); }
|
|
}, 250);
|
|
return () => window.clearTimeout(t);
|
|
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
|
|
|
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
|
setDraft((d) => ({ ...d, [key]: value }));
|
|
}
|
|
|
|
// Country drives the DXCC entity number (ADIF). The DXCC field is read-only;
|
|
// picking a Country resolves and stamps its DXCC# so they can't diverge.
|
|
async function onCountryChange(v: string) {
|
|
set('country', v);
|
|
try {
|
|
const n = await DXCCForCountry(v);
|
|
set('dxcc', (n && n > 0 ? n : undefined) as any);
|
|
} catch { /* leave DXCC as-is if resolution fails */ }
|
|
}
|
|
|
|
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
|
|
// the draft — handy after correcting the callsign. Only overwrites the
|
|
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
|
async function fetchLookup() {
|
|
const call = (draft.callsign ?? '').trim().toUpperCase();
|
|
if (!call) { setLocalErr('Callsign required'); return; }
|
|
setLooking(true);
|
|
setLocalErr('');
|
|
try {
|
|
const r: any = await LookupCallsign(call);
|
|
setDraft((d) => ({
|
|
...d,
|
|
name: r.name ?? d.name,
|
|
qth: r.qth ?? d.qth,
|
|
address: r.address ?? (d as any).address,
|
|
email: r.email ?? (d as any).email,
|
|
country: r.country ?? d.country,
|
|
grid: r.grid ?? d.grid,
|
|
state: r.state ?? d.state,
|
|
cnty: r.cnty ?? d.cnty,
|
|
cont: r.cont ?? d.cont,
|
|
qsl_via: r.qsl_via ?? d.qsl_via,
|
|
dxcc: r.dxcc || d.dxcc,
|
|
cqz: r.cqz || d.cqz,
|
|
ituz: r.ituz || d.ituz,
|
|
lat: r.lat || d.lat,
|
|
lon: r.lon || d.lon,
|
|
}));
|
|
} catch (e: any) {
|
|
setLocalErr('Lookup: ' + String(e?.message ?? e));
|
|
} finally {
|
|
setLooking(false);
|
|
}
|
|
}
|
|
|
|
function save() {
|
|
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
|
setSaving(true);
|
|
setLocalErr('');
|
|
const out: any = {
|
|
...draft,
|
|
callsign: draft.callsign.trim().toUpperCase(),
|
|
grid: (draft.grid ?? '').trim().toUpperCase(),
|
|
gridsquare_ext: (draft.gridsquare_ext ?? '').trim().toUpperCase(),
|
|
station_callsign: (draft.station_callsign ?? '').trim().toUpperCase(),
|
|
operator: (draft.operator ?? '').trim().toUpperCase(),
|
|
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
|
|
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
|
|
// iota / sota_ref / pota_ref are set below from the Award Refs tab.
|
|
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
|
|
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
|
|
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
|
|
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
|
|
qso_date_off: endEnabled ? (parseLocalISO(dateOff) ?? undefined) : undefined,
|
|
freq_hz: freqKHz.trim() ? parseInt(freqKHz, 10) * 1000 + (parseInt(freqHz, 10) || 0) : undefined,
|
|
freq_rx_hz: freqRxKHz.trim() ? parseInt(freqRxKHz, 10) * 1000 + (parseInt(freqRxHz, 10) || 0) : undefined,
|
|
dxcc: intOrUndef(draft.dxcc),
|
|
cqz: intOrUndef(draft.cqz),
|
|
ituz: intOrUndef(draft.ituz),
|
|
age: intOrUndef(draft.age),
|
|
srx: intOrUndef(draft.srx),
|
|
stx: intOrUndef(draft.stx),
|
|
my_dxcc: intOrUndef(draft.my_dxcc),
|
|
my_cq_zone: intOrUndef(draft.my_cq_zone),
|
|
my_itu_zone: intOrUndef(draft.my_itu_zone),
|
|
lat: numOrUndef(draft.lat), lon: numOrUndef(draft.lon),
|
|
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
|
|
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
|
|
tx_pwr: numOrUndef(draft.tx_pwr),
|
|
distance: numOrUndef(draft.distance),
|
|
rx_pwr: numOrUndef(draft.rx_pwr),
|
|
a_index: numOrUndef(draft.a_index),
|
|
k_index: numOrUndef(draft.k_index),
|
|
sfi: numOrUndef(draft.sfi),
|
|
extras: draft.extras && Object.keys(draft.extras).length ? draft.extras : undefined,
|
|
};
|
|
// The Award Refs tab is authoritative for the reference-list awards. Reset
|
|
// the dedicated columns, then route the picked refs back onto the payload
|
|
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
|
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
|
applyAwardRefs(out, awardRefs, awardFieldRef.current);
|
|
onSave(out);
|
|
}
|
|
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
|
|
}
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
});
|
|
|
|
const extrasCount = useMemo(
|
|
() => (draft.extras ? Object.keys(draft.extras).length : 0),
|
|
[draft.extras],
|
|
);
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
|
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
|
<DialogHeader className="flex-row items-baseline gap-2">
|
|
<DialogTitle>Edit QSO</DialogTitle>
|
|
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
|
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
|
<TabsList className="px-3 overflow-x-auto">
|
|
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
|
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
|
<TabsTrigger value="awards">Award Refs</TabsTrigger>
|
|
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
|
<TabsTrigger value="contest">Contest</TabsTrigger>
|
|
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
|
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
|
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
|
|
<TabsTrigger value="extras">
|
|
ADIF fields
|
|
{extrasCount > 0 && (
|
|
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{localErr && (
|
|
<div className="mx-5 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2">
|
|
{localErr}
|
|
</div>
|
|
)}
|
|
|
|
<div className="overflow-y-auto px-5 py-4 flex-1">
|
|
<TabsContent value="qsoinfo" className="mt-0">
|
|
{/* Top: Callsign + RST + Fetch */}
|
|
<div className="flex items-end gap-2 mb-3">
|
|
<div className="flex flex-col flex-1 min-w-0">
|
|
<Label>Callsign</Label>
|
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
|
</div>
|
|
<div className="flex flex-col w-20"><Label>S</Label>
|
|
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
|
<div className="flex flex-col w-20"><Label>R</Label>
|
|
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
|
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
|
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
|
{/* ── Left column ── */}
|
|
<div className="flex flex-col gap-2.5">
|
|
<div><Label>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="w-20 shrink-0">Band</Label>
|
|
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
|
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="w-20 shrink-0">RX Band</Label>
|
|
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
|
<SelectContent><SelectItem value="_">—</SelectItem>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="w-20 shrink-0">Mode</Label>
|
|
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
|
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="w-20 shrink-0">Country</Label>
|
|
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
|
onChange={onCountryChange} className="flex-1" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="w-20 shrink-0">ITU</Label>
|
|
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
|
<Label>CQ</Label>
|
|
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
|
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title="DXCC entity # — set automatically from Country" />
|
|
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="w-20 shrink-0">Freq</Label>
|
|
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
|
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="w-20 shrink-0">RX Freq</Label>
|
|
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
|
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Right column ── */}
|
|
<div className="flex flex-col gap-2.5">
|
|
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
|
<div>
|
|
<Label className="flex items-center gap-2">
|
|
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
|
|
</Label>
|
|
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex flex-col flex-1"><Label>Grid</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
|
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
|
</div>
|
|
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
|
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="contact" className="mt-0">
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
|
{/* Left column */}
|
|
<div className="flex flex-col gap-2.5">
|
|
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
|
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
|
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
|
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
|
</div>
|
|
{/* Right column */}
|
|
<div className="flex flex-col gap-2.5">
|
|
<div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
|
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
|
</div>
|
|
<div><Label>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
|
<div><Label>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="awards" className="mt-0">
|
|
<div className="grid grid-cols-[1fr_240px] gap-5">
|
|
{/* Left: pick reference-list awards (POTA/SOTA/IOTA/WWFF/…) */}
|
|
<div>
|
|
<AwardRefSelector dxcc={draft.dxcc} value={awardRefs} onChange={setAwardRefs} />
|
|
</div>
|
|
|
|
{/* Right: computed awards (read-only) derived from this QSO */}
|
|
<div className="flex flex-col gap-1.5 min-w-0">
|
|
<span className="text-xs font-semibold">Computed (automatic)</span>
|
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
|
Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.
|
|
</p>
|
|
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
|
{computedRefs.length === 0 ? (
|
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
|
|
) : (
|
|
computedRefs.map((r) => (
|
|
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
|
<span className="font-mono font-semibold">{r.code}@{r.ref}</span>
|
|
{r.name && <span className="text-[10px] text-muted-foreground ml-1.5 truncate">{r.name}</span>}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="qsl" className="mt-0">
|
|
{(() => {
|
|
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
|
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
|
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
|
return (
|
|
<div className="flex gap-6">
|
|
{/* Left: edit one confirmation channel at a time */}
|
|
<div className="flex-1 max-w-sm space-y-3">
|
|
<div>
|
|
<Label>Manage Confirmation</Label>
|
|
<Select value={confSel} onValueChange={setConfSel}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
|
<div><Label>Received</Label>
|
|
{def.rcvd
|
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
|
: <Input disabled value="—" />}
|
|
</div>
|
|
<div><Label>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
|
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
|
{def.via && (
|
|
<div className="col-span-2"><Label>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></div>
|
|
)}
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Pick a channel, edit it — the table on the right updates live. Everything is written when you click <strong>Save changes</strong>.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Right: live status grid for every channel */}
|
|
<div className="w-72 shrink-0">
|
|
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
|
<thead>
|
|
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
<th className="text-left font-semibold">Type</th>
|
|
<th className="font-semibold">Sent</th>
|
|
<th className="font-semibold">Received</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{CONFIRMATIONS.map((c) => (
|
|
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
|
|
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
|
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="contest" className="mt-0">
|
|
<div className="grid grid-cols-6 gap-3">
|
|
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
|
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
|
|
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
|
|
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
|
|
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
|
|
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
|
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
|
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="sat" className="mt-0">
|
|
<div className="grid grid-cols-6 gap-3">
|
|
<F label="Propagation mode">
|
|
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</F>
|
|
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
|
<F label="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
|
<F label="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
|
<F label="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
|
<F label="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="mystation" className="mt-0 space-y-3">
|
|
<p className="text-xs text-muted-foreground">These override the active station profile for this QSO only.</p>
|
|
<div className="grid grid-cols-6 gap-3">
|
|
<F label="Station callsign" span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
|
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
|
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
|
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
|
<F label="Country" span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder="Country" onChange={(v) => set('my_country', v)} /></F>
|
|
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
|
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
|
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
|
<F label="CQ zone"><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
|
<F label="ITU zone"><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
|
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
|
|
<F label="SOTA ref"><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
|
<F label="POTA ref"><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
|
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
|
|
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
|
|
<F label="Street" span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
|
<F label="City" span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
|
<F label="Postal" span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
|
<F label="Rig" span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
|
<F label="Antenna" span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
|
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</p>
|
|
<div className="grid grid-cols-6 gap-3">
|
|
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
|
|
<F label="SIG info" span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
|
<F label="WWFF ref" span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
|
<F label="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Power & propagation */}
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Power & space weather</p>
|
|
<div className="grid grid-cols-6 gap-3">
|
|
<F label="RX power (W)"><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
|
<F label="Distance (km)"><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
|
<F label="A index"><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
|
<F label="K index"><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
|
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Identity & clubs */}
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Identity & clubs</p>
|
|
<div className="grid grid-cols-6 gap-3">
|
|
<F label="Contacted op" span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
|
<F label="Former call (EQ_CALL)" span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
|
<F label="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
|
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
|
|
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
|
|
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
|
|
<F label="DARC DOK"><Input value={draft.darc_dok ?? ''} onChange={(e) => set('darc_dok', e.target.value)} /></F>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Flags & credits */}
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags & credits</p>
|
|
<div className="grid grid-cols-6 gap-3">
|
|
<F label="QSO complete"><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
|
<F label="QSO random"><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
|
<F label="Silent key"><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
|
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
|
|
<F label="Credit granted" span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
|
<F label="Credit submitted" span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
|
</div>
|
|
</div>
|
|
|
|
{/* My station extras */}
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">My station (ADIF)</p>
|
|
<div className="grid grid-cols-6 gap-3">
|
|
<F label="My name" span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
|
<F label="My WWFF ref" span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
|
<F label="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
|
<F label="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
|
<F label="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
|
<F label="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
|
<F label="My VUCC grids" span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="extras" className="mt-0">
|
|
<AdifExtrasEditor value={draft.extras} onChange={(next) => set('extras', next as any)} />
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
|
|
<DialogFooter className="!flex-row gap-2">
|
|
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
|
|
<Trash2 className="size-3.5" /> Delete
|
|
</Button>
|
|
<div className="flex-1" />
|
|
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
|
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|