271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import { useState, useEffect, type FormEvent } from 'react';
|
|
import { toast } from 'sonner';
|
|
import type { ModuleType } from '../../types';
|
|
import { Modal } from '../ui/Modal';
|
|
import { Button } from '../ui/Button';
|
|
import { useRackStore } from '../../store/useRackStore';
|
|
import {
|
|
MODULE_TYPE_LABELS,
|
|
MODULE_TYPE_COLORS,
|
|
MODULE_U_DEFAULTS,
|
|
MODULE_PORT_DEFAULTS,
|
|
} from '../../lib/constants';
|
|
import { cn } from '../../lib/utils';
|
|
|
|
interface AddModuleModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
rackId: string;
|
|
uPosition: number;
|
|
/** Pre-select a type (e.g. from a palette drag) — skips the type picker step. */
|
|
initialType?: ModuleType;
|
|
}
|
|
|
|
const ALL_TYPES: ModuleType[] = [
|
|
'SWITCH', 'AGGREGATE_SWITCH', 'ROUTER', 'FIREWALL', 'PATCH_PANEL',
|
|
'MODEM', 'SERVER', 'NAS', 'PDU', 'AP', 'BLANK', 'OTHER',
|
|
];
|
|
|
|
export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }: AddModuleModalProps) {
|
|
const { addModule } = useRackStore();
|
|
const [selectedType, setSelectedType] = useState<ModuleType | null>(initialType ?? null);
|
|
const [name, setName] = useState(initialType ? MODULE_TYPE_LABELS[initialType] : '');
|
|
const [uSize, setUSize] = useState(initialType ? MODULE_U_DEFAULTS[initialType] : 1);
|
|
const [portCount, setPortCount] = useState(initialType ? MODULE_PORT_DEFAULTS[initialType] : 0);
|
|
const [ipAddress, setIpAddress] = useState('');
|
|
const [manufacturer, setManufacturer] = useState('');
|
|
const [model, setModel] = useState('');
|
|
const [sfpCount, setSfpCount] = useState(0);
|
|
const [wanCount, setWanCount] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Sync state when modal opens with a new initialType (e.g. drag-drop reuse)
|
|
useEffect(() => {
|
|
if (open && initialType) {
|
|
setSelectedType(initialType);
|
|
setName(MODULE_TYPE_LABELS[initialType]);
|
|
setUSize(MODULE_U_DEFAULTS[initialType]);
|
|
setPortCount(MODULE_PORT_DEFAULTS[initialType]);
|
|
} else if (!open) {
|
|
reset();
|
|
}
|
|
}, [open, initialType]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
function handleTypeSelect(type: ModuleType) {
|
|
setSelectedType(type);
|
|
setName(MODULE_TYPE_LABELS[type]);
|
|
setUSize(MODULE_U_DEFAULTS[type]);
|
|
setPortCount(MODULE_PORT_DEFAULTS[type]);
|
|
setSfpCount(0);
|
|
setWanCount(0);
|
|
}
|
|
|
|
function reset() {
|
|
setSelectedType(null);
|
|
setName('');
|
|
setUSize(1);
|
|
setPortCount(0);
|
|
setIpAddress('');
|
|
setManufacturer('');
|
|
setModel('');
|
|
setSfpCount(0);
|
|
setWanCount(0);
|
|
}
|
|
|
|
async function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (!selectedType || !name.trim()) return;
|
|
setLoading(true);
|
|
try {
|
|
await addModule(rackId, {
|
|
name: name.trim(),
|
|
type: selectedType,
|
|
uPosition,
|
|
uSize,
|
|
portCount,
|
|
sfpCount,
|
|
wanCount,
|
|
ipAddress: ipAddress.trim() || undefined,
|
|
manufacturer: manufacturer.trim() || undefined,
|
|
model: model.trim() || undefined,
|
|
});
|
|
toast.success(`${name.trim()} added at U${uPosition}`);
|
|
reset();
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to add module');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleClose() {
|
|
if (!loading) {
|
|
reset();
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal open={open} onClose={handleClose} title={`Add Module — U${uPosition}`} size="md">
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Type selector */}
|
|
{!selectedType ? (
|
|
<div>
|
|
<p className="text-xs text-slate-400 mb-2">Select device type</p>
|
|
<div className="grid grid-cols-3 gap-1.5">
|
|
{ALL_TYPES.map((type) => {
|
|
const colors = MODULE_TYPE_COLORS[type];
|
|
return (
|
|
<button
|
|
key={type}
|
|
type="button"
|
|
onClick={() => handleTypeSelect(type)}
|
|
className={cn(
|
|
'flex flex-col items-center gap-1 px-2 py-2 rounded border text-center hover:brightness-125 transition-all',
|
|
colors.bg,
|
|
colors.border
|
|
)}
|
|
>
|
|
<span className="text-[11px] font-medium text-white leading-tight">
|
|
{MODULE_TYPE_LABELS[type]}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400">
|
|
{MODULE_U_DEFAULTS[type]}U
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={cn(
|
|
'px-2 py-0.5 rounded text-xs font-medium border',
|
|
MODULE_TYPE_COLORS[selectedType].bg,
|
|
MODULE_TYPE_COLORS[selectedType].border,
|
|
'text-white'
|
|
)}
|
|
>
|
|
{MODULE_TYPE_LABELS[selectedType]}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedType(null)}
|
|
className="text-xs text-slate-500 hover:text-slate-300 underline"
|
|
>
|
|
Change type
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="block text-sm text-slate-300">
|
|
Name <span className="text-red-400">*</span>
|
|
</label>
|
|
<input
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
disabled={loading}
|
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<label className="block text-sm text-slate-300">Size (U)</label>
|
|
<input
|
|
type="number" min={1} max={20}
|
|
value={uSize}
|
|
onChange={(e) => setUSize(Number(e.target.value))}
|
|
disabled={loading}
|
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="block text-sm text-slate-300">IP Address</label>
|
|
<input
|
|
value={ipAddress}
|
|
onChange={(e) => setIpAddress(e.target.value)}
|
|
disabled={loading}
|
|
placeholder="192.168.1.1"
|
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="space-y-1">
|
|
<label className="block text-sm text-slate-300">Ethernet</label>
|
|
<input
|
|
type="number" min={0} max={128}
|
|
value={portCount}
|
|
onChange={(e) => setPortCount(Number(e.target.value))}
|
|
disabled={loading}
|
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="block text-sm text-slate-300">SFP</label>
|
|
<input
|
|
type="number" min={0} max={128}
|
|
value={sfpCount}
|
|
onChange={(e) => setSfpCount(Number(e.target.value))}
|
|
disabled={loading}
|
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="block text-sm text-slate-300">WAN</label>
|
|
<input
|
|
type="number" min={0} max={128}
|
|
value={wanCount}
|
|
onChange={(e) => setWanCount(Number(e.target.value))}
|
|
disabled={loading}
|
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<label className="block text-sm text-slate-300">Manufacturer</label>
|
|
<input
|
|
value={manufacturer}
|
|
onChange={(e) => setManufacturer(e.target.value)}
|
|
disabled={loading}
|
|
placeholder="Cisco, Ubiquiti…"
|
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="block text-sm text-slate-300">Model</label>
|
|
<input
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
disabled={loading}
|
|
placeholder="SG300-28…"
|
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-1">
|
|
<Button variant="secondary" size="sm" type="button" onClick={handleClose} disabled={loading}>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
|
|
Add Module
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|