diff --git a/frontend/src/components/layout-editor/RackVisualizer.tsx b/frontend/src/components/layout-editor/RackVisualizer.tsx
index 27b6934..5524f46 100644
--- a/frontend/src/components/layout-editor/RackVisualizer.tsx
+++ b/frontend/src/components/layout-editor/RackVisualizer.tsx
@@ -3,7 +3,7 @@
* Intuitive grid-based layout with drag-drop targets
*/
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import type { LayoutPlantType } from '../../lib/layoutApi';
@@ -167,29 +167,54 @@ interface PlantSlotProps {
}
function PlantSlot({ slot, isHighlighted, onClick, onDrop }: PlantSlotProps) {
+ const [isDragOver, setIsDragOver] = useState(false);
const isOccupied = slot.status === 'OCCUPIED' && slot.plantType;
const isDamaged = slot.status === 'DAMAGED';
const isReserved = slot.status === 'RESERVED';
+ const canDrop = slot.status === 'EMPTY';
const handleDragOver = (e: React.DragEvent) => {
- if (slot.status === 'EMPTY') {
+ if (canDrop) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
};
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+ if (canDrop) setIsDragOver(true);
+ };
+
+ const handleDragLeave = () => {
+ setIsDragOver(false);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ setIsDragOver(false);
+ onDrop(slot, e);
+ };
+
return (
onClick?.(slot)}
onDragOver={handleDragOver}
- onDrop={(e) => onDrop(slot, e)}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
className={cn(
- 'rounded flex items-center justify-center cursor-pointer transition-all',
- 'border-2 border-dashed',
- slot.status === 'EMPTY' && 'border-slate-600 bg-slate-800/30 hover:border-emerald-500 hover:bg-emerald-900/20',
+ 'rounded flex items-center justify-center cursor-pointer transition-all duration-150',
+ 'border-2',
+ // Empty slot styles
+ slot.status === 'EMPTY' && !isDragOver && 'border-dashed border-slate-600 bg-slate-800/30 hover:border-slate-500',
+ // Drag over state - highlight
+ isDragOver && 'border-solid border-emerald-400 bg-emerald-500/30 scale-105 shadow-lg shadow-emerald-500/20',
+ // Occupied
isOccupied && 'border-solid',
+ // Damaged
isDamaged && 'border-red-500 bg-red-900/30',
+ // Reserved
isReserved && 'border-yellow-500 bg-yellow-900/20 border-solid',
+ // Highlighted (searched)
isHighlighted && 'ring-2 ring-emerald-400 ring-offset-1 ring-offset-slate-900'
)}
style={{
@@ -207,6 +232,9 @@ function PlantSlot({ slot, isHighlighted, onClick, onDrop }: PlantSlotProps) {
)}
{isDamaged && ✕}
{isReserved && ◆}
+ {isDragOver && !isOccupied && (
+ +
+ )}
);
}
diff --git a/frontend/src/pages/LayoutEditorPage.tsx b/frontend/src/pages/LayoutEditorPage.tsx
index 914d004..62389a1 100644
--- a/frontend/src/pages/LayoutEditorPage.tsx
+++ b/frontend/src/pages/LayoutEditorPage.tsx
@@ -1,18 +1,27 @@
/**
* Layout Editor Page - 2D facility layout management
+ * Now with persistent floor dropdown and proper app shell integration
*/
import { useParams, useNavigate } from 'react-router-dom';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
import { LayoutEditor } from '../components/layout-editor';
-import { layoutApi, LayoutProperty } from '../lib/layoutApi';
+import { layoutApi, LayoutProperty, LayoutFloor } from '../lib/layoutApi';
+
+interface FloorOption {
+ id: string;
+ name: string;
+ propertyName: string;
+ buildingName: string;
+}
export function LayoutEditorPage() {
const { floorId } = useParams<{ floorId: string }>();
const navigate = useNavigate();
const [properties, setProperties] = useState([]);
const [selectedFloorId, setSelectedFloorId] = useState(floorId);
- const [loading, setLoading] = useState(!floorId);
+ const [loading, setLoading] = useState(true);
+ const [dropdownOpen, setDropdownOpen] = useState(false);
// Load properties for floor selector
useEffect(() => {
@@ -38,60 +47,131 @@ export function LayoutEditorPage() {
load();
}, [floorId, navigate]);
+ // Flatten floors for dropdown
+ const floorOptions = useMemo(() => {
+ const options: FloorOption[] = [];
+ for (const prop of properties) {
+ for (const building of prop.buildings || []) {
+ for (const floor of building.floors || []) {
+ options.push({
+ id: floor.id,
+ name: floor.name,
+ propertyName: prop.name,
+ buildingName: building.name,
+ });
+ }
+ }
+ }
+ return options;
+ }, [properties]);
+
+ const selectedFloor = floorOptions.find(f => f.id === selectedFloorId);
+
+ const handleFloorChange = (newFloorId: string) => {
+ setSelectedFloorId(newFloorId);
+ setDropdownOpen(false);
+ navigate(`/layout-editor/${newFloorId}`);
+ };
+
if (loading) {
return (
-
-
Loading...
+
);
}
- // Floor selector if no floor specified
- if (!selectedFloorId) {
+ // No floors available
+ if (floorOptions.length === 0) {
return (
-
-
Select a Floor
-
- {properties.length === 0 ? (
-
-
No facility layouts found.
-
Create a property first to get started.
-
- ) : (
-
- {properties.map(prop => (
-
-
{prop.name}
- {prop.buildings?.map(building => (
-
-
{building.name}
-
- {building.floors?.map(floor => (
-
- ))}
-
-
- ))}
-
- ))}
-
- )}
+
+
+
No Facility Layouts
+
Create a property with a floor first to use the layout editor.
);
}
return (
-
-
+
+ {/* Header with Floor Selector */}
+
+
+
+
Layout Editor
+
+ {/* Floor Dropdown */}
+
+
+
+ {dropdownOpen && (
+ <>
+
setDropdownOpen(false)} />
+
+ {properties.map(prop => (
+
+
+ {prop.name}
+
+ {prop.buildings?.map(building => (
+
+
+ {building.name}
+
+ {building.floors?.map(floor => (
+
+ ))}
+
+ ))}
+
+ ))}
+
+ >
+ )}
+
+
+
+ {/* Quick stats */}
+ {selectedFloor && (
+
+ {selectedFloor.propertyName} — {selectedFloor.buildingName}
+
+ )}
+
+
+
+ {/* Editor Canvas */}
+
+ {selectedFloorId ? (
+
+ ) : (
+
+ Select a floor to start editing
+
+ )}
+
);
}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 1f737c5..8e43e3e 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -193,6 +193,11 @@ export const router = createBrowserRouter([
path: 'metrc',
element:
}>
,
},
+ // 2D Layout Editor (inside app shell)
+ {
+ path: 'layout-editor/:floorId?',
+ element:
}>
,
+ },
// 404 catch-all
{
path: '*',
@@ -232,21 +237,5 @@ export const router = createBrowserRouter([
),
errorElement:
,
},
- // 2D Layout Editor (Rackula-inspired)
- {
- path: '/layout-editor/:floorId?',
- element: (
-
-
-
-
- }>
-
-
-
- ),
- errorElement:
,
- },
]);