546 lines
19 KiB
TypeScript
546 lines
19 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import NumberInput from '@/components/ui/NumberInput.tsx';
|
|
import FrequencyBandPanel from '@/components/panels/FrequencyBandPanel.tsx';
|
|
import ModalBackdrop from './ModalBackdrop.tsx';
|
|
|
|
export interface SiteFormValues {
|
|
name: string;
|
|
lat: number;
|
|
lon: number;
|
|
power: number;
|
|
gain: number;
|
|
frequency: number;
|
|
height: number;
|
|
antennaType: 'omni' | 'sector';
|
|
azimuth: number;
|
|
beamwidth: number;
|
|
notes: string;
|
|
}
|
|
|
|
interface SiteConfigModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSave: (data: SiteFormValues) => void;
|
|
onSaveAndCalculate?: (data: SiteFormValues) => void;
|
|
onDelete?: () => void;
|
|
initialData?: Partial<SiteFormValues>;
|
|
mode: 'create' | 'edit';
|
|
}
|
|
|
|
const TEMPLATES = {
|
|
limesdr: {
|
|
label: 'LimeSDR',
|
|
tooltip: 'SDR dev board — low power, short range testing (20 dBm, 2 dBi, 1800 MHz)',
|
|
style: 'purple',
|
|
name: 'LimeSDR Mini',
|
|
power: 20,
|
|
gain: 2,
|
|
frequency: 1800,
|
|
height: 10,
|
|
antennaType: 'omni' as const,
|
|
},
|
|
lowBBU: {
|
|
label: 'Low BBU',
|
|
tooltip: 'Low-power baseband unit — suburban/campus coverage (40 dBm, 8 dBi, 1800 MHz)',
|
|
style: 'green',
|
|
name: 'Low Power BBU',
|
|
power: 40,
|
|
gain: 8,
|
|
frequency: 1800,
|
|
height: 20,
|
|
antennaType: 'omni' as const,
|
|
},
|
|
highBBU: {
|
|
label: 'High BBU',
|
|
tooltip: 'High-power BBU — urban macro sector (43 dBm, 15 dBi, 65\u00B0 sector)',
|
|
style: 'orange',
|
|
name: 'High Power BBU',
|
|
power: 43,
|
|
gain: 15,
|
|
frequency: 1800,
|
|
height: 30,
|
|
antennaType: 'sector' as const,
|
|
azimuth: 0,
|
|
beamwidth: 65,
|
|
},
|
|
urbanMacro: {
|
|
label: 'Urban Macro',
|
|
tooltip: 'Standard urban macro site — rooftop/tower sector (43 dBm, 18 dBi, 65\u00B0 sector)',
|
|
style: 'blue',
|
|
name: 'Urban Macro Site',
|
|
power: 43,
|
|
gain: 18,
|
|
frequency: 1800,
|
|
height: 30,
|
|
antennaType: 'sector' as const,
|
|
azimuth: 0,
|
|
beamwidth: 65,
|
|
},
|
|
ruralTower: {
|
|
label: 'Rural Tower',
|
|
tooltip: 'Rural high tower — long range 800 MHz omni coverage (46 dBm, 8 dBi, 50m)',
|
|
style: 'emerald',
|
|
name: 'Rural Tower',
|
|
power: 46,
|
|
gain: 8,
|
|
frequency: 800,
|
|
height: 50,
|
|
antennaType: 'omni' as const,
|
|
},
|
|
smallCell: {
|
|
label: 'Small Cell',
|
|
tooltip: 'Urban small cell — street-level high capacity (30 dBm, 12 dBi, 2600 MHz)',
|
|
style: 'cyan',
|
|
name: 'Small Cell',
|
|
power: 30,
|
|
gain: 12,
|
|
frequency: 2600,
|
|
height: 6,
|
|
antennaType: 'sector' as const,
|
|
azimuth: 0,
|
|
beamwidth: 90,
|
|
},
|
|
indoorDAS: {
|
|
label: 'Indoor DAS',
|
|
tooltip: 'Indoor distributed antenna — in-building coverage (23 dBm, 2 dBi, 2100 MHz)',
|
|
style: 'rose',
|
|
name: 'Indoor DAS',
|
|
power: 23,
|
|
gain: 2,
|
|
frequency: 2100,
|
|
height: 3,
|
|
antennaType: 'omni' as const,
|
|
},
|
|
uhfTactical: {
|
|
label: 'UHF Tactical',
|
|
tooltip: 'UHF tactical radio — man-portable field comms (25 dBm, 3 dBi, 450 MHz)',
|
|
style: 'amber',
|
|
name: 'UHF Tactical Radio',
|
|
power: 25,
|
|
gain: 3,
|
|
frequency: 450,
|
|
height: 5,
|
|
antennaType: 'omni' as const,
|
|
},
|
|
vhfRepeater: {
|
|
label: 'VHF Repeater',
|
|
tooltip: 'VHF repeater — long range voice/data relay (40 dBm, 6 dBi, 150 MHz)',
|
|
style: 'teal',
|
|
name: 'VHF Repeater',
|
|
power: 40,
|
|
gain: 6,
|
|
frequency: 150,
|
|
height: 25,
|
|
antennaType: 'omni' as const,
|
|
},
|
|
};
|
|
|
|
const TEMPLATE_COLORS: Record<string, string> = {
|
|
purple: 'bg-purple-100 hover:bg-purple-200 text-purple-700 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 dark:text-purple-300',
|
|
green: 'bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/30 dark:hover:bg-green-900/50 dark:text-green-300',
|
|
orange: 'bg-orange-100 hover:bg-orange-200 text-orange-700 dark:bg-orange-900/30 dark:hover:bg-orange-900/50 dark:text-orange-300',
|
|
blue: 'bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300',
|
|
emerald: 'bg-emerald-100 hover:bg-emerald-200 text-emerald-700 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 dark:text-emerald-300',
|
|
cyan: 'bg-cyan-100 hover:bg-cyan-200 text-cyan-700 dark:bg-cyan-900/30 dark:hover:bg-cyan-900/50 dark:text-cyan-300',
|
|
rose: 'bg-rose-100 hover:bg-rose-200 text-rose-700 dark:bg-rose-900/30 dark:hover:bg-rose-900/50 dark:text-rose-300',
|
|
amber: 'bg-amber-100 hover:bg-amber-200 text-amber-700 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 dark:text-amber-300',
|
|
teal: 'bg-teal-100 hover:bg-teal-200 text-teal-700 dark:bg-teal-900/30 dark:hover:bg-teal-900/50 dark:text-teal-300',
|
|
};
|
|
|
|
function getDefaults(initialData?: Partial<SiteFormValues>): SiteFormValues {
|
|
return {
|
|
name: initialData?.name ?? 'Station-1',
|
|
lat: initialData?.lat ?? 48.4647,
|
|
lon: initialData?.lon ?? 35.0462,
|
|
power: initialData?.power ?? 43,
|
|
gain: initialData?.gain ?? 8,
|
|
frequency: initialData?.frequency ?? 2100,
|
|
height: initialData?.height ?? 30,
|
|
antennaType: initialData?.antennaType ?? 'omni',
|
|
azimuth: initialData?.azimuth ?? 0,
|
|
beamwidth: initialData?.beamwidth ?? 65,
|
|
notes: initialData?.notes ?? '',
|
|
};
|
|
}
|
|
|
|
export default function SiteConfigModal({
|
|
isOpen,
|
|
onClose,
|
|
onSave,
|
|
onSaveAndCalculate,
|
|
onDelete,
|
|
initialData,
|
|
mode,
|
|
}: SiteConfigModalProps) {
|
|
const [form, setForm] = useState<SiteFormValues>(() => getDefaults(initialData));
|
|
const [errors, setErrors] = useState<Partial<Record<keyof SiteFormValues, string>>>({});
|
|
|
|
// Reset form when modal opens with new data
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setForm(getDefaults(initialData));
|
|
setErrors({});
|
|
}
|
|
}, [isOpen, initialData]);
|
|
|
|
const updateField = useCallback(<K extends keyof SiteFormValues>(field: K, value: SiteFormValues[K]) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
// Clear error for that field
|
|
setErrors((prev) => {
|
|
if (prev[field]) {
|
|
const next = { ...prev };
|
|
delete next[field];
|
|
return next;
|
|
}
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
const validate = useCallback((): boolean => {
|
|
const newErrors: Partial<Record<keyof SiteFormValues, string>> = {};
|
|
|
|
if (!form.name.trim()) {
|
|
newErrors.name = 'Site name is required';
|
|
}
|
|
if (form.lat < -90 || form.lat > 90) {
|
|
newErrors.lat = 'Latitude must be -90 to 90';
|
|
}
|
|
if (form.lon < -180 || form.lon > 180) {
|
|
newErrors.lon = 'Longitude must be -180 to 180';
|
|
}
|
|
if (form.power < 10 || form.power > 50) {
|
|
newErrors.power = 'Power must be 10-50 dBm';
|
|
}
|
|
if (form.gain < 0 || form.gain > 30) {
|
|
newErrors.gain = 'Gain must be 0-30 dBi';
|
|
}
|
|
if (form.frequency < 100 || form.frequency > 6000) {
|
|
newErrors.frequency = 'Frequency must be 100-6000 MHz';
|
|
}
|
|
if (form.height < 1 || form.height > 100) {
|
|
newErrors.height = 'Height must be 1-100m';
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
}, [form]);
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (!validate()) return;
|
|
onSave(form);
|
|
}, [form, validate, onSave]);
|
|
|
|
const handleSaveAndCalculate = useCallback(() => {
|
|
if (!validate()) return;
|
|
if (onSaveAndCalculate) {
|
|
onSaveAndCalculate(form);
|
|
} else {
|
|
onSave(form);
|
|
}
|
|
}, [form, validate, onSave, onSaveAndCalculate]);
|
|
|
|
const applyTemplate = useCallback((key: keyof typeof TEMPLATES) => {
|
|
const t = TEMPLATES[key];
|
|
setForm((prev) => ({
|
|
...prev,
|
|
name: t.name,
|
|
power: t.power,
|
|
gain: t.gain,
|
|
frequency: t.frequency,
|
|
height: t.height,
|
|
antennaType: t.antennaType,
|
|
azimuth: 'azimuth' in t ? t.azimuth : prev.azimuth,
|
|
beamwidth: 'beamwidth' in t ? t.beamwidth : prev.beamwidth,
|
|
}));
|
|
}, []);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<ModalBackdrop onClose={onClose}>
|
|
<div
|
|
className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl
|
|
border border-gray-200 dark:border-white/10"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-white/10 px-5 py-4 flex items-center justify-between z-10 rounded-t-xl">
|
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
|
|
{mode === 'create' ? '\uD83D\uDCCD New Site Configuration' : '\u270F\uFE0F Edit Site'}
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-white text-xl w-8 h-8 flex items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
|
>
|
|
{'\u2715'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-5 space-y-4">
|
|
{/* Site Name */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
|
Site Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => updateField('name', e.target.value)}
|
|
placeholder="Station-1"
|
|
className={`w-full px-3 py-2 border rounded-md text-sm
|
|
focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
dark:bg-slate-700 dark:text-white
|
|
${errors.name
|
|
? 'border-red-400 dark:border-red-500'
|
|
: 'border-gray-300 dark:border-slate-600'
|
|
}`}
|
|
/>
|
|
{errors.name && (
|
|
<p className="text-xs text-red-500">{errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Coordinates */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
|
Coordinates
|
|
</label>
|
|
<div className="flex gap-3">
|
|
<div className="flex-1">
|
|
<input
|
|
type="number"
|
|
step="0.0001"
|
|
value={form.lat}
|
|
onChange={(e) => updateField('lat', Number(e.target.value))}
|
|
className={`w-full px-3 py-2 border rounded-md text-sm font-mono
|
|
focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
dark:bg-slate-700 dark:text-white
|
|
${errors.lat
|
|
? 'border-red-400 dark:border-red-500'
|
|
: 'border-gray-300 dark:border-slate-600'
|
|
}`}
|
|
/>
|
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Latitude</p>
|
|
{errors.lat && <p className="text-xs text-red-500">{errors.lat}</p>}
|
|
</div>
|
|
<div className="flex-1">
|
|
<input
|
|
type="number"
|
|
step="0.0001"
|
|
value={form.lon}
|
|
onChange={(e) => updateField('lon', Number(e.target.value))}
|
|
className={`w-full px-3 py-2 border rounded-md text-sm font-mono
|
|
focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
dark:bg-slate-700 dark:text-white
|
|
${errors.lon
|
|
? 'border-red-400 dark:border-red-500'
|
|
: 'border-gray-300 dark:border-slate-600'
|
|
}`}
|
|
/>
|
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Longitude</p>
|
|
{errors.lon && <p className="text-xs text-red-500">{errors.lon}</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* RF Parameters separator */}
|
|
<div className="border-t border-gray-200 dark:border-white/10 pt-3">
|
|
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
RF Parameters
|
|
</span>
|
|
</div>
|
|
|
|
{/* Power */}
|
|
<NumberInput
|
|
label="Transmit Power"
|
|
value={form.power}
|
|
min={10}
|
|
max={50}
|
|
step={1}
|
|
unit="dBm"
|
|
hint="LimeSDR 20, BBU 43, RRU 46"
|
|
onChange={(v) => updateField('power', v)}
|
|
/>
|
|
|
|
{/* Gain */}
|
|
<NumberInput
|
|
label="Antenna Gain"
|
|
value={form.gain}
|
|
min={0}
|
|
max={30}
|
|
step={0.5}
|
|
unit="dBi"
|
|
hint={
|
|
form.gain <= 8
|
|
? `Omni-directional (${form.gain} dBi)`
|
|
: form.gain <= 18
|
|
? `Sector/Panel (${form.gain} dBi)`
|
|
: `Parabolic/Dish (${form.gain} dBi)`
|
|
}
|
|
onChange={(v) => updateField('gain', v)}
|
|
/>
|
|
|
|
{/* Band panel — UHF/VHF/LTE/5G grouped selector + custom input */}
|
|
<FrequencyBandPanel
|
|
value={form.frequency}
|
|
onChange={(v) => updateField('frequency', v)}
|
|
/>
|
|
|
|
{/* Physical Parameters separator */}
|
|
<div className="border-t border-gray-200 dark:border-white/10 pt-3">
|
|
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
Physical Parameters
|
|
</span>
|
|
</div>
|
|
|
|
{/* Height */}
|
|
<NumberInput
|
|
label="Antenna Height"
|
|
value={form.height}
|
|
min={1}
|
|
max={100}
|
|
step={1}
|
|
unit="m"
|
|
hint="Height from ground to antenna center"
|
|
onChange={(v) => updateField('height', v)}
|
|
/>
|
|
|
|
{/* Antenna type */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
|
Antenna Type
|
|
</label>
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-2 cursor-pointer min-h-[36px]">
|
|
<input
|
|
type="radio"
|
|
name="modalAntennaType"
|
|
checked={form.antennaType === 'omni'}
|
|
onChange={() => updateField('antennaType', 'omni')}
|
|
className="accent-blue-600"
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-200">Omni</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer min-h-[36px]">
|
|
<input
|
|
type="radio"
|
|
name="modalAntennaType"
|
|
checked={form.antennaType === 'sector'}
|
|
onChange={() => updateField('antennaType', 'sector')}
|
|
className="accent-blue-600"
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-200">Sector</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sector parameters */}
|
|
{form.antennaType === 'sector' && (
|
|
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-md p-3 space-y-3 border border-gray-200 dark:border-slate-600">
|
|
<NumberInput
|
|
label="Azimuth"
|
|
value={form.azimuth}
|
|
min={0}
|
|
max={359}
|
|
step={1}
|
|
unit={'\u00B0'}
|
|
onChange={(v) => updateField('azimuth', v)}
|
|
/>
|
|
<NumberInput
|
|
label="Beamwidth"
|
|
value={form.beamwidth}
|
|
min={30}
|
|
max={120}
|
|
step={5}
|
|
unit={'\u00B0'}
|
|
onChange={(v) => updateField('beamwidth', v)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes separator */}
|
|
<div className="border-t border-gray-200 dark:border-white/10 pt-3">
|
|
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
Notes
|
|
</span>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
|
Equipment / Notes (optional)
|
|
</label>
|
|
<textarea
|
|
value={form.notes}
|
|
onChange={(e) => updateField('notes', e.target.value)}
|
|
placeholder="e.g., ZTE B8200 BBU + custom omni antenna"
|
|
rows={2}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white rounded-md text-sm
|
|
focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Quick Templates */}
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
Quick Templates
|
|
</label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{Object.entries(TEMPLATES).map(([key, t]) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => applyTemplate(key as keyof typeof TEMPLATES)}
|
|
title={t.tooltip}
|
|
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors min-h-[32px]
|
|
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-white/10 px-5 py-3 flex items-center justify-between rounded-b-xl">
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-slate-700 hover:bg-gray-200 dark:hover:bg-slate-600 rounded-md transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
{mode === 'edit' && onDelete && (
|
|
<button
|
|
onClick={onDelete}
|
|
className="px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 rounded-md transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{onSaveAndCalculate && (
|
|
<button
|
|
onClick={handleSaveAndCalculate}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded-md transition-colors shadow-sm"
|
|
>
|
|
{mode === 'create' ? 'Create & Calculate' : 'Save & Calculate'}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleSave}
|
|
className="px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 rounded-md transition-colors"
|
|
>
|
|
{mode === 'create' ? 'Create Site' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ModalBackdrop>
|
|
);
|
|
}
|