feat: Add rack configuration modal
- RackConfigModal with rows/columns/tiers inputs - Section type selector (Table, Rack, Tray, Hanger, Floor) - Visual preview of grid layout - Gear button in rack header (shows on hover) - API integration to update section dimensions
This commit is contained in:
parent
fe5c6decc2
commit
554bf214c1
4 changed files with 285 additions and 4 deletions
|
|
@ -6,6 +6,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { TypeLibrary } from './TypeLibrary';
|
||||
import { RackVisualizer, RackData, PlantSlotData } from './RackVisualizer';
|
||||
import { RackConfigModal } from './RackConfigModal';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { layoutApi, LayoutPlantType, Floor3DData } from '../../lib/layoutApi';
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ export function LayoutEditor({ floorId, className }: LayoutEditorProps) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSlot, setSelectedSlot] = useState<PlantSlotData | null>(null);
|
||||
const [draggingType, setDraggingType] = useState<LayoutPlantType | null>(null);
|
||||
const [configRack, setConfigRack] = useState<RackData | null>(null);
|
||||
|
||||
// Load floor data and plant types
|
||||
useEffect(() => {
|
||||
|
|
@ -79,6 +81,34 @@ export function LayoutEditor({ floorId, className }: LayoutEditorProps) {
|
|||
setDraggingType(plantType);
|
||||
}, []);
|
||||
|
||||
const handleConfigClick = useCallback((rack: RackData) => {
|
||||
setConfigRack(rack);
|
||||
}, []);
|
||||
|
||||
const handleConfigSave = useCallback(async (updates: Partial<RackData>) => {
|
||||
if (!configRack) return;
|
||||
|
||||
try {
|
||||
// Call API to update section
|
||||
await layoutApi.updateSection(configRack.id, {
|
||||
name: updates.name,
|
||||
code: updates.code,
|
||||
type: updates.subtype,
|
||||
rows: updates.rows,
|
||||
columns: updates.columns,
|
||||
});
|
||||
|
||||
// Reload floor data to get updated positions
|
||||
const floor = await layoutApi.getFloor3D(floorId);
|
||||
setFloorData(floor);
|
||||
|
||||
console.log('Section updated:', configRack.id, updates);
|
||||
} catch (e) {
|
||||
console.error('Failed to update section:', e);
|
||||
throw e;
|
||||
}
|
||||
}, [configRack, floorId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center h-full', className)}>
|
||||
|
|
@ -138,6 +168,7 @@ export function LayoutEditor({ floorId, className }: LayoutEditorProps) {
|
|||
rack={rack}
|
||||
onSlotClick={handleSlotClick}
|
||||
onSlotDrop={handleSlotDrop}
|
||||
onConfigClick={handleConfigClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -222,6 +253,16 @@ export function LayoutEditor({ floorId, className }: LayoutEditorProps) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rack Config Modal */}
|
||||
{configRack && (
|
||||
<RackConfigModal
|
||||
rack={configRack}
|
||||
isOpen={!!configRack}
|
||||
onClose={() => setConfigRack(null)}
|
||||
onSave={handleConfigSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
223
frontend/src/components/layout-editor/RackConfigModal.tsx
Normal file
223
frontend/src/components/layout-editor/RackConfigModal.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* RackConfigModal - Modal for configuring rack/section dimensions
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { RackData } from './RackVisualizer';
|
||||
|
||||
interface RackConfigModalProps {
|
||||
rack: RackData;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (updates: Partial<RackData>) => void;
|
||||
}
|
||||
|
||||
const SUBTYPE_OPTIONS = [
|
||||
{ value: 'TABLE', label: 'Table', icon: '🪵', desc: 'Flat growing surface' },
|
||||
{ value: 'RACK', label: 'Rack', icon: '🗄️', desc: 'Multi-tier vertical rack' },
|
||||
{ value: 'TRAY', label: 'Tray', icon: '🌿', desc: 'Flood/drain tray system' },
|
||||
{ value: 'HANGER', label: 'Hanger', icon: '🪝', desc: 'Hanging/drying racks' },
|
||||
{ value: 'FLOOR', label: 'Floor', icon: '⬜', desc: 'Ground-level positions' },
|
||||
];
|
||||
|
||||
export function RackConfigModal({ rack, isOpen, onClose, onSave }: RackConfigModalProps) {
|
||||
const [name, setName] = useState(rack.name);
|
||||
const [code, setCode] = useState(rack.code || '');
|
||||
const [subtype, setSubtype] = useState(rack.subtype);
|
||||
const [rows, setRows] = useState(rack.rows);
|
||||
const [columns, setColumns] = useState(rack.columns);
|
||||
const [tiers, setTiers] = useState(rack.tiers);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Reset form when rack changes
|
||||
useEffect(() => {
|
||||
setName(rack.name);
|
||||
setCode(rack.code || '');
|
||||
setSubtype(rack.subtype);
|
||||
setRows(rack.rows);
|
||||
setColumns(rack.columns);
|
||||
setTiers(rack.tiers);
|
||||
}, [rack]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
name,
|
||||
code: code || undefined,
|
||||
subtype,
|
||||
rows,
|
||||
columns,
|
||||
tiers,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to save rack config:', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalSlots = rows * columns * tiers;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 border border-slate-600">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-white">Configure Section</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4 space-y-5">
|
||||
{/* Name & Code */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white focus:outline-none focus:border-emerald-500"
|
||||
placeholder="e.g., Table 1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white focus:outline-none focus:border-emerald-500"
|
||||
placeholder="e.g., T1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Section Type</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SUBTYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setSubtype(opt.value as RackData['subtype'])}
|
||||
className={cn(
|
||||
'flex flex-col items-center p-2 rounded border transition-colors',
|
||||
subtype === opt.value
|
||||
? 'border-emerald-500 bg-emerald-900/30 text-emerald-400'
|
||||
: 'border-slate-600 bg-slate-700 text-slate-300 hover:border-slate-500'
|
||||
)}
|
||||
title={opt.desc}
|
||||
>
|
||||
<span className="text-xl mb-1">{opt.icon}</span>
|
||||
<span className="text-xs">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimensions */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Dimensions</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Rows</label>
|
||||
<input
|
||||
type="number"
|
||||
value={rows}
|
||||
onChange={(e) => setRows(Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))}
|
||||
min={1}
|
||||
max={50}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white text-center focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Columns</label>
|
||||
<input
|
||||
type="number"
|
||||
value={columns}
|
||||
onChange={(e) => setColumns(Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))}
|
||||
min={1}
|
||||
max={50}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white text-center focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Tiers</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tiers}
|
||||
onChange={(e) => setTiers(Math.max(1, Math.min(10, parseInt(e.target.value) || 1)))}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white text-center focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Total positions: <span className="text-emerald-400 font-medium">{totalSlots}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Visual Preview */}
|
||||
<div className="bg-slate-900 rounded p-3">
|
||||
<p className="text-xs text-slate-400 mb-2">Preview</p>
|
||||
<div
|
||||
className="grid gap-1"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.min(columns, 10)}, 1fr)`,
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: Math.min(rows * columns, 50) }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-slate-700 border border-dashed border-slate-600 rounded"
|
||||
style={{ minWidth: '12px', minHeight: '12px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{rows * columns > 50 && (
|
||||
<p className="text-xs text-slate-500 mt-1">Showing first 50 slots</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-slate-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim()}
|
||||
className="px-4 py-2 text-sm bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white rounded transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RackConfigModal;
|
||||
|
|
@ -33,6 +33,7 @@ interface RackVisualizerProps {
|
|||
selectedTier?: number;
|
||||
onSlotClick?: (slot: PlantSlotData) => void;
|
||||
onSlotDrop?: (slot: PlantSlotData, plantType: LayoutPlantType) => void;
|
||||
onConfigClick?: (rack: RackData) => void;
|
||||
highlightedSlots?: Set<string>;
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -55,6 +56,7 @@ export function RackVisualizer({
|
|||
selectedTier = 1,
|
||||
onSlotClick,
|
||||
onSlotDrop,
|
||||
onConfigClick,
|
||||
highlightedSlots,
|
||||
className
|
||||
}: RackVisualizerProps) {
|
||||
|
|
@ -91,15 +93,29 @@ export function RackVisualizer({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn('inline-block', className)}>
|
||||
<div className={cn('inline-block group', className)}>
|
||||
{/* Rack header */}
|
||||
<div className={cn('flex items-center gap-2 px-3 py-1.5 rounded-t-lg border-b-0', style.bg, style.border, 'border')}>
|
||||
<span className="text-lg">{style.icon}</span>
|
||||
<span className="text-sm font-medium text-white">{rack.name}</span>
|
||||
<span className="text-xs text-slate-400">({rack.code})</span>
|
||||
{rack.tiers > 1 && (
|
||||
<span className="ml-auto text-xs text-slate-400">Tier {selectedTier}/{rack.tiers}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{rack.tiers > 1 && (
|
||||
<span className="text-xs text-slate-400">Tier {selectedTier}/{rack.tiers}</span>
|
||||
)}
|
||||
{onConfigClick && (
|
||||
<button
|
||||
onClick={() => onConfigClick(rack)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-slate-400 hover:text-white hover:bg-slate-600 rounded transition-all"
|
||||
title="Configure section"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier tabs (if multi-tier) */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { LayoutEditor } from './LayoutEditor';
|
||||
export { TypeLibrary } from './TypeLibrary';
|
||||
export { RackVisualizer } from './RackVisualizer';
|
||||
export { RackConfigModal } from './RackConfigModal';
|
||||
export type { RackData, PlantSlotData } from './RackVisualizer';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue