@mytec: 2nd iteration implemented for tests
This commit is contained in:
96
frontend/src/components/panels/BatchEdit.tsx
Normal file
96
frontend/src/components/panels/BatchEdit.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
|
||||
export default function BatchEdit() {
|
||||
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
|
||||
const batchUpdateHeight = useSitesStore((s) => s.batchUpdateHeight);
|
||||
const batchSetHeight = useSitesStore((s) => s.batchSetHeight);
|
||||
const clearSelection = useSitesStore((s) => s.clearSelection);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const [customHeight, setCustomHeight] = useState('');
|
||||
|
||||
if (selectedSiteIds.length === 0) return null;
|
||||
|
||||
const handleAdjustHeight = async (delta: number) => {
|
||||
await batchUpdateHeight(delta);
|
||||
addToast(
|
||||
`Adjusted ${selectedSiteIds.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`,
|
||||
'success'
|
||||
);
|
||||
};
|
||||
|
||||
const handleSetHeight = async () => {
|
||||
const height = parseInt(customHeight, 10);
|
||||
if (isNaN(height) || height < 1 || height > 100) {
|
||||
addToast('Height must be between 1-100m', 'error');
|
||||
return;
|
||||
}
|
||||
await batchSetHeight(height);
|
||||
addToast(`Set ${selectedSiteIds.length} site(s) to ${height}m`, 'success');
|
||||
setCustomHeight('');
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-200">
|
||||
Batch Edit ({selectedSiteIds.length} selected)
|
||||
</h3>
|
||||
<Button onClick={clearSelection} size="sm" variant="ghost">
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick height adjustments */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
|
||||
Adjust Height:
|
||||
</label>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(10)}>
|
||||
+10m
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(5)}>
|
||||
+5m
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(-5)}>
|
||||
-5m
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(-10)}>
|
||||
-10m
|
||||
</Button>
|
||||
</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:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={customHeight}
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSetHeight}
|
||||
disabled={!customHeight}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
import BatchEdit from './BatchEdit.tsx';
|
||||
|
||||
interface SiteListProps {
|
||||
onEditSite: (site: Site) => void;
|
||||
@@ -15,6 +16,10 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||
const togglePlacingMode = useSitesStore((s) => s.togglePlacingMode);
|
||||
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
|
||||
const toggleSiteSelection = useSitesStore((s) => s.toggleSiteSelection);
|
||||
const selectAllSites = useSitesStore((s) => s.selectAllSites);
|
||||
const clearSelection = useSitesStore((s) => s.clearSelection);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
@@ -22,8 +27,11 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
addToast(`"${name}" deleted`, 'info');
|
||||
};
|
||||
|
||||
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-dark-border flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||
Sites ({sites.length})
|
||||
@@ -42,63 +50,105 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select All row (only when sites exist) */}
|
||||
{sites.length > 0 && (
|
||||
<div className="px-4 py-1.5 border-b border-gray-100 dark:border-dark-border flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-xs text-gray-500 dark:text-dark-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => (allSelected ? clearSelection() : selectAllSites())}
|
||||
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600"
|
||||
/>
|
||||
{allSelected ? 'Deselect All' : 'Select All'}
|
||||
</label>
|
||||
{selectedSiteIds.length > 0 && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||||
{selectedSiteIds.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch edit panel (appears when sites are selected) */}
|
||||
{selectedSiteIds.length > 0 && (
|
||||
<div className="px-3 pt-3">
|
||||
<BatchEdit />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sites list */}
|
||||
{sites.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-400 dark:text-dark-muted">
|
||||
No sites yet. Click on the map or use "+ Manual" to add one.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-60 overflow-y-auto">
|
||||
{sites.map((site) => (
|
||||
<div
|
||||
key={site.id}
|
||||
className={`px-4 py-2.5 flex items-center gap-3 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' : ''}`}
|
||||
onClick={() => selectSite(site.id)}
|
||||
>
|
||||
{/* Color indicator */}
|
||||
{sites.map((site) => {
|
||||
const isBatchSelected = selectedSiteIds.includes(site.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
|
||||
style={{ backgroundColor: site.color }}
|
||||
/>
|
||||
key={site.id}
|
||||
className={`px-4 py-2.5 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' : ''}
|
||||
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
|
||||
onClick={() => selectSite(site.id)}
|
||||
>
|
||||
{/* 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"
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate">
|
||||
{site.name}
|
||||
{/* 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 }}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate">
|
||||
{site.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{site.frequency} MHz · {site.power} dBm ·{' '}
|
||||
{site.antennaType === 'omni'
|
||||
? 'Omni'
|
||||
: `Sector ${site.azimuth ?? 0}°`}
|
||||
{' '}· {site.height}m
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{site.frequency} MHz · {site.power} dBm ·{' '}
|
||||
{site.antennaType === 'omni'
|
||||
? 'Omni'
|
||||
: `Sector ${site.azimuth ?? 0}°`}
|
||||
{' '}· {site.height}m
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSite(site);
|
||||
}}
|
||||
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-w-[44px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(site.id, site.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-[32px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSite(site);
|
||||
}}
|
||||
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-w-[44px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(site.id, site.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-[32px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user