@mytec: iter10.6 ready for testing
This commit is contained in:
@@ -12,11 +12,12 @@ import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
||||
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
|
||||
import HeatmapLegend from '@/components/map/HeatmapLegend.tsx';
|
||||
import SiteList from '@/components/panels/SiteList.tsx';
|
||||
import SiteForm from '@/components/panels/SiteForm.tsx';
|
||||
import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
||||
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
||||
import CoverageStats from '@/components/panels/CoverageStats.tsx';
|
||||
import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
||||
import { SiteConfigModal } from '@/components/modals/index.ts';
|
||||
import type { SiteFormValues } from '@/components/modals/index.ts';
|
||||
import ToastContainer from '@/components/ui/Toast.tsx';
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
@@ -51,12 +52,12 @@ export default function App() {
|
||||
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
||||
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editSite, setEditSite] = useState<Site | null>(null);
|
||||
const [pendingLocation, setPendingLocation] = useState<{
|
||||
lat: number;
|
||||
lon: number;
|
||||
} | null>(null);
|
||||
const [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
editSiteId?: string;
|
||||
initialData?: Partial<SiteFormValues>;
|
||||
}>({ isOpen: false, mode: 'create' });
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [kbDeleteTarget, setKbDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
@@ -66,35 +67,103 @@ export default function App() {
|
||||
loadSites();
|
||||
}, [loadSites]);
|
||||
|
||||
// Handle map click -> open form with coordinates
|
||||
// Handle map click -> open modal with coordinates
|
||||
const handleMapClick = useCallback(
|
||||
(lat: number, lon: number) => {
|
||||
setPendingLocation({ lat, lon });
|
||||
setEditSite(null);
|
||||
setShowForm(true);
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
mode: 'create',
|
||||
initialData: { lat, lon },
|
||||
});
|
||||
setPlacingMode(false);
|
||||
},
|
||||
[setPlacingMode]
|
||||
);
|
||||
|
||||
const handleEditSite = useCallback((site: Site) => {
|
||||
setEditSite(site);
|
||||
setPendingLocation(null);
|
||||
setShowForm(true);
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
mode: 'edit',
|
||||
editSiteId: site.id,
|
||||
initialData: {
|
||||
name: site.name,
|
||||
lat: site.lat,
|
||||
lon: site.lon,
|
||||
power: site.power,
|
||||
gain: site.gain,
|
||||
frequency: site.frequency,
|
||||
height: site.height,
|
||||
antennaType: site.antennaType,
|
||||
azimuth: site.azimuth ?? 0,
|
||||
beamwidth: site.beamwidth ?? 65,
|
||||
notes: site.notes ?? '',
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddManual = useCallback(() => {
|
||||
setEditSite(null);
|
||||
setPendingLocation(null);
|
||||
setShowForm(true);
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
mode: 'create',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCloseForm = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditSite(null);
|
||||
setPendingLocation(null);
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
}, []);
|
||||
|
||||
// Modal save handler
|
||||
const handleModalSave = useCallback(async (data: SiteFormValues) => {
|
||||
const addSite = useSitesStore.getState().addSite;
|
||||
const updateSite = useSitesStore.getState().updateSite;
|
||||
|
||||
if (modalState.mode === 'edit' && modalState.editSiteId) {
|
||||
await updateSite(modalState.editSiteId, {
|
||||
name: data.name,
|
||||
lat: data.lat,
|
||||
lon: data.lon,
|
||||
power: data.power,
|
||||
gain: data.gain,
|
||||
frequency: data.frequency,
|
||||
height: data.height,
|
||||
antennaType: data.antennaType,
|
||||
azimuth: data.antennaType === 'sector' ? data.azimuth : undefined,
|
||||
beamwidth: data.antennaType === 'sector' ? data.beamwidth : undefined,
|
||||
notes: data.notes || undefined,
|
||||
});
|
||||
addToast('Site updated', 'success');
|
||||
} else {
|
||||
await addSite({
|
||||
name: data.name,
|
||||
lat: data.lat,
|
||||
lon: data.lon,
|
||||
power: data.power,
|
||||
gain: data.gain,
|
||||
frequency: data.frequency,
|
||||
height: data.height,
|
||||
antennaType: data.antennaType,
|
||||
azimuth: data.antennaType === 'sector' ? data.azimuth : undefined,
|
||||
beamwidth: data.antennaType === 'sector' ? data.beamwidth : undefined,
|
||||
color: '',
|
||||
visible: true,
|
||||
notes: data.notes || undefined,
|
||||
});
|
||||
addToast('Site added', 'success');
|
||||
}
|
||||
handleCloseModal();
|
||||
}, [modalState.mode, modalState.editSiteId, addToast, handleCloseModal]);
|
||||
|
||||
const handleModalDelete = useCallback(async () => {
|
||||
if (modalState.editSiteId) {
|
||||
const site = sites.find((s) => s.id === modalState.editSiteId);
|
||||
await useSitesStore.getState().deleteSite(modalState.editSiteId);
|
||||
if (site) {
|
||||
addToast(`"${site.name}" deleted`, 'info');
|
||||
}
|
||||
handleCloseModal();
|
||||
}
|
||||
}, [modalState.editSiteId, sites, addToast, handleCloseModal]);
|
||||
|
||||
// Keyboard delete confirmation handler
|
||||
const handleKbDeleteConfirmed = useCallback(async () => {
|
||||
if (!kbDeleteTarget) return;
|
||||
@@ -222,10 +291,16 @@ export default function App() {
|
||||
}
|
||||
}, [setIsCalculating, setResult, addToast]);
|
||||
|
||||
// Save site from modal and trigger calculation
|
||||
const handleModalSaveAndCalculate = useCallback(async (data: SiteFormValues) => {
|
||||
await handleModalSave(data);
|
||||
setTimeout(() => handleCalculate(), 50);
|
||||
}, [handleModalSave, handleCalculate]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
onCalculate: handleCalculate,
|
||||
onCloseForm: handleCloseForm,
|
||||
onCloseForm: handleCloseModal,
|
||||
onShowShortcuts: useCallback(() => setShowShortcuts(true), []),
|
||||
onDeleteRequest: useCallback((id: string, name: string) => {
|
||||
setKbDeleteTarget({ id, name });
|
||||
@@ -415,17 +490,6 @@ export default function App() {
|
||||
{/* Site list */}
|
||||
<SiteList onEditSite={handleEditSite} onAddSite={handleAddManual} />
|
||||
|
||||
{/* Site form */}
|
||||
{showForm && (
|
||||
<SiteForm
|
||||
editSite={editSite}
|
||||
pendingLocation={pendingLocation}
|
||||
onClose={handleCloseForm}
|
||||
onClearPending={() => setPendingLocation(null)}
|
||||
onCalculate={handleCalculate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Coverage settings */}
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||
@@ -590,6 +654,17 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Site configuration modal */}
|
||||
<SiteConfigModal
|
||||
isOpen={modalState.isOpen}
|
||||
mode={modalState.mode}
|
||||
initialData={modalState.initialData}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleModalSave}
|
||||
onSaveAndCalculate={handleModalSaveAndCalculate}
|
||||
onDelete={modalState.mode === 'edit' ? handleModalDelete : undefined}
|
||||
/>
|
||||
|
||||
{/* Keyboard delete confirmation dialog */}
|
||||
{kbDeleteTarget && (
|
||||
<ConfirmDialog
|
||||
|
||||
42
frontend/src/components/modals/ModalBackdrop.tsx
Normal file
42
frontend/src/components/modals/ModalBackdrop.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
507
frontend/src/components/modals/SiteConfigModal.tsx
Normal file
507
frontend/src/components/modals/SiteConfigModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/components/modals/index.ts
Normal file
3
frontend/src/components/modals/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
|
||||
@@ -40,6 +40,11 @@ interface SitesState {
|
||||
batchSetHeight: (height: number) => Promise<void>;
|
||||
batchAdjustAzimuth: (delta: number) => Promise<void>;
|
||||
batchSetAzimuth: (azimuth: number) => Promise<void>;
|
||||
batchAdjustPower: (delta: number) => Promise<void>;
|
||||
batchSetPower: (power: number) => Promise<void>;
|
||||
batchAdjustTilt: (delta: number) => Promise<void>;
|
||||
batchSetTilt: (tilt: number) => Promise<void>;
|
||||
batchSetFrequency: (frequency: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
@@ -336,4 +341,148 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
set({ sites: updatedSites });
|
||||
useCoverageStore.getState().clearCoverage();
|
||||
},
|
||||
|
||||
batchAdjustPower: async (delta: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const now = new Date();
|
||||
|
||||
const updatedSites = sites.map((site) => {
|
||||
if (!selectedSet.has(site.id)) return site;
|
||||
return {
|
||||
...site,
|
||||
power: Math.max(10, Math.min(50, site.power + delta)),
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
|
||||
for (const site of toUpdate) {
|
||||
await db.sites.put({
|
||||
id: site.id,
|
||||
data: JSON.stringify(site),
|
||||
createdAt: site.createdAt.getTime(),
|
||||
updatedAt: now.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
set({ sites: updatedSites });
|
||||
useCoverageStore.getState().clearCoverage();
|
||||
},
|
||||
|
||||
batchSetPower: async (power: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const clamped = Math.max(10, Math.min(50, power));
|
||||
const now = new Date();
|
||||
|
||||
const updatedSites = sites.map((site) => {
|
||||
if (!selectedSet.has(site.id)) return site;
|
||||
return {
|
||||
...site,
|
||||
power: clamped,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
|
||||
for (const site of toUpdate) {
|
||||
await db.sites.put({
|
||||
id: site.id,
|
||||
data: JSON.stringify(site),
|
||||
createdAt: site.createdAt.getTime(),
|
||||
updatedAt: now.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
set({ sites: updatedSites });
|
||||
useCoverageStore.getState().clearCoverage();
|
||||
},
|
||||
|
||||
batchAdjustTilt: async (delta: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const now = new Date();
|
||||
|
||||
const updatedSites = sites.map((site) => {
|
||||
if (!selectedSet.has(site.id)) return site;
|
||||
const current = site.tilt ?? 0;
|
||||
return {
|
||||
...site,
|
||||
tilt: Math.max(-90, Math.min(90, current + delta)),
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
|
||||
for (const site of toUpdate) {
|
||||
await db.sites.put({
|
||||
id: site.id,
|
||||
data: JSON.stringify(site),
|
||||
createdAt: site.createdAt.getTime(),
|
||||
updatedAt: now.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
set({ sites: updatedSites });
|
||||
useCoverageStore.getState().clearCoverage();
|
||||
},
|
||||
|
||||
batchSetTilt: async (tilt: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const clamped = Math.max(-90, Math.min(90, tilt));
|
||||
const now = new Date();
|
||||
|
||||
const updatedSites = sites.map((site) => {
|
||||
if (!selectedSet.has(site.id)) return site;
|
||||
return {
|
||||
...site,
|
||||
tilt: clamped,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
|
||||
for (const site of toUpdate) {
|
||||
await db.sites.put({
|
||||
id: site.id,
|
||||
data: JSON.stringify(site),
|
||||
createdAt: site.createdAt.getTime(),
|
||||
updatedAt: now.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
set({ sites: updatedSites });
|
||||
useCoverageStore.getState().clearCoverage();
|
||||
},
|
||||
|
||||
batchSetFrequency: async (frequency: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const clamped = Math.max(100, Math.min(6000, frequency));
|
||||
const now = new Date();
|
||||
|
||||
const updatedSites = sites.map((site) => {
|
||||
if (!selectedSet.has(site.id)) return site;
|
||||
return {
|
||||
...site,
|
||||
frequency: clamped,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
|
||||
for (const site of toUpdate) {
|
||||
await db.sites.put({
|
||||
id: site.id,
|
||||
data: JSON.stringify(site),
|
||||
createdAt: site.createdAt.getTime(),
|
||||
updatedAt: now.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
set({ sites: updatedSites });
|
||||
useCoverageStore.getState().clearCoverage();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Site {
|
||||
antennaType: 'omni' | 'sector';
|
||||
azimuth?: number; // degrees (0-360), sector only
|
||||
beamwidth?: number; // degrees (30-120), sector only
|
||||
tilt?: number; // degrees (-90 to +90), antenna downtilt
|
||||
color: string; // hex color for map marker
|
||||
visible: boolean;
|
||||
notes?: string;
|
||||
|
||||
Reference in New Issue
Block a user