Grid on the UDP path — WSJT-X and MSHV can only send a FOUR-character grid, that is all the FT8 protocol carries. The enrichment rule there is "fill only what is empty", so the coarse JN05 always won and QRZ's JN05JG was thrown away: roughly 100 km of accuracy discarded on every digital QSO. The lookup grid is now taken when it EXTENDS the received square. A lookup that disagrees outright (JN18 against JN05) is not a refinement — the station may be portable and what came over the air is then the better record. Both rules are pinned by tests. Manual fetch in the QSO editor — it read the CACHE, valid for 30 days. An operator who upgraded their QRZ subscription went on getting the thin free-account record and clearing the cached row by hand was the only way out. The button now forces a lookup that bypasses the cache, reaches the provider and REFRESHES the stored row, with a 15 s budget instead of 2 s: a deliberate click must reach the network, where giving up early would fall back to cty.dat and look like nothing happened. The automatic type-ahead lookup keeps the cache, which is where it earns its keep. Same fetch: it used `??`, which only guards against null. Go marshals an unset string as "", so a QRZ record with no grid BLANKED the grid already in the QSO. The lookup still wins — that is the point of asking for it — but an empty result no longer erases a good value.
820 lines
50 KiB
TypeScript
820 lines
50 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||
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 { useI18n } from '@/lib/i18n';
|
||
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'];
|
||
// label holds an i18n key (resolved with t() at render time).
|
||
const QSL_STATUSES = [
|
||
{ value: '_', label: 'qedit.qslDash' },
|
||
{ value: 'Y', label: 'qedit.qslYes' },
|
||
{ value: 'N', label: 'qedit.qslNo' },
|
||
{ value: 'R', label: 'qedit.qslRequested' },
|
||
{ value: 'I', label: 'qedit.qslIgnore' },
|
||
];
|
||
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 },
|
||
];
|
||
// i18n label keys for confirmation channels whose label has translatable words
|
||
// (brand names like LoTW/eQSL stay as their literal label in CONFIRMATIONS).
|
||
const CONF_LABEL_KEYS: Record<string, string> = {
|
||
QSL: 'qedit.confQslPaper',
|
||
};
|
||
|
||
// Colour-coded status cell for the confirmation grid.
|
||
function StatusCell({ value }: { value?: string }) {
|
||
const { t } = useI18n();
|
||
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>;
|
||
}
|
||
// One colour per state, and each colour means something:
|
||
// Yes green — confirmed, the thing you wanted
|
||
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||
// This is the ONLY state asking for action, so it gets the
|
||
// only alarming colour.
|
||
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
|
||
// every row; painting that orange (as it used to be, in the
|
||
// same orange as Requested) made the table shout about a
|
||
// non-problem and told you nothing apart.
|
||
// Ignore dashed — deliberately excluded, on purpose.
|
||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
|
||
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||
: 'bg-muted text-muted-foreground border border-border';
|
||
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[];
|
||
bands?: string[];
|
||
modes?: string[];
|
||
}
|
||
|
||
function toLocalISO(d: any): string {
|
||
if (!d) return '';
|
||
const date = new Date(d);
|
||
if (isNaN(date.getTime())) return '';
|
||
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
|
||
// doesn't apply to a time struct), so a QSO with no end time arrives as a
|
||
// year-1 date. Treat anything that old as unset — otherwise the datetime
|
||
// field shows a garbage value and fights the user's typing.
|
||
if (date.getUTCFullYear() <= 1) 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>
|
||
);
|
||
}
|
||
|
||
// AdifDateInput — a real date picker over an ADIF date.
|
||
//
|
||
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
|
||
// are converted at the edge and the log keeps its ADIF form. The native control
|
||
// is used on purpose: it brings the OS calendar, the locale's day/month order
|
||
// and keyboard entry for free, which a hand-rolled popover would have to
|
||
// reimplement and get wrong.
|
||
//
|
||
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
|
||
// a plain text box instead, so it stays visible and correctable rather than
|
||
// silently disappearing behind an empty picker.
|
||
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
|
||
const raw = (value ?? '').trim();
|
||
const valid = /^\d{8}$/.test(raw);
|
||
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
|
||
const today = () => {
|
||
const d = new Date();
|
||
const p = (n: number) => String(n).padStart(2, '0');
|
||
// UTC: every date in the log is UTC, and near midnight the local day is the
|
||
// wrong one.
|
||
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
|
||
};
|
||
if (raw !== '' && !valid) {
|
||
return (
|
||
<div className="flex gap-1">
|
||
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
|
||
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="flex gap-1">
|
||
<Input
|
||
type="date"
|
||
value={iso}
|
||
disabled={disabled}
|
||
onChange={(e) => {
|
||
const v = e.target.value; // "" when cleared
|
||
onChange(v ? v.replace(/-/g, '') : '');
|
||
}}
|
||
className="font-mono"
|
||
/>
|
||
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
|
||
onClick={today} title="Today (UTC)">
|
||
<CalendarDays className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||
const { t } = useI18n();
|
||
return (
|
||
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
}
|
||
|
||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||
const { t } = useI18n();
|
||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
||
// imported/legacy value is never silently dropped from the dropdown.
|
||
const bandList = useMemo(() => {
|
||
const base = (bands && bands.length ? bands : BANDS).slice();
|
||
if (qso.band && !base.includes(qso.band)) base.unshift(qso.band);
|
||
return base;
|
||
}, [bands, qso.band]);
|
||
const modeList = useMemo(() => {
|
||
const base = (modes && modes.length ? modes : MODES).slice();
|
||
if (qso.mode && !base.includes(qso.mode)) base.unshift(qso.mode);
|
||
return base;
|
||
}, [modes, qso.mode]);
|
||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
|
||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||
useEffect(() => {
|
||
GetListsSettings()
|
||
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
|
||
.catch(() => {});
|
||
}, []);
|
||
// 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 dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
|
||
const [dateOff, setDateOff] = useState(dateOffISO);
|
||
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
|
||
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('');
|
||
// The refs present when the editor opened, so on save we can tell which ones
|
||
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
|
||
const seedAwardRefsRef = useRef('');
|
||
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(';');
|
||
seedAwardRefsRef.current = seed;
|
||
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]);
|
||
|
||
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
|
||
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
|
||
// accepts letters (sections/zones), not just numbers.
|
||
function setExchange(which: 'srx' | 'stx', raw: string) {
|
||
const t = raw.trim();
|
||
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
|
||
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
|
||
}
|
||
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(t('qedit.callsignRequired')); return; }
|
||
setLooking(true);
|
||
setLocalErr('');
|
||
try {
|
||
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
|
||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||
// stale row) otherwise stayed for its whole 30-day life and the button
|
||
// appeared to do nothing.
|
||
const r: any = await LookupCallsignFresh(call);
|
||
// The lookup WINS over what is in the record — that is the point of asking
|
||
// for it. But an EMPTY result must never blank a good value: `??` only
|
||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||
// record with no grid used to wipe the grid that was already there.
|
||
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
|
||
setDraft((d) => ({
|
||
...d,
|
||
name: keep(r.name, d.name),
|
||
qth: keep(r.qth, d.qth),
|
||
address: keep(r.address, (d as any).address),
|
||
email: keep(r.email, (d as any).email),
|
||
country: keep(r.country, d.country),
|
||
grid: keep(r.grid, d.grid),
|
||
state: keep(r.state, d.state),
|
||
cnty: keep(r.cnty, d.cnty),
|
||
cont: keep(r.cont, d.cont),
|
||
qsl_via: keep(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(t('qedit.lookupError', { msg: String(e?.message ?? e) }));
|
||
} finally {
|
||
setLooking(false);
|
||
}
|
||
}
|
||
|
||
function save() {
|
||
if (!draft.callsign?.trim()) { setLocalErr(t('qedit.callsignRequired')); 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, seedAwardRefsRef.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-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||
<DialogHeader className="flex-row items-baseline gap-2">
|
||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||
<DialogDescription className="sr-only">{t('qedit.editFieldsFor', { id: 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">{t('qedit.tabQsoInfo')}</TabsTrigger>
|
||
<TabsTrigger value="contact">{t('qedit.tabContact')}</TabsTrigger>
|
||
<TabsTrigger value="awards">{t('qedit.tabAwards')}</TabsTrigger>
|
||
<TabsTrigger value="qsl">{t('qedit.tabQsl')}</TabsTrigger>
|
||
<TabsTrigger value="contest">{t('qedit.tabContest')}</TabsTrigger>
|
||
<TabsTrigger value="sat">{t('qedit.tabSat')}</TabsTrigger>
|
||
<TabsTrigger value="mystation">{t('qedit.tabMyStation')}</TabsTrigger>
|
||
<TabsTrigger value="moreadif">{t('qedit.tabMoreAdif')}</TabsTrigger>
|
||
<TabsTrigger value="extras">
|
||
{t('qedit.tabAdifFields')}
|
||
{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>{t('qedit.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>
|
||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||
<div className="flex flex-col w-20"><Label>R</Label>
|
||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||
title={t('qedit.fetchTitle')}>
|
||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.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>{t('qedit.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">{t('qedit.band')}</Label>
|
||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||
<SelectContent>{bandList.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">{t('qedit.rxBand')}</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>{bandList.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">{t('qedit.mode')}</Label>
|
||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||
<SelectContent>{modeList.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">{t('qedit.country')}</Label>
|
||
<Combobox value={draft.country ?? ''} options={countries} placeholder={t('qedit.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={t('qedit.dxccTitle')} />
|
||
{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">{t('qedit.txFreq')}</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">{t('qedit.rxFreq')}</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>{t('qedit.qsoStart')}</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) => {
|
||
const on = !!c;
|
||
setEndEnabled(on);
|
||
// Prefill an empty end with the start time so the user
|
||
// only tweaks the minutes instead of typing a full date.
|
||
if (on && !dateOff) setDateOff(dateOn);
|
||
}} /> {t('qedit.qsoEnd')}
|
||
</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>{t('qedit.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>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||
<div><Label>{t('qedit.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>{t('qedit.county')}</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||
<div><Label>{t('qedit.state')}</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||
<div>
|
||
<Label>{t('qedit.continent')}</Label>
|
||
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
||
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_">—</SelectItem>
|
||
{['NA', 'SA', 'EU', 'AF', 'AS', 'OC', 'AN'].map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||
<div><Label>{t('qedit.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>{t('qedit.email')}</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>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||
<div><Label>{t('qedit.qslVia')}</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} fieldValues={{ state: draft.state ?? '', cnty: draft.cnty ?? '' }} />
|
||
</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">{t('qedit.computedAuto')}</span>
|
||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||
{t('qedit.computedHint')}
|
||
</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">{t('qedit.noneYet')}</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>{t('qedit.manageConf')}</Label>
|
||
<Select value={confSel} onValueChange={setConfSel}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||
<div><Label>{t('qedit.received')}</Label>
|
||
{def.rcvd
|
||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||
: <Input disabled value="—" />}
|
||
</div>
|
||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||
{def.via && (
|
||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Right: live status grid for every channel.
|
||
Sized by its content, not pinned to a width: a fixed 288px box
|
||
left the label column too narrow, so "QSL (paper)" wrapped onto
|
||
two lines and padded out the whole row. There is spare width to
|
||
the right — spend it on the label. */}
|
||
<div className="shrink-0">
|
||
<table className="border-separate" style={{ borderSpacing: 4 }}>
|
||
<thead>
|
||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||
<th className="font-semibold">{t('qedit.sent')}</th>
|
||
<th className="font-semibold">{t('qedit.received')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{CONFIRMATIONS.map((c) => (
|
||
<tr key={c.key} className="text-xs">
|
||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : 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={t('qedit.contestId')} span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder={t('qedit.rcvdExchange')} onChange={(e) => setExchange('srx', e.target.value)} /></F>
|
||
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder={t('qedit.sentExchange')} onChange={(e) => setExchange('stx', e.target.value)} /></F>
|
||
<F label={t('qedit.check')}><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||
<F label={t('qedit.precedence')}><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||
<F label={t('qedit.arrlSection')}><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={t('qedit.propMode')}>
|
||
<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={t('qedit.satName')}><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||
<F label={t('qedit.satMode')}><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||
<F label={t('qedit.antAz')}><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||
<F label={t('qedit.antEl')}><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||
<F label={t('qedit.antPath')}><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">{t('qedit.myStationHint')}</p>
|
||
<div className="grid grid-cols-6 gap-3">
|
||
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||
<F label={t('qedit.gridExt')}><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
||
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||
<F label={t('qedit.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={t('qedit.cqZone')}><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||
<F label={t('qedit.ituZone')}><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={t('qedit.sotaRef')}><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||
<F label={t('qedit.potaRef')}><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={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||
<F label={t('qedit.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">{t('qedit.specialActivity')}</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={t('qedit.sigInfo')} span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||
<F label={t('qedit.wwffRef')} 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={t('qedit.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">{t('qedit.powerWeather')}</p>
|
||
<div className="grid grid-cols-6 gap-3">
|
||
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||
<F label={t('qedit.kIndex')}><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">{t('qedit.identityClubs')}</p>
|
||
<div className="grid grid-cols-6 gap-3">
|
||
<F label={t('qedit.contactedOp')} span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||
<F label={t('qedit.formerCall')} span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||
<F label={t('qedit.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">{t('qedit.flagsCredits')}</p>
|
||
<div className="grid grid-cols-6 gap-3">
|
||
<F label={t('qedit.qsoComplete')}><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||
<F label={t('qedit.qsoRandom')}><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||
<F label={t('qedit.silentKey')}><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={t('qedit.creditGranted')} span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||
<F label={t('qedit.creditSubmitted')} 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">{t('qedit.myStationAdif')}</p>
|
||
<div className="grid grid-cols-6 gap-3">
|
||
<F label={t('qedit.myName')} span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||
<F label={t('qedit.myWwffRef')} span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||
<F label={t('qedit.myArrlSect')} span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||
<F label={t('qedit.mySig')}><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||
<F label={t('qedit.mySigInfo')} span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||
<F label={t('qedit.myDarcDok')}><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||
<F label={t('qedit.myVuccGrids')} 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" /> {t('qedit.delete')}
|
||
</Button>
|
||
<div className="flex-1" />
|
||
<Button variant="outline" onClick={onClose} disabled={saving}>{t('qedit.cancel')}</Button>
|
||
<Button onClick={save} disabled={saving}>{saving ? t('qedit.saving') : t('qedit.saveChanges')}</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|