@mytec: iter10.5 ready for testing
This commit is contained in:
@@ -11,6 +11,38 @@ interface SiteListProps {
|
||||
onAddSite: () => void;
|
||||
}
|
||||
|
||||
/** Greek letter prefix for sector visual identifiers */
|
||||
const SECTOR_GREEK: Record<string, string> = {
|
||||
Alpha: '\u03B1',
|
||||
Beta: '\u03B2',
|
||||
Gamma: '\u03B3',
|
||||
Delta: '\u03B4',
|
||||
Epsilon: '\u03B5',
|
||||
Zeta: '\u03B6',
|
||||
Eta: '\u03B7',
|
||||
Theta: '\u03B8',
|
||||
};
|
||||
|
||||
/** Get greek letter for a sector name, falling back to bullet */
|
||||
function getGreekLetter(sectorName: string): string {
|
||||
// Extract sector suffix — e.g. "Station-1-Alpha" → "Alpha"
|
||||
const match = sectorName.match(/-(Alpha|Beta|Gamma|Delta|Epsilon|Zeta|Eta|Theta)$/i);
|
||||
if (match) {
|
||||
const key = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
|
||||
return SECTOR_GREEK[key] || '\u2022';
|
||||
}
|
||||
return '\u2022';
|
||||
}
|
||||
|
||||
/** Get short sector display name (without site prefix) */
|
||||
function getSectorDisplayName(fullName: string, baseName: string): string {
|
||||
// "Station-1-Alpha" with base "Station-1" → "Alpha"
|
||||
if (fullName.startsWith(baseName + '-')) {
|
||||
return fullName.slice(baseName.length + 1);
|
||||
}
|
||||
return fullName;
|
||||
}
|
||||
|
||||
/** Group co-located sites (within ~10m = 0.0001°) */
|
||||
function groupSites(sites: Site[]): { key: string; label: string; sites: Site[] }[] {
|
||||
const groups = new Map<string, Site[]>();
|
||||
@@ -53,6 +85,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const [flashIds, setFlashIds] = useState<Set<string>>(new Set());
|
||||
const [expandedSites, setExpandedSites] = useState<Set<string>>(new Set(['__all__']));
|
||||
|
||||
const triggerFlash = useCallback((ids: string[]) => {
|
||||
setFlashIds(new Set(ids));
|
||||
@@ -103,6 +136,27 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
}
|
||||
}, [deleteTarget, sites, deleteSite, addToast]);
|
||||
|
||||
const toggleExpand = useCallback((groupKey: string) => {
|
||||
setExpandedSites((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupKey)) {
|
||||
next.delete(groupKey);
|
||||
} else {
|
||||
next.add(groupKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Start with all groups expanded
|
||||
const isExpanded = useCallback(
|
||||
(groupKey: string) => {
|
||||
// Default all expanded (if __all__ sentinel present or group key present)
|
||||
return expandedSites.has('__all__') || expandedSites.has(groupKey);
|
||||
},
|
||||
[expandedSites]
|
||||
);
|
||||
|
||||
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
|
||||
|
||||
// Group co-located sites
|
||||
@@ -112,7 +166,8 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
// Count unique locations
|
||||
const locationCount = siteGroups.length;
|
||||
|
||||
const renderSiteRow = (site: Site, isGrouped: boolean) => {
|
||||
/** Render a single standalone site (not grouped) */
|
||||
const renderStandaloneSite = (site: Site) => {
|
||||
const isBatchSelected = selectedSiteIds.includes(site.id);
|
||||
const isFlashing = flashIds.has(site.id);
|
||||
|
||||
@@ -121,10 +176,9 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
key={site.id}
|
||||
className={`px-4 py-2 flex items-center gap-2.5 cursor-pointer
|
||||
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
|
||||
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
|
||||
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20 border-l-[3px] border-l-purple-500' : 'border-l-[3px] border-l-transparent'}
|
||||
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}
|
||||
${isFlashing ? 'flash-update' : ''}
|
||||
${isGrouped ? 'pl-8' : ''}`}
|
||||
${isFlashing ? 'flash-update' : ''}`}
|
||||
onClick={() => selectSite(site.id)}
|
||||
>
|
||||
{/* Batch checkbox */}
|
||||
@@ -136,29 +190,22 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Color indicator */}
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
|
||||
style={{ backgroundColor: site.color }}
|
||||
/>
|
||||
{/* Site icon */}
|
||||
<span className="text-base flex-shrink-0" title="Site">
|
||||
{'\uD83D\uDCCD'}
|
||||
</span>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate flex items-center gap-1.5">
|
||||
<div className="text-sm font-semibold text-gray-800 dark:text-dark-text truncate">
|
||||
{site.name}
|
||||
{/* Azimuth badge for grouped sectors */}
|
||||
{isGrouped && site.antennaType === 'sector' && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{site.azimuth ?? 0}°
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{site.frequency} MHz · {site.power} dBm ·{' '}
|
||||
{site.frequency} MHz {'\u00B7'} {site.power} dBm {'\u00B7'}{' '}
|
||||
{site.antennaType === 'omni'
|
||||
? 'Omni'
|
||||
: `Sector ${site.azimuth ?? 0}°`}
|
||||
{' '}· {site.height}m
|
||||
: `Sector ${site.azimuth ?? 0}\u00B0`}
|
||||
{' '}{'\u00B7'} {site.height}m
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,10 +225,10 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await cloneSector(site.id);
|
||||
addToast(`Added sector to "${site.name}" (+120°)`, 'success');
|
||||
addToast(`Added sector to "${site.name}" (+120\u00B0)`, 'success');
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded min-h-[32px] flex items-center justify-center"
|
||||
title="Add sector at same location (+120° azimuth)"
|
||||
title="Add sector at same location (+120\u00B0 azimuth)"
|
||||
>
|
||||
+ Sector
|
||||
</button>
|
||||
@@ -193,7 +240,164 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||
title="Delete site"
|
||||
>
|
||||
×
|
||||
{'\u00D7'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Render a grouped site header (for multi-sector groups) */
|
||||
const renderGroupHeader = (group: { key: string; label: string; sites: Site[] }) => {
|
||||
const expanded = isExpanded(group.key);
|
||||
const firstSite = group.sites[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-2 bg-gray-50 dark:bg-dark-bg flex items-center gap-2.5 cursor-pointer
|
||||
hover:bg-gray-100 dark:hover:bg-dark-border/30 transition-colors"
|
||||
onClick={() => toggleExpand(group.key)}
|
||||
>
|
||||
{/* Expand/collapse chevron */}
|
||||
<span
|
||||
className={`text-xs text-gray-400 dark:text-dark-muted transition-transform flex-shrink-0 ${
|
||||
expanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
|
||||
{/* Site icon */}
|
||||
<span className="text-base flex-shrink-0" title="Site group">
|
||||
{'\uD83D\uDCCD'}
|
||||
</span>
|
||||
|
||||
{/* Group name + sector count */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-dark-text truncate">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 font-medium flex-shrink-0">
|
||||
{group.sites.length} sector{group.sites.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Group-level actions */}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSite(firstSite);
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded min-h-[28px] flex items-center justify-center"
|
||||
title="Edit site"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await cloneSector(firstSite.id);
|
||||
addToast(`Added sector to "${group.label}" (+120\u00B0)`, 'success');
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded min-h-[28px] flex items-center justify-center"
|
||||
title="Add sector at same location (+120\u00B0 azimuth)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Delete first site in group (user can delete others individually)
|
||||
setDeleteTarget({ id: firstSite.id, name: firstSite.name });
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[28px] min-h-[28px] flex items-center justify-center"
|
||||
title="Delete site"
|
||||
>
|
||||
{'\u00D7'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Render a sector row within a group (indented, with tree connector) */
|
||||
const renderSectorRow = (
|
||||
site: Site,
|
||||
baseName: string,
|
||||
index: number,
|
||||
total: number
|
||||
) => {
|
||||
const isBatchSelected = selectedSiteIds.includes(site.id);
|
||||
const isFlashing = flashIds.has(site.id);
|
||||
const isLast = index === total - 1;
|
||||
const greekLetter = getGreekLetter(site.name);
|
||||
const shortName = getSectorDisplayName(site.name, baseName);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={site.id}
|
||||
className={`flex items-center cursor-pointer
|
||||
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
|
||||
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
|
||||
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}
|
||||
${isFlashing ? 'flash-update' : ''}`}
|
||||
onClick={() => selectSite(site.id)}
|
||||
>
|
||||
{/* Tree connector area */}
|
||||
<div className="flex items-center pl-7 pr-1 self-stretch flex-shrink-0">
|
||||
{/* Batch checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isBatchSelected}
|
||||
onChange={() => toggleSiteSelection(site.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0 mr-2"
|
||||
/>
|
||||
{/* Tree connector characters */}
|
||||
<span className="text-xs text-gray-300 dark:text-dark-muted font-mono mr-1.5 flex-shrink-0 w-5 text-right">
|
||||
{isLast ? '\u2514\u2500' : '\u251C\u2500'}
|
||||
</span>
|
||||
{/* Greek letter */}
|
||||
<span
|
||||
className="w-5 text-center text-sm font-semibold text-purple-500 dark:text-purple-400 flex-shrink-0"
|
||||
title={shortName}
|
||||
>
|
||||
{greekLetter}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sector info */}
|
||||
<div className="flex-1 min-w-0 py-1.5 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-dark-text truncate">
|
||||
{shortName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-dark-muted whitespace-nowrap">
|
||||
{site.azimuth ?? 0}{'\u00B0'} {'\u00B7'} {site.frequency}MHz {'\u00B7'} {site.power}dBm
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sector actions */}
|
||||
<div className="flex gap-1 flex-shrink-0 pr-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSite(site);
|
||||
}}
|
||||
className="px-1.5 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded min-h-[28px] flex items-center justify-center"
|
||||
title="Edit sector"
|
||||
>
|
||||
{'\u270E'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({ id: site.id, name: site.name });
|
||||
}}
|
||||
className="px-1.5 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-h-[28px] flex items-center justify-center"
|
||||
title="Delete sector"
|
||||
>
|
||||
{'\u00D7'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +412,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
Sites ({sites.length})
|
||||
{hasGroups && (
|
||||
<span className="font-normal text-gray-400 dark:text-dark-muted ml-1">
|
||||
· {locationCount} location{locationCount !== 1 ? 's' : ''}
|
||||
{'\u00B7'} {locationCount} location{locationCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
@@ -262,24 +466,19 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-72 overflow-y-auto">
|
||||
{siteGroups.map((group) => {
|
||||
if (group.sites.length === 1) {
|
||||
// Single site — render normally
|
||||
return renderSiteRow(group.sites[0], false);
|
||||
// Single site — render standalone
|
||||
return renderStandaloneSite(group.sites[0]);
|
||||
}
|
||||
|
||||
// Multi-sector group
|
||||
// Multi-sector group with hierarchy
|
||||
const expanded = isExpanded(group.key);
|
||||
return (
|
||||
<div key={group.key}>
|
||||
{/* Group header */}
|
||||
<div className="px-4 py-1.5 bg-gray-50 dark:bg-dark-bg flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-dark-muted">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 font-medium">
|
||||
{group.sites.length} sectors
|
||||
</span>
|
||||
</div>
|
||||
{/* Grouped site rows */}
|
||||
{group.sites.map((site) => renderSiteRow(site, true))}
|
||||
{renderGroupHeader(group)}
|
||||
{expanded &&
|
||||
group.sites.map((site, i) =>
|
||||
renderSectorRow(site, group.label, i, group.sites.length)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useRef, useState, useEffect } from 'react';
|
||||
|
||||
interface NumberInputProps {
|
||||
label: string;
|
||||
@@ -25,6 +25,18 @@ export default function NumberInput({
|
||||
}: NumberInputProps) {
|
||||
const holdTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Local string state for text input — allows free typing without immediate validation
|
||||
const [localValue, setLocalValue] = useState(String(value));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Sync local state when external value changes (slider, arrows, external)
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
const display = Number.isInteger(step) ? String(value) : String(Number(value.toFixed(1)));
|
||||
setLocalValue(display);
|
||||
}
|
||||
}, [value, step, isFocused]);
|
||||
|
||||
const clamp = useCallback(
|
||||
(v: number) => Math.max(min, Math.min(max, v)),
|
||||
[min, max]
|
||||
@@ -54,16 +66,50 @@ export default function NumberInput({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Allow free typing — no validation on change
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '' || raw === '-') return;
|
||||
const v = Number(raw);
|
||||
if (!isNaN(v)) {
|
||||
onChange(clamp(v));
|
||||
setLocalValue(e.target.value);
|
||||
};
|
||||
|
||||
// Validate and commit value on blur or Enter
|
||||
const commitValue = useCallback(() => {
|
||||
const parsed = parseFloat(localValue);
|
||||
if (isNaN(parsed) || localValue.trim() === '') {
|
||||
// Reset to current valid value
|
||||
const display = Number.isInteger(step) ? String(value) : String(Number(value.toFixed(1)));
|
||||
setLocalValue(display);
|
||||
return;
|
||||
}
|
||||
const clamped = clamp(parsed);
|
||||
const display = Number.isInteger(step) ? String(clamped) : String(Number(clamped.toFixed(1)));
|
||||
setLocalValue(display);
|
||||
if (clamped !== value) {
|
||||
onChange(clamped);
|
||||
}
|
||||
}, [localValue, value, step, clamp, onChange]);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
commitValue();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
commitValue();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
const display = Number.isInteger(step) ? String(value) : String(Number(value.toFixed(1)));
|
||||
setLocalValue(display);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
};
|
||||
|
||||
// Format value for display — avoid long decimals
|
||||
// Format value for display in the badge (always shows committed value)
|
||||
const displayValue = Number.isInteger(step) ? value : Number(value.toFixed(1));
|
||||
|
||||
return (
|
||||
@@ -81,12 +127,13 @@ export default function NumberInput({
|
||||
{/* Text input + arrow buttons */}
|
||||
<div className="flex items-stretch gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={displayValue}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={localValue}
|
||||
onChange={handleTextChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 px-2 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text
|
||||
rounded-md text-sm text-center font-mono
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
|
||||
Reference in New Issue
Block a user