fix: Layout editor UX improvements
- 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:
parent
f534c9818e
commit
fe5c6decc2
3 changed files with 164 additions and 67 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
);
|
||||||
<div className="space-y-4 max-w-md w-full">
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-slate-900">
|
||||||
|
{/* 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 => (
|
{properties.map(prop => (
|
||||||
<div key={prop.id} className="bg-slate-900 rounded-lg p-4 border border-slate-700">
|
<div key={prop.id}>
|
||||||
<h2 className="font-semibold text-lg mb-2">{prop.name}</h2>
|
<div className="px-3 py-2 text-xs font-semibold text-slate-400 bg-slate-900/50">
|
||||||
|
{prop.name}
|
||||||
|
</div>
|
||||||
{prop.buildings?.map(building => (
|
{prop.buildings?.map(building => (
|
||||||
<div key={building.id} className="ml-4 mt-2">
|
<div key={building.id}>
|
||||||
<h3 className="text-sm text-slate-400">{building.name}</h3>
|
<div className="px-3 py-1 text-xs text-slate-500 border-l-2 border-slate-600 ml-2">
|
||||||
<div className="ml-4 mt-1 space-y-1">
|
{building.name}
|
||||||
|
</div>
|
||||||
{building.floors?.map(floor => (
|
{building.floors?.map(floor => (
|
||||||
<button
|
<button
|
||||||
key={floor.id}
|
key={floor.id}
|
||||||
onClick={() => {
|
onClick={() => handleFloorChange(floor.id)}
|
||||||
setSelectedFloorId(floor.id);
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-slate-700 transition-colors ${floor.id === selectedFloorId
|
||||||
navigate(`/layout-editor/${floor.id}`);
|
? 'bg-emerald-600/20 text-emerald-400'
|
||||||
}}
|
: 'text-white'
|
||||||
className="block w-full text-left px-3 py-2 bg-slate-800 hover:bg-emerald-600 rounded transition-colors text-sm"
|
}`}
|
||||||
>
|
>
|
||||||
{floor.name}
|
{floor.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
{selectedFloor && (
|
||||||
|
<div className="text-sm text-slate-400">
|
||||||
|
{selectedFloor.propertyName} — {selectedFloor.buildingName}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* Editor Canvas */}
|
||||||
<div className="h-screen overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{selectedFloorId ? (
|
||||||
<LayoutEditor floorId={selectedFloorId} className="h-full" />
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 />,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue