@mytec: iter10.6 ready for testing

This commit is contained in:
2026-01-30 20:11:48 +02:00
parent d8256288b0
commit 625cce31e4
7 changed files with 1066 additions and 68 deletions

View File

@@ -0,0 +1,42 @@
import { useEffect, useCallback } from 'react';
interface ModalBackdropProps {
children: React.ReactNode;
onClose: () => void;
}
export default function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
// Prevent body scrolling while modal is open
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, []);
return (
<div
className="fixed inset-0 z-[9000] bg-black/60 backdrop-blur-[2px] flex items-center justify-center p-4"
onClick={handleBackdropClick}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,507 @@
import { useState, useEffect, useCallback } from 'react';
import NumberInput from '@/components/ui/NumberInput.tsx';
import FrequencySelector from '@/components/panels/FrequencySelector.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',
style: 'purple',
name: 'LimeSDR Mini',
power: 20,
gain: 2,
frequency: 1800,
height: 10,
antennaType: 'omni' as const,
},
lowBBU: {
label: 'Low BBU',
style: 'green',
name: 'Low Power BBU',
power: 40,
gain: 8,
frequency: 1800,
height: 20,
antennaType: 'omni' as const,
},
highBBU: {
label: 'High BBU',
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',
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',
style: 'emerald',
name: 'Rural Tower',
power: 46,
gain: 8,
frequency: 800,
height: 50,
antennaType: 'omni' as const,
},
smallCell: {
label: 'Small Cell',
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',
style: 'rose',
name: 'Indoor DAS',
power: 23,
gain: 2,
frequency: 2100,
height: 3,
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',
};
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 > 25) {
newErrors.gain = 'Gain must be 0-25 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={25}
step={0.5}
unit="dBi"
hint="Omni 2-8, Sector 15-18, Parabolic 20-25"
onChange={(v) => updateField('gain', v)}
/>
{/* Frequency */}
<FrequencySelector
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)}
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>
);
}

View File

@@ -0,0 +1,3 @@
export { default as SiteConfigModal } from './SiteConfigModal.tsx';
export { default as ModalBackdrop } from './ModalBackdrop.tsx';
export type { SiteFormValues } from './SiteConfigModal.tsx';

View File

@@ -7,24 +7,39 @@ interface BatchEditProps {
onBatchApplied?: (affectedIds: string[]) => void;
}
const QUICK_FREQS = [800, 1800, 1900, 2100, 2600];
export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
const batchUpdateHeight = useSitesStore((s) => s.batchUpdateHeight);
const batchSetHeight = useSitesStore((s) => s.batchSetHeight);
const batchAdjustAzimuth = useSitesStore((s) => s.batchAdjustAzimuth);
const batchSetAzimuth = useSitesStore((s) => s.batchSetAzimuth);
const batchAdjustPower = useSitesStore((s) => s.batchAdjustPower);
const batchSetPower = useSitesStore((s) => s.batchSetPower);
const batchAdjustTilt = useSitesStore((s) => s.batchAdjustTilt);
const batchSetTilt = useSitesStore((s) => s.batchSetTilt);
const batchSetFrequency = useSitesStore((s) => s.batchSetFrequency);
const clearSelection = useSitesStore((s) => s.clearSelection);
const addToast = useToastStore((s) => s.addToast);
const [customHeight, setCustomHeight] = useState('');
const [customAzimuth, setCustomAzimuth] = useState('');
const [customPower, setCustomPower] = useState('');
const [customTilt, setCustomTilt] = useState('');
const [customFrequency, setCustomFrequency] = useState('');
if (selectedSiteIds.length === 0) return null;
const notifyBatch = (ids: string[]) => {
onBatchApplied?.(ids);
};
// ── Height ──
const handleAdjustHeight = async (delta: number) => {
const ids = [...selectedSiteIds];
await batchUpdateHeight(delta);
onBatchApplied?.(ids);
notifyBatch(ids);
addToast(
`Updated ${ids.length} site(s) height by ${delta > 0 ? '+' : ''}${delta}m`,
'success'
@@ -39,17 +54,18 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
}
const ids = [...selectedSiteIds];
await batchSetHeight(height);
onBatchApplied?.(ids);
notifyBatch(ids);
addToast(`Set ${ids.length} site(s) to ${height}m`, 'success');
setCustomHeight('');
};
// ── Azimuth ──
const handleAdjustAzimuth = async (delta: number) => {
const ids = [...selectedSiteIds];
await batchAdjustAzimuth(delta);
onBatchApplied?.(ids);
notifyBatch(ids);
addToast(
`Rotated ${ids.length} site(s) by ${delta > 0 ? '+' : ''}${delta}°`,
`Rotated ${ids.length} site(s) by ${delta > 0 ? '+' : ''}${delta}\u00B0`,
'success'
);
};
@@ -57,16 +73,88 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
const handleSetAzimuth = async () => {
const az = parseInt(customAzimuth, 10);
if (isNaN(az) || az < 0 || az > 359) {
addToast('Azimuth must be between 0-359°', 'error');
addToast('Azimuth must be between 0-359\u00B0', 'error');
return;
}
const ids = [...selectedSiteIds];
await batchSetAzimuth(az);
onBatchApplied?.(ids);
addToast(`Set ${ids.length} site(s) azimuth to ${az}°`, 'success');
notifyBatch(ids);
addToast(`Set ${ids.length} site(s) azimuth to ${az}\u00B0`, 'success');
setCustomAzimuth('');
};
// ── Power ──
const handleAdjustPower = async (delta: number) => {
const ids = [...selectedSiteIds];
await batchAdjustPower(delta);
notifyBatch(ids);
addToast(
`Adjusted ${ids.length} site(s) power by ${delta > 0 ? '+' : ''}${delta} dB`,
'success'
);
};
const handleSetPower = async () => {
const power = parseInt(customPower, 10);
if (isNaN(power) || power < 10 || power > 50) {
addToast('Power must be between 10-50 dBm', 'error');
return;
}
const ids = [...selectedSiteIds];
await batchSetPower(power);
notifyBatch(ids);
addToast(`Set ${ids.length} site(s) power to ${power} dBm`, 'success');
setCustomPower('');
};
// ── Tilt ──
const handleAdjustTilt = async (delta: number) => {
const ids = [...selectedSiteIds];
await batchAdjustTilt(delta);
notifyBatch(ids);
addToast(
`Adjusted ${ids.length} site(s) tilt by ${delta > 0 ? '+' : ''}${delta}\u00B0`,
'success'
);
};
const handleSetTilt = async () => {
const tilt = parseInt(customTilt, 10);
if (isNaN(tilt) || tilt < -90 || tilt > 90) {
addToast('Tilt must be between -90\u00B0 and +90\u00B0', 'error');
return;
}
const ids = [...selectedSiteIds];
await batchSetTilt(tilt);
notifyBatch(ids);
addToast(`Set ${ids.length} site(s) tilt to ${tilt}\u00B0`, 'success');
setCustomTilt('');
};
// ── Frequency ──
const handleSetFrequencyQuick = async (freq: number) => {
const ids = [...selectedSiteIds];
await batchSetFrequency(freq);
notifyBatch(ids);
addToast(`Set ${ids.length} site(s) to ${freq} MHz`, 'success');
};
const handleSetFrequencyCustom = async () => {
const freq = parseInt(customFrequency, 10);
if (isNaN(freq) || freq < 100 || freq > 6000) {
addToast('Frequency must be between 100-6000 MHz', 'error');
return;
}
const ids = [...selectedSiteIds];
await batchSetFrequency(freq);
notifyBatch(ids);
addToast(`Set ${ids.length} site(s) to ${freq} MHz`, 'success');
setCustomFrequency('');
};
const inputClass =
'flex-1 px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500';
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
@@ -78,7 +166,7 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
</Button>
</div>
{/* Quick height adjustments */}
{/* ── Height ── */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Adjust Height:
@@ -99,7 +187,6 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
</div>
</div>
{/* Set exact height */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Set Height:
@@ -113,47 +200,43 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
onChange={(e) => setCustomHeight(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSetHeight()}
placeholder="meters"
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
className={inputClass}
/>
<Button
size="sm"
onClick={handleSetHeight}
disabled={!customHeight}
>
<Button size="sm" onClick={handleSetHeight} disabled={!customHeight}>
Apply
</Button>
</div>
</div>
{/* Adjust azimuth */}
<div className="border-t border-blue-200 dark:border-blue-700" />
{/* ── Azimuth ── */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Adjust Azimuth:
</label>
<div className="flex gap-1 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(-90)}>
-90°
-90{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(-45)}>
-45°
-45{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(-10)}>
-10°
-10{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(10)}>
+10°
+10{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(45)}>
+45°
+45{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustAzimuth(90)}>
+90°
+90{'\u00B0'}
</Button>
</div>
</div>
{/* Set exact azimuth */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Set Azimuth:
@@ -166,9 +249,8 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
value={customAzimuth}
onChange={(e) => setCustomAzimuth(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSetAzimuth()}
placeholder="0-359°"
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="0-359\u00B0"
className={inputClass}
/>
<Button
size="sm"
@@ -176,18 +258,157 @@ export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
onClick={() => {
const ids = [...selectedSiteIds];
batchSetAzimuth(0).then(() => {
onBatchApplied?.(ids);
addToast(`Set ${ids.length} site(s) to North (0°)`, 'success');
notifyBatch(ids);
addToast(`Set ${ids.length} site(s) to North (0\u00B0)`, 'success');
});
}}
>
N 0°
N 0{'\u00B0'}
</Button>
<Button
size="sm"
onClick={handleSetAzimuth}
disabled={!customAzimuth}
>
<Button size="sm" onClick={handleSetAzimuth} disabled={!customAzimuth}>
Apply
</Button>
</div>
</div>
<div className="border-t border-blue-200 dark:border-blue-700" />
{/* ── Power ── */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Adjust Power:
</label>
<div className="flex gap-1 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => handleAdjustPower(6)}>
+6dB
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustPower(3)}>
+3dB
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustPower(1)}>
+1dB
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustPower(-1)}>
-1dB
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustPower(-3)}>
-3dB
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustPower(-6)}>
-6dB
</Button>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Set Power:
</label>
<div className="flex gap-2">
<input
type="number"
min={10}
max={50}
value={customPower}
onChange={(e) => setCustomPower(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSetPower()}
placeholder="dBm"
className={inputClass}
/>
<Button size="sm" onClick={handleSetPower} disabled={!customPower}>
Apply
</Button>
</div>
</div>
<div className="border-t border-blue-200 dark:border-blue-700" />
{/* ── Tilt ── */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Adjust Tilt:
</label>
<div className="flex gap-1 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => handleAdjustTilt(10)}>
+10{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustTilt(5)}>
+5{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustTilt(2)}>
+2{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustTilt(-2)}>
-2{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustTilt(-5)}>
-5{'\u00B0'}
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustTilt(-10)}>
-10{'\u00B0'}
</Button>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Set Tilt:
</label>
<div className="flex gap-2">
<input
type="number"
min={-90}
max={90}
value={customTilt}
onChange={(e) => setCustomTilt(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSetTilt()}
placeholder="degrees"
className={inputClass}
/>
<Button size="sm" onClick={handleSetTilt} disabled={!customTilt}>
Apply
</Button>
</div>
</div>
<div className="border-t border-blue-200 dark:border-blue-700" />
{/* ── Frequency ── */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Set Frequency:
</label>
<div className="flex gap-1.5 flex-wrap">
{QUICK_FREQS.map((freq) => (
<Button
key={freq}
size="sm"
variant="secondary"
onClick={() => handleSetFrequencyQuick(freq)}
>
{freq}
</Button>
))}
<span className="self-center text-xs text-gray-400 dark:text-dark-muted">MHz</span>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Custom Frequency:
</label>
<div className="flex gap-2">
<input
type="number"
min={100}
max={6000}
value={customFrequency}
onChange={(e) => setCustomFrequency(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSetFrequencyCustom()}
placeholder="MHz"
className={inputClass}
/>
<Button size="sm" onClick={handleSetFrequencyCustom} disabled={!customFrequency}>
Apply
</Button>
</div>