fix: Layout editor UX improvements
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Move route inside app shell (now has navigation)
- Add persistent floor dropdown selector in header
- Add drag-over visual feedback (scale, highlight, + icon)
- Fix router syntax error
This commit is contained in:
fullsizemalt 2026-01-01 14:50:00 -08:00
parent f534c9818e
commit fe5c6decc2
3 changed files with 164 additions and 67 deletions

View file

@ -3,7 +3,7 @@
* Intuitive grid-based layout with drag-drop targets * Intuitive grid-based layout with drag-drop targets
*/ */
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import type { LayoutPlantType } from '../../lib/layoutApi'; import type { LayoutPlantType } from '../../lib/layoutApi';
@ -167,29 +167,54 @@ interface PlantSlotProps {
} }
function PlantSlot({ slot, isHighlighted, onClick, onDrop }: PlantSlotProps) { function PlantSlot({ slot, isHighlighted, onClick, onDrop }: PlantSlotProps) {
const [isDragOver, setIsDragOver] = useState(false);
const isOccupied = slot.status === 'OCCUPIED' && slot.plantType; const isOccupied = slot.status === 'OCCUPIED' && slot.plantType;
const isDamaged = slot.status === 'DAMAGED'; const isDamaged = slot.status === 'DAMAGED';
const isReserved = slot.status === 'RESERVED'; const isReserved = slot.status === 'RESERVED';
const canDrop = slot.status === 'EMPTY';
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
if (slot.status === 'EMPTY') { if (canDrop) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; 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 ( return (
<div <div
onClick={() => onClick?.(slot)} onClick={() => onClick?.(slot)}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => onDrop(slot, e)} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn( className={cn(
'rounded flex items-center justify-center cursor-pointer transition-all', 'rounded flex items-center justify-center cursor-pointer transition-all duration-150',
'border-2 border-dashed', 'border-2',
slot.status === 'EMPTY' && 'border-slate-600 bg-slate-800/30 hover:border-emerald-500 hover:bg-emerald-900/20', // 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', isOccupied && 'border-solid',
// Damaged
isDamaged && 'border-red-500 bg-red-900/30', isDamaged && 'border-red-500 bg-red-900/30',
// Reserved
isReserved && 'border-yellow-500 bg-yellow-900/20 border-solid', 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' isHighlighted && 'ring-2 ring-emerald-400 ring-offset-1 ring-offset-slate-900'
)} )}
style={{ style={{
@ -207,6 +232,9 @@ function PlantSlot({ slot, isHighlighted, onClick, onDrop }: PlantSlotProps) {
)} )}
{isDamaged && <span className="text-red-400"></span>} {isDamaged && <span className="text-red-400"></span>}
{isReserved && <span className="text-yellow-400 text-xs"></span>} {isReserved && <span className="text-yellow-400 text-xs"></span>}
{isDragOver && !isOccupied && (
<span className="text-emerald-400 text-lg">+</span>
)}
</div> </div>
); );
} }

View file

@ -1,18 +1,27 @@
/** /**
* Layout Editor Page - 2D facility layout management * 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 { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { LayoutEditor } from '../components/layout-editor'; 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() { export function LayoutEditorPage() {
const { floorId } = useParams<{ floorId: string }>(); const { floorId } = useParams<{ floorId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [properties, setProperties] = useState<LayoutProperty[]>([]); const [properties, setProperties] = useState<LayoutProperty[]>([]);
const [selectedFloorId, setSelectedFloorId] = useState<string | undefined>(floorId); const [selectedFloorId, setSelectedFloorId] = useState<string | undefined>(floorId);
const [loading, setLoading] = useState(!floorId); const [loading, setLoading] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false);
// Load properties for floor selector // Load properties for floor selector
useEffect(() => { useEffect(() => {
@ -38,60 +47,131 @@ export function LayoutEditorPage() {
load(); load();
}, [floorId, navigate]); }, [floorId, navigate]);
// Flatten floors for dropdown
const floorOptions = useMemo<FloorOption[]>(() => {
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) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-screen bg-slate-950"> <div className="flex items-center justify-center h-full bg-slate-900">
<div className="text-slate-400">Loading...</div> <div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div> </div>
); );
} }
// Floor selector if no floor specified // No floors available
if (!selectedFloorId) { if (floorOptions.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-screen bg-slate-950 text-white"> <div className="flex flex-col items-center justify-center h-full bg-slate-900 text-white">
<h1 className="text-2xl font-bold mb-6">Select a Floor</h1> <svg className="w-16 h-16 mb-4 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
{properties.length === 0 ? ( </svg>
<div className="text-slate-400"> <h2 className="text-xl font-semibold mb-2">No Facility Layouts</h2>
<p>No facility layouts found.</p> <p className="text-slate-400 text-sm">Create a property with a floor first to use the layout editor.</p>
<p className="text-sm mt-2">Create a property first to get started.</p>
</div>
) : (
<div className="space-y-4 max-w-md w-full">
{properties.map(prop => (
<div key={prop.id} className="bg-slate-900 rounded-lg p-4 border border-slate-700">
<h2 className="font-semibold text-lg mb-2">{prop.name}</h2>
{prop.buildings?.map(building => (
<div key={building.id} className="ml-4 mt-2">
<h3 className="text-sm text-slate-400">{building.name}</h3>
<div className="ml-4 mt-1 space-y-1">
{building.floors?.map(floor => (
<button
key={floor.id}
onClick={() => {
setSelectedFloorId(floor.id);
navigate(`/layout-editor/${floor.id}`);
}}
className="block w-full text-left px-3 py-2 bg-slate-800 hover:bg-emerald-600 rounded transition-colors text-sm"
>
{floor.name}
</button>
))}
</div>
</div>
))}
</div>
))}
</div>
)}
</div> </div>
); );
} }
return ( return (
<div className="h-screen overflow-hidden"> <div className="flex flex-col h-full bg-slate-900">
<LayoutEditor floorId={selectedFloorId} className="h-full" /> {/* Header with Floor Selector */}
<div className="flex-shrink-0 border-b border-slate-700 bg-slate-800 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-white">Layout Editor</h1>
{/* Floor Dropdown */}
<div className="relative">
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 rounded border border-slate-600 text-sm text-white transition-colors"
>
<span className="text-slate-300">Floor:</span>
<span className="font-medium">
{selectedFloor?.name || 'Select floor'}
</span>
<svg className={`w-4 h-4 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{dropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setDropdownOpen(false)} />
<div className="absolute left-0 top-full mt-1 w-72 bg-slate-800 border border-slate-600 rounded-lg shadow-xl z-20 max-h-80 overflow-y-auto">
{properties.map(prop => (
<div key={prop.id}>
<div className="px-3 py-2 text-xs font-semibold text-slate-400 bg-slate-900/50">
{prop.name}
</div>
{prop.buildings?.map(building => (
<div key={building.id}>
<div className="px-3 py-1 text-xs text-slate-500 border-l-2 border-slate-600 ml-2">
{building.name}
</div>
{building.floors?.map(floor => (
<button
key={floor.id}
onClick={() => handleFloorChange(floor.id)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-slate-700 transition-colors ${floor.id === selectedFloorId
? 'bg-emerald-600/20 text-emerald-400'
: 'text-white'
}`}
>
{floor.name}
</button>
))}
</div>
))}
</div>
))}
</div>
</>
)}
</div>
</div>
{/* Quick stats */}
{selectedFloor && (
<div className="text-sm text-slate-400">
{selectedFloor.propertyName} {selectedFloor.buildingName}
</div>
)}
</div>
</div>
{/* Editor Canvas */}
<div className="flex-1 overflow-hidden">
{selectedFloorId ? (
<LayoutEditor floorId={selectedFloorId} className="h-full" />
) : (
<div className="flex items-center justify-center h-full text-slate-400">
Select a floor to start editing
</div>
)}
</div>
</div> </div>
); );
} }

View file

@ -193,6 +193,11 @@ export const router = createBrowserRouter([
path: 'metrc', path: 'metrc',
element: <Suspense fallback={<PageLoader />}><MetrcDashboardPage /></Suspense>, element: <Suspense fallback={<PageLoader />}><MetrcDashboardPage /></Suspense>,
}, },
// 2D Layout Editor (inside app shell)
{
path: 'layout-editor/:floorId?',
element: <Suspense fallback={<PageLoader />}><LayoutEditorPage /></Suspense>,
},
// 404 catch-all // 404 catch-all
{ {
path: '*', path: '*',
@ -232,21 +237,5 @@ export const router = createBrowserRouter([
), ),
errorElement: <RouterErrorPage />, errorElement: <RouterErrorPage />,
}, },
// 2D Layout Editor (Rackula-inspired)
{
path: '/layout-editor/:floorId?',
element: (
<ProtectedRoute>
<Suspense fallback={
<div className="h-screen w-screen bg-slate-950 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
}>
<LayoutEditorPage />
</Suspense>
</ProtectedRoute>
),
errorElement: <RouterErrorPage />,
},
]); ]);