472 lines
23 KiB
TypeScript
472 lines
23 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
AllCommunityModule, ModuleRegistry,
|
|
type ColDef,
|
|
} from 'ag-grid-community';
|
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
|
import { AgGridReact } from 'ag-grid-react';
|
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
|
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 { Badge } from '@/components/ui/badge';
|
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import type { QSOForm } from '@/types';
|
|
import {
|
|
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
|
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
|
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
|
WorkedBefore,
|
|
} from '@/../wailsjs/go/main/App';
|
|
import { netctl } from '@/../wailsjs/go/models';
|
|
|
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
|
|
|
const hamlogTheme = hamlogGridTheme;
|
|
|
|
type Net = netctl.Net;
|
|
type Station = netctl.Station;
|
|
|
|
function fmtTimeOn(s: any): string {
|
|
if (!s) return '';
|
|
const d = new Date(s);
|
|
if (isNaN(d.getTime())) return '';
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
|
|
}
|
|
|
|
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
|
|
|
type Props = {
|
|
onLogged?: () => void;
|
|
countries?: string[];
|
|
bands?: string[];
|
|
modes?: string[];
|
|
};
|
|
|
|
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|
const { t } = useI18n();
|
|
const [nets, setNets] = useState<Net[]>([]);
|
|
const [selId, setSelId] = useState<string>('');
|
|
const [openId, setOpenId] = useState<string>('');
|
|
const [roster, setRoster] = useState<Station[]>([]);
|
|
const [active, setActive] = useState<QSOForm[]>([]);
|
|
const [error, setError] = useState('');
|
|
|
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
|
|
|
// Add/edit-contact dialog.
|
|
const [contactOpen, setContactOpen] = useState(false);
|
|
const [contact, setContact] = useState<Station>(emptyStation());
|
|
const [looking, setLooking] = useState(false);
|
|
|
|
const activeGrid = useRef<any>(null);
|
|
const rosterGrid = useRef<any>(null);
|
|
|
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
|
const [wbCall, setWbCall] = useState('');
|
|
const [wb, setWb] = useState<any>(null);
|
|
const [wbBusy, setWbBusy] = useState(false);
|
|
const wbReq = useRef(0);
|
|
// Resizable height (drag the handle on top). Persisted.
|
|
const [wbHeight, setWbHeight] = useState(() => {
|
|
const v = parseInt(localStorage.getItem('opslog.netWbHeight') || '', 10);
|
|
return v >= 80 && v <= 600 ? v : 176;
|
|
});
|
|
useEffect(() => { localStorage.setItem('opslog.netWbHeight', String(wbHeight)); }, [wbHeight]);
|
|
const startWbResize = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
const startY = e.clientY, startH = wbHeight;
|
|
const onMove = (ev: MouseEvent) => setWbHeight(Math.max(80, Math.min(600, startH - (ev.clientY - startY))));
|
|
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
};
|
|
const showWorkedBefore = useCallback(async (call: string, dxcc = 0) => {
|
|
const c = (call ?? '').trim().toUpperCase();
|
|
setWbCall(c);
|
|
if (!c) { setWb(null); return; }
|
|
const req = ++wbReq.current;
|
|
setWbBusy(true);
|
|
try { const r = await WorkedBefore(c, dxcc); if (wbReq.current === req) setWb(r); }
|
|
catch { if (wbReq.current === req) setWb(null); }
|
|
finally { if (wbReq.current === req) setWbBusy(false); }
|
|
}, []);
|
|
|
|
const isOpen = openId !== '' && openId === selId;
|
|
|
|
const refreshNets = useCallback(async () => {
|
|
try {
|
|
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
|
|
const arr = (list ?? []) as Net[];
|
|
setNets(arr);
|
|
setOpenId(oid ?? '');
|
|
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
|
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}, []);
|
|
|
|
const refreshRoster = useCallback(async (id: string) => {
|
|
if (!id) { setRoster([]); return; }
|
|
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
|
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}, []);
|
|
|
|
const refreshActive = useCallback(async () => {
|
|
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
|
|
catch { /* ignore */ }
|
|
}, []);
|
|
|
|
useEffect(() => { refreshNets(); }, [refreshNets]);
|
|
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
|
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
|
|
|
// The roster side hides callsigns currently on the air (they live in the left
|
|
// grid until logged), mirroring Log4OM's two-list behaviour.
|
|
const activeCalls = useMemo(
|
|
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
|
|
[active],
|
|
);
|
|
const rosterShown = useMemo(
|
|
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
|
|
[roster, activeCalls],
|
|
);
|
|
|
|
async function newNet() {
|
|
const name = window.prompt(t('ncp.newNetPrompt'));
|
|
if (!name?.trim()) return;
|
|
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
async function renameNet() {
|
|
if (!selId) return;
|
|
const cur = nets.find((n) => n.id === selId);
|
|
const name = window.prompt(t('ncp.renamePrompt'), cur?.name ?? '');
|
|
if (!name?.trim()) return;
|
|
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
async function deleteNet() {
|
|
if (!selId) return;
|
|
const cur = nets.find((n) => n.id === selId);
|
|
if (!window.confirm(t('ncp.deleteConfirm', { name: cur?.name ?? '' }))) return;
|
|
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
async function toggleOpen() {
|
|
try {
|
|
if (isOpen) {
|
|
if (active.length > 0 &&
|
|
!window.confirm(t('ncp.closeConfirm', { n: active.length }))) return;
|
|
await NetClose(); setOpenId('');
|
|
} else {
|
|
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
|
}
|
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
|
|
// Roster → active (start QSO).
|
|
async function activate(call: string) {
|
|
if (!isOpen || !call) return;
|
|
try { await NetActivate(call); await refreshActive(); }
|
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
// Active → logged (end QSO, removed from session, written to the logbook).
|
|
async function deactivate(id?: number) {
|
|
if (id == null) return;
|
|
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
|
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
|
async function saveDraft(q: QSOForm) {
|
|
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
|
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
async function discardDraft(id: number) {
|
|
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
|
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
|
|
// Add-contact dialog.
|
|
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
|
|
async function lookupContact() {
|
|
const call = (contact.callsign ?? '').trim();
|
|
if (!call) return;
|
|
setLooking(true);
|
|
try {
|
|
const r = await NetLookup(call);
|
|
setContact((c) => netctl.Station.createFrom({
|
|
...c, callsign: (r.callsign || call).toUpperCase(),
|
|
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
|
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
|
grid: r.grid || c.grid, address: r.address || c.address, state: r.state || c.state,
|
|
cnty: r.cnty || c.cnty, cont: r.cont || c.cont, lat: r.lat || c.lat, lon: r.lon || c.lon,
|
|
email: r.email || c.email,
|
|
}));
|
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
|
finally { setLooking(false); }
|
|
}
|
|
async function saveContact() {
|
|
const call = (contact.callsign ?? '').trim().toUpperCase();
|
|
if (!call || !selId) return;
|
|
try {
|
|
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
|
|
setContactOpen(false);
|
|
await refreshRoster(selId);
|
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
async function removeSelectedRoster() {
|
|
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
|
if (sel.length === 0) return;
|
|
if (!window.confirm(t('ncp.removeConfirm', { n: sel.length }))) return;
|
|
try {
|
|
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
|
await refreshRoster(selId);
|
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
|
}
|
|
|
|
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
|
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
|
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
|
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
|
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
|
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
|
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
|
], [t]);
|
|
|
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
|
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
|
], [t]);
|
|
|
|
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
|
|
|
return (
|
|
<div className="flex flex-col min-h-0 flex-1">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
|
<Plus className="size-3.5" /> {t('ncp.newNet')}
|
|
</Button>
|
|
<select
|
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
|
value={selId}
|
|
disabled={isOpen}
|
|
onChange={(e) => setSelId(e.target.value)}
|
|
title={isOpen ? t('ncp.closeToSwitch') : t('ncp.selectNetTitle')}
|
|
>
|
|
<option value="">{t('ncp.selectNetOption')}</option>
|
|
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
|
</select>
|
|
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
|
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
|
|
<Button variant="ghost" size="sm" className="h-8 text-danger" disabled={!selId || isOpen} onClick={deleteNet}>
|
|
<Trash2 className="size-3.5" /> {t('ncp.delete')}
|
|
</Button>
|
|
|
|
<div className="flex-1" />
|
|
{isOpen && <Badge className="bg-success text-success-foreground tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
|
|
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
|
|
</span>
|
|
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1">
|
|
{/* ACTIVE USERS (left) */}
|
|
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground text-[11px] font-semibold uppercase tracking-wider">
|
|
{t('ncp.onAirActive')}
|
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
<div style={{ position: 'absolute', inset: 0 }}>
|
|
<AgGridReact<QSOForm>
|
|
ref={activeGrid}
|
|
theme={hamlogTheme}
|
|
rowData={active}
|
|
columnDefs={activeCols}
|
|
defaultColDef={defaultColDef}
|
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
|
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
|
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
|
animateRows={false}
|
|
getRowId={(p) => String((p.data as any).id)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{isOpen && active.length > 0 && (
|
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
|
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Worked-before for the clicked station — click a row (on air or
|
|
roster) to see if/when we contacted it before. Lives INSIDE the
|
|
on-air column so resizing only splits this column; the roster on
|
|
the right keeps its full height. */}
|
|
<div className="shrink-0 flex flex-col" style={{ height: wbHeight }}>
|
|
<div className="h-1.5 shrink-0 cursor-ns-resize border-t border-border/60 hover:bg-primary/40 transition-colors" onMouseDown={startWbResize} title={t('ncp.wbResize')} />
|
|
<div className="flex-1 min-h-0 bg-card flex flex-col">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
|
<History className="size-3.5" />
|
|
{t('ncp.workedBefore')}
|
|
{wbCall && <span className="font-mono text-foreground normal-case">{wbCall}</span>}
|
|
{wb && wb.count > 0 && (
|
|
<span className="ml-auto font-normal normal-case text-foreground">
|
|
{wb.count} QSO · {t('ncp.wbFirst')} {String(wb.first ?? '').slice(0, 10)} · {t('ncp.wbLast')} {String(wb.last ?? '').slice(0, 10)}
|
|
{wb.dxcc_name ? ` · ${wb.dxcc_name} (${wb.dxcc_count})` : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
{!wbCall ? (
|
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
|
) : wbBusy ? (
|
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">…</div>
|
|
) : !wb || wb.count === 0 ? (
|
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
|
) : (
|
|
<table className="w-full text-xs">
|
|
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
|
<tr className="text-left">
|
|
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
|
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
|
|
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
|
|
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
|
|
<th className="px-2 py-1 font-semibold">RST</th>
|
|
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{((wb.entries ?? []) as any[]).map((q, i) => (
|
|
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
|
|
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
|
|
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
|
|
<td className="px-2 py-1">{q.band}</td>
|
|
<td className="px-2 py-1">{q.mode}</td>
|
|
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
|
|
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* NET USERS / roster (right) */}
|
|
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-success text-success-foreground text-[11px] font-semibold uppercase tracking-wider">
|
|
{t('ncp.netUsersRoster')}
|
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
<div style={{ position: 'absolute', inset: 0 }}>
|
|
<AgGridReact<Station>
|
|
ref={rosterGrid}
|
|
theme={hamlogTheme}
|
|
rowData={rosterShown}
|
|
columnDefs={rosterCols}
|
|
defaultColDef={defaultColDef}
|
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
|
animateRows={false}
|
|
getRowId={(p) => String((p.data as any).callsign)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
|
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-danger" disabled={!selId} onClick={removeSelectedRoster}>
|
|
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
|
|
</Button>
|
|
{isOpen && (
|
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
|
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
|
<PlusCircle className="size-3.5" /> {t('ncp.putOnAir')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
|
|
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
|
|
{editingDraft && (
|
|
<QSOEditModal
|
|
qso={editingDraft}
|
|
onSave={saveDraft}
|
|
onDelete={discardDraft}
|
|
onClose={() => setEditingDraft(null)}
|
|
countries={countries}
|
|
bands={bands}
|
|
modes={modes}
|
|
/>
|
|
)}
|
|
|
|
{/* Add / edit contact dialog */}
|
|
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('ncp.addContactTitle')}</DialogTitle>
|
|
<DialogDescription>{t('ncp.addContactDesc')}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-3 px-5 py-2">
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<Label className="text-[11px]">{t('ncp.callsign')}</Label>
|
|
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
|
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
|
</div>
|
|
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
|
<Search className="size-3.5" /> {looking ? '…' : t('ncp.search')}
|
|
</Button>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">{t('ncp.name')}</Label>
|
|
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">QTH</Label>
|
|
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">{t('ncp.country')}</Label>
|
|
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>{t('ncp.cancel')}</Button>
|
|
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
|
<PlusCircle className="size-3.5" /> {t('ncp.saveInNet')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|