294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
|
||
import type { ComponentType, ComponentStatus } from '../types';
|
||
|
||
interface AddComponentProps {
|
||
rackId: string;
|
||
totalUnits: number;
|
||
initialPosition?: number;
|
||
onSave: (data: ComponentFormData) => Promise<void>;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export interface ComponentFormData {
|
||
rack_id: string;
|
||
name: string;
|
||
type: ComponentType;
|
||
position?: number | null;
|
||
height_units: number;
|
||
port_count?: number | null;
|
||
sfp_count?: number | null;
|
||
manufacturer?: string;
|
||
model?: string;
|
||
serial_number?: string;
|
||
asset_tag?: string;
|
||
ip_address?: string;
|
||
mac_address?: string;
|
||
status: ComponentStatus;
|
||
notes: string;
|
||
}
|
||
|
||
export function AddComponentModal({ rackId, totalUnits, initialPosition, onSave, onClose }: AddComponentProps) {
|
||
const [form, setForm] = useState<ComponentFormData>({
|
||
rack_id: rackId,
|
||
name: '',
|
||
type: 'server',
|
||
position: initialPosition ?? null,
|
||
height_units: 1,
|
||
port_count: null,
|
||
sfp_count: null,
|
||
manufacturer: '',
|
||
model: '',
|
||
serial_number: '',
|
||
asset_tag: '',
|
||
ip_address: '',
|
||
mac_address: '',
|
||
status: 'active',
|
||
notes: '',
|
||
});
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
function set(key: keyof ComponentFormData, value: unknown) {
|
||
setForm(f => ({ ...f, [key]: value }));
|
||
}
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!form.name.trim()) { setError('Name is required'); return; }
|
||
setSaving(true);
|
||
setError('');
|
||
try {
|
||
await onSave({
|
||
...form,
|
||
position: form.position ? Number(form.position) : null,
|
||
height_units: Number(form.height_units),
|
||
port_count: (form.type === 'switch' || form.type === 'patch_panel') && form.port_count
|
||
? Number(form.port_count) : null,
|
||
sfp_count: form.type === 'switch' && form.sfp_count ? Number(form.sfp_count) : null,
|
||
manufacturer: form.manufacturer || undefined,
|
||
model: form.model || undefined,
|
||
serial_number: form.serial_number || undefined,
|
||
asset_tag: form.asset_tag || undefined,
|
||
ip_address: form.ip_address || undefined,
|
||
mac_address: form.mac_address || undefined,
|
||
});
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||
<div className="modal">
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">Add Component</h2>
|
||
<button className="modal-close" onClick={onClose}>✕</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="modal-form">
|
||
<div className="form-row">
|
||
<div className="form-group form-group-lg">
|
||
<label>Name *</label>
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
value={form.name}
|
||
onChange={e => set('name', e.target.value)}
|
||
placeholder="e.g. Core Switch SW-01"
|
||
className="form-input"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Type</label>
|
||
<select value={form.type} onChange={e => set('type', e.target.value)} className="form-input">
|
||
{COMPONENT_TYPES.map(t => (
|
||
<option key={t} value={t}>{COMPONENT_META[t].label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>Rack Position (U)</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={totalUnits}
|
||
value={form.position ?? ''}
|
||
onChange={e => set('position', e.target.value ? Number(e.target.value) : null)}
|
||
placeholder="auto"
|
||
className="form-input"
|
||
/>
|
||
<span className="form-hint">Leave empty to leave unpositioned</span>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Height (U)</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={totalUnits}
|
||
value={form.height_units}
|
||
onChange={e => set('height_units', Number(e.target.value))}
|
||
className="form-input"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Status</label>
|
||
<select value={form.status} onChange={e => set('status', e.target.value)} className="form-input">
|
||
<option value="active">Active</option>
|
||
<option value="maintenance">Maintenance</option>
|
||
<option value="decommissioned">Decommissioned</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{(form.type === 'switch' || form.type === 'patch_panel') && (
|
||
<div className="form-row port-count-row">
|
||
<div className="form-group form-group-lg">
|
||
<label>
|
||
{form.type === 'patch_panel' ? '🔌 Available Patches (port count)' : '🔌 Number of Switch Ports'}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={96}
|
||
value={form.port_count ?? 24}
|
||
onChange={e => set('port_count', Number(e.target.value))}
|
||
className="form-input"
|
||
placeholder="24"
|
||
/>
|
||
<span className="form-hint">
|
||
{form.type === 'patch_panel'
|
||
? 'Number of RJ45 jacks on the front panel (e.g. 12, 24, 48)'
|
||
: 'Total ethernet ports shown on the rack diagram (e.g. 8, 16, 24, 48)'}
|
||
</span>
|
||
</div>
|
||
{form.type === 'switch' && (
|
||
<div className="form-group">
|
||
<label>🔶 Fiber / SFP Ports</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={16}
|
||
value={form.sfp_count ?? 0}
|
||
onChange={e => set('sfp_count', Number(e.target.value))}
|
||
className="form-input"
|
||
placeholder="0"
|
||
/>
|
||
<span className="form-hint">SFP / fiber uplink slots (0–16)</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>Manufacturer</label>
|
||
<input type="text" value={form.manufacturer} onChange={e => set('manufacturer', e.target.value)} placeholder="e.g. Cisco" className="form-input" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Model</label>
|
||
<input type="text" value={form.model} onChange={e => set('model', e.target.value)} placeholder="e.g. Catalyst 2960" className="form-input" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>IP Address</label>
|
||
<input type="text" value={form.ip_address} onChange={e => set('ip_address', e.target.value)} placeholder="192.168.1.1" className="form-input" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>MAC Address</label>
|
||
<input type="text" value={form.mac_address} onChange={e => set('mac_address', e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" className="form-input" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>Serial Number</label>
|
||
<input type="text" value={form.serial_number} onChange={e => set('serial_number', e.target.value)} className="form-input" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Asset Tag</label>
|
||
<input type="text" value={form.asset_tag} onChange={e => set('asset_tag', e.target.value)} className="form-input" />
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className="form-error">{error}</div>}
|
||
|
||
<div className="modal-footer">
|
||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||
<button type="submit" className="btn-primary" disabled={saving}>
|
||
{saving ? 'Saving...' : 'Add Component'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Generic modal for creating sites/rooms/racks
|
||
interface SimpleCreateProps {
|
||
title: string;
|
||
fields: { key: string; label: string; placeholder?: string; type?: string; defaultValue?: string | number }[];
|
||
onSave: (data: Record<string, string | number>) => Promise<void>;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function SimpleCreateModal({ title, fields, onSave, onClose }: SimpleCreateProps) {
|
||
const [values, setValues] = useState<Record<string, string | number>>(
|
||
Object.fromEntries(fields.map(f => [f.key, f.defaultValue ?? '']))
|
||
);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
setError('');
|
||
try {
|
||
await onSave(values);
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||
<div className="modal modal-sm">
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">{title}</h2>
|
||
<button className="modal-close" onClick={onClose}>✕</button>
|
||
</div>
|
||
<form onSubmit={handleSubmit} className="modal-form">
|
||
{fields.map(f => (
|
||
<div className="form-group" key={f.key}>
|
||
<label>{f.label}</label>
|
||
<input
|
||
autoFocus={fields[0].key === f.key}
|
||
type={f.type ?? 'text'}
|
||
value={values[f.key] as string}
|
||
onChange={e => setValues(v => ({ ...v, [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value }))}
|
||
placeholder={f.placeholder}
|
||
className="form-input"
|
||
/>
|
||
</div>
|
||
))}
|
||
{error && <div className="form-error">{error}</div>}
|
||
<div className="modal-footer">
|
||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||
<button type="submit" className="btn-primary" disabled={saving}>
|
||
{saving ? 'Saving...' : 'Create'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|