feat: Add rack configuration modal
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- 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:
fullsizemalt 2026-01-01 15:03:47 -08:00
parent fe5c6decc2
commit 554bf214c1
4 changed files with 285 additions and 4 deletions

View file

@ -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>
);
}

View 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;

View file

@ -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) */}

View file

@ -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';