The stamp answers "PSE QSL", so it reads as its counterpart or not at all: a
bare "TNX" next to a QSL message says thanks for something unnamed.
Changed at the source of the {qso.pse_tnx} token, so every card picks it up
with no template edit. The live indicator in the QSO editor and the hint beside
it follow.
926 lines
57 KiB
TypeScript
926 lines
57 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } 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 { titleCase, sentenceCase } from '@/lib/textCase';
|
||
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 = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||
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',
|
||
};
|
||
|
||
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||
// the channel picker and the status table alongside the rest.
|
||
const OPSLOG_CONF = 'OPSLOG';
|
||
|
||
// 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],
|
||
);
|
||
|
||
// OpsLog QSL "received" marker (ADIF extra). Drives the card's PSE/TNX stamp:
|
||
// received → TNX QSL (thanks for your card), otherwise PSE QSL (please send one).
|
||
// Saved with the normal Save via draft.extras.
|
||
const OPSLOG_QSL_RCVD = 'APP_OPSLOG_QSL_RCVD';
|
||
const qslReceived = !!String(draft.extras?.[OPSLOG_QSL_RCVD] ?? '').trim();
|
||
// Sent side, for the confirmations table. Two key names because the marker was
|
||
// renamed once and old QSOs still carry the first one — same test the Recent
|
||
// QSOs "OpsLog QSL" column makes.
|
||
const opslogQslSent = !!(draft.extras?.['APP_OPSLOG_QSL_SENT'] || draft.extras?.['APP_OPSLOG_QSL_CARD_SENT']);
|
||
const toggleQslReceived = (on: boolean) => {
|
||
// Reflect immediately in the draft (for the PSE/TNX indicator)…
|
||
const next = { ...(draft.extras ?? {}) };
|
||
if (on) next[OPSLOG_QSL_RCVD] = new Date().toISOString();
|
||
else delete next[OPSLOG_QSL_RCVD];
|
||
set('extras', next as any);
|
||
// …and persist right away with a targeted write that reliably sets OR clears
|
||
// the key (the modal's extras-merge on Save wouldn't clear a removed key).
|
||
if ((draft as any).id) SetOpsLogQSLReceived((draft as any).id, on).catch(() => {});
|
||
};
|
||
|
||
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">
|
||
<div className="flex items-center">
|
||
<Label>{t('qedit.callsign')}</Label>
|
||
{(draft.callsign ?? '').trim() && (
|
||
<button
|
||
type="button"
|
||
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
|
||
onClick={() => {
|
||
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
||
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
|
||
}}
|
||
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
|
||
>
|
||
QRZ ↗
|
||
</button>
|
||
)}
|
||
</div>
|
||
<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)}
|
||
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></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)}
|
||
onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
|
||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
|
||
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></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)}
|
||
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></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>
|
||
{/* The OpsLog QSL marker used to sit here, under QSL Msg. It
|
||
belongs with the other confirmation channels — see the QSL
|
||
Info tab. */}
|
||
<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>)}
|
||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
||
QSO columns, and this channel lives in the ADIF
|
||
extras. It gets its own editor below rather than the
|
||
generic sent/received/date grid, which has no field
|
||
to bind to. */}
|
||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{confSel === OPSLOG_CONF ? (
|
||
/* OpsLog's own card. "Sent" is stamped when the card
|
||
actually goes out, so it is shown, not offered: ticking
|
||
it by hand would record something that never happened.
|
||
"Received" drives the PSE/TNX stamp printed on the card,
|
||
which is the whole reason the flag exists — hence the
|
||
live indicator next to it. It writes immediately rather
|
||
than on Save, because clearing an extras key would not
|
||
survive the merge Save does. */
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>{t('qedit.sent')}</Label>
|
||
<Input disabled value={opslogQslSent ? t('qedit.qslYes') : t('qedit.qslNo')} />
|
||
</div>
|
||
<div>
|
||
<Label>{t('qedit.received')}</Label>
|
||
<label className="flex h-9 items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
|
||
{t('qedit.qslReceived')}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||
{qslReceived ? 'TNX QSL' : 'PSE QSL'}
|
||
</span>
|
||
<span className="text-[11px] text-muted-foreground">{t('qedit.pseTnxHint')}</span>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">{t('qedit.opslogSentHint')}</p>
|
||
</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>
|
||
))}
|
||
{/* OpsLog's own card, read from the ADIF extras rather
|
||
than a QSO column — hence a hand-written row instead
|
||
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
||
when the card actually goes out, so it stays
|
||
read-only here: an operator ticking it by hand would
|
||
be recording something that never happened. */}
|
||
<tr className="text-xs">
|
||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></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>
|
||
);
|
||
}
|