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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { TypeLibrary } from './TypeLibrary';
|
import { TypeLibrary } from './TypeLibrary';
|
||||||
import { RackVisualizer, RackData, PlantSlotData } from './RackVisualizer';
|
import { RackVisualizer, RackData, PlantSlotData } from './RackVisualizer';
|
||||||
|
import { RackConfigModal } from './RackConfigModal';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { layoutApi, LayoutPlantType, Floor3DData } from '../../lib/layoutApi';
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [selectedSlot, setSelectedSlot] = useState<PlantSlotData | null>(null);
|
const [selectedSlot, setSelectedSlot] = useState<PlantSlotData | null>(null);
|
||||||
const [draggingType, setDraggingType] = useState<LayoutPlantType | null>(null);
|
const [draggingType, setDraggingType] = useState<LayoutPlantType | null>(null);
|
||||||
|
const [configRack, setConfigRack] = useState<RackData | null>(null);
|
||||||
|
|
||||||
// Load floor data and plant types
|
// Load floor data and plant types
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -79,6 +81,34 @@ export function LayoutEditor({ floorId, className }: LayoutEditorProps) {
|
||||||
setDraggingType(plantType);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center justify-center h-full', className)}>
|
<div className={cn('flex items-center justify-center h-full', className)}>
|
||||||
|
|
@ -138,6 +168,7 @@ export function LayoutEditor({ floorId, className }: LayoutEditorProps) {
|
||||||
rack={rack}
|
rack={rack}
|
||||||
onSlotClick={handleSlotClick}
|
onSlotClick={handleSlotClick}
|
||||||
onSlotDrop={handleSlotDrop}
|
onSlotDrop={handleSlotDrop}
|
||||||
|
onConfigClick={handleConfigClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,6 +253,16 @@ export function LayoutEditor({ floorId, className }: LayoutEditorProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rack Config Modal */}
|
||||||
|
{configRack && (
|
||||||
|
<RackConfigModal
|
||||||
|
rack={configRack}
|
||||||
|
isOpen={!!configRack}
|
||||||
|
onClose={() => setConfigRack(null)}
|
||||||
|
onSave={handleConfigSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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;
|
selectedTier?: number;
|
||||||
onSlotClick?: (slot: PlantSlotData) => void;
|
onSlotClick?: (slot: PlantSlotData) => void;
|
||||||
onSlotDrop?: (slot: PlantSlotData, plantType: LayoutPlantType) => void;
|
onSlotDrop?: (slot: PlantSlotData, plantType: LayoutPlantType) => void;
|
||||||
|
onConfigClick?: (rack: RackData) => void;
|
||||||
highlightedSlots?: Set<string>;
|
highlightedSlots?: Set<string>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +56,7 @@ export function RackVisualizer({
|
||||||
selectedTier = 1,
|
selectedTier = 1,
|
||||||
onSlotClick,
|
onSlotClick,
|
||||||
onSlotDrop,
|
onSlotDrop,
|
||||||
|
onConfigClick,
|
||||||
highlightedSlots,
|
highlightedSlots,
|
||||||
className
|
className
|
||||||
}: RackVisualizerProps) {
|
}: RackVisualizerProps) {
|
||||||
|
|
@ -91,15 +93,29 @@ export function RackVisualizer({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('inline-block', className)}>
|
<div className={cn('inline-block group', className)}>
|
||||||
{/* Rack header */}
|
{/* 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')}>
|
<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-lg">{style.icon}</span>
|
||||||
<span className="text-sm font-medium text-white">{rack.name}</span>
|
<span className="text-sm font-medium text-white">{rack.name}</span>
|
||||||
<span className="text-xs text-slate-400">({rack.code})</span>
|
<span className="text-xs text-slate-400">({rack.code})</span>
|
||||||
{rack.tiers > 1 && (
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<span className="ml-auto text-xs text-slate-400">Tier {selectedTier}/{rack.tiers}</span>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Tier tabs (if multi-tier) */}
|
{/* Tier tabs (if multi-tier) */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { LayoutEditor } from './LayoutEditor';
|
export { LayoutEditor } from './LayoutEditor';
|
||||||
export { TypeLibrary } from './TypeLibrary';
|
export { TypeLibrary } from './TypeLibrary';
|
||||||
export { RackVisualizer } from './RackVisualizer';
|
export { RackVisualizer } from './RackVisualizer';
|
||||||
|
export { RackConfigModal } from './RackConfigModal';
|
||||||
export type { RackData, PlantSlotData } from './RackVisualizer';
|
export type { RackData, PlantSlotData } from './RackVisualizer';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue