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: , - }, ]);