import { useEffect, useState, useCallback } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import * as api from '../api'; import type { Component, Port } from '../types'; import { COMPONENT_META, COMPONENT_TYPES } from '../types'; import MarkdownEditor from '../components/MarkdownEditor'; import AuditTrail from '../components/AuditTrail'; const STATUS_OPTIONS = ['active', 'maintenance', 'decommissioned'] as const; export default function ComponentPage() { const { componentId } = useParams<{ componentId: string }>(); const navigate = useNavigate(); const [component, setComponent] = useState(null); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(false); const [notes, setNotes] = useState(''); const [form, setForm] = useState>({}); const [saving, setSaving] = useState(false); type PortModalState = { editPort: Port | null }; const BLANK_PORT_FORM = { port_number: '', label: '', port_type: 'RJ45', notes: '', connected_to_port_id: '' }; const [portModal, setPortModal] = useState(null); const [portForm, setPortForm] = useState(BLANK_PORT_FORM); const [patchPanels, setPatchPanels] = useState([]); const [switches, setSwitches] = useState([]); const [swLink, setSwLink] = useState<{ switchId: string; portNumber: string; portId: string | null } | null>(null); const [portError, setPortError] = useState(null); const loadComponent = useCallback(async () => { if (!componentId) return; const c = await api.getComponent(componentId); setComponent(c); setNotes(c.notes ?? ''); setForm({ name: c.name, type: c.type, position: c.position, height_units: c.height_units, manufacturer: c.manufacturer, model: c.model, serial_number: c.serial_number, asset_tag: c.asset_tag, ip_address: c.ip_address, mac_address: c.mac_address, status: c.status, port_count: c.port_count, sfp_count: c.sfp_count, }); setLoading(false); if (c.type === 'switch' && c.rack?.id) { const all = await api.getComponents(c.rack.id); setPatchPanels(all.filter(comp => comp.type === 'patch_panel')); } if (c.type === 'patch_panel' && c.rack?.id) { const all = await api.getComponents(c.rack.id); setSwitches(all.filter(comp => comp.type === 'switch')); } }, [componentId]); useEffect(() => { loadComponent(); }, [loadComponent]); const saveNotes = async (val: string) => { if (!componentId) return; await api.updateComponent(componentId, { notes: val }); }; const saveMeta = async () => { if (!componentId) return; setSaving(true); try { await api.updateComponent(componentId, form); await loadComponent(); setEditing(false); } finally { setSaving(false); } }; const handleDelete = async () => { if (!componentId || !component || !confirm(`Delete component "${component.name}"?`)) return; await api.deleteComponent(componentId); navigate(component.rack ? `/racks/${component.rack.id}` : '/'); }; const handleDeletePort = async (portId: string) => { if (!componentId || !confirm('Delete this port?')) return; await api.deletePort(componentId, portId); await loadComponent(); }; const openPortModal = (port?: Port) => { setPortError(null); setPortModal({ editPort: port ?? null }); setPortForm(port ? { port_number: String(port.port_number), label: port.label ?? '', port_type: port.port_type, notes: port.notes ?? '', connected_to_port_id: port.connected_to_port_id ?? '', } : BLANK_PORT_FORM); if (port?.linked_port?.component_type === 'switch') { setSwLink({ switchId: port.linked_port.component_id, portNumber: String(port.linked_port.port_number), portId: port.linked_port.id }); } else { setSwLink(null); } }; const closePortModal = () => { setPortModal(null); setSwLink(null); setPortError(null); }; const handleSavePort = async (e: React.FormEvent) => { e.preventDefault(); if (!componentId || !portForm.port_number) return; setPortError(null); // Patch-panel only: validate no duplicate port number and capacity limit if (component?.type === 'patch_panel' && !portModal?.editPort) { const existingPorts: Port[] = component.ports ?? []; const portNum = Number(portForm.port_number); const maxPorts = component.port_count ?? Infinity; if (existingPorts.some(p => p.port_number === portNum)) { setPortError(`Port ${portNum} is already added. Each port number can only appear once.`); return; } if (existingPorts.length >= maxPorts) { setPortError(`This patch panel is full (${maxPorts} ports). Delete an existing port to add a new one.`); return; } if (portNum < 1 || portNum > maxPorts) { setPortError(`Port number must be between 1 and ${maxPorts}.`); return; } } // Resolve connected_to_port_id for patch panels via swLink let connectedToPortId: string | null | undefined = portForm.connected_to_port_id || null; if (component?.type === 'patch_panel') { if (swLink?.switchId && swLink?.portNumber) { if (swLink.portId) { connectedToPortId = swLink.portId; } else { // Switch port record doesn't exist yet – create it first const newSwPort = await api.createPort(swLink.switchId, { port_number: Number(swLink.portNumber), port_type: 'RJ45', }); connectedToPortId = newSwPort.id; } } else { connectedToPortId = null; } } try { if (portModal?.editPort) { await api.updatePort(componentId, portModal.editPort.id, { label: portForm.label || undefined, port_type: portForm.port_type, notes: portForm.notes || undefined, connected_to_port_id: connectedToPortId, }); } else { await api.createPort(componentId, { port_number: Number(portForm.port_number), label: portForm.label || undefined, port_type: portForm.port_type, notes: portForm.notes || undefined, connected_to_port_id: connectedToPortId ?? undefined, }); } } catch (err: unknown) { setPortError(err instanceof Error ? err.message : 'Failed to save port'); return; } setPortModal(null); setSwLink(null); setPortError(null); await loadComponent(); }; const setField = (key: keyof Component, val: unknown) => setForm(f => ({ ...f, [key]: val })); if (loading || !component) return
Loading…
; const meta = COMPONENT_META[component.type]; const ports: Port[] = component.ports ?? []; return (
Dashboard {component.site && <> / {component.site.name}} {component.room && <> / {component.room.name}} {component.rack && <> / {component.rack.name}} / {component.name}
{component.rack && ( )}
{/* Component Header */}
{meta.label}

{component.name}

{component.status}
{/* Meta fields */}
{editing ? (
setField('name', e.target.value)} />
setField('position', e.target.value ? Number(e.target.value) : null)} />
setField('height_units', Number(e.target.value))} />
{(form.type === 'switch' || form.type === 'patch_panel') && (
setField('port_count', Number(e.target.value))} /> {form.type === 'patch_panel' ? 'RJ45 jacks on front panel' : 'Ethernet port count'}
)} {form.type === 'switch' && (
setField('sfp_count', Number(e.target.value))} /> SFP / fiber uplink slots
)}
setField('manufacturer', e.target.value)} />
setField('model', e.target.value)} />
setField('ip_address', e.target.value)} />
setField('mac_address', e.target.value)} />
setField('serial_number', e.target.value)} />
setField('asset_tag', e.target.value)} />
) : (
{(component.type === 'switch' || component.type === 'patch_panel') && ( )} {component.type === 'switch' && ( )}
)}
{/* Ports */}

Ports / Connections

{ports.length === 0 ? (
No ports documented yet.
) : (
{ports.map(port => (
{port.port_number}
{port.label || 'β€”'}
{component.type !== 'patch_panel' && (
{port.port_type}
)} {port.notes &&
{port.notes}
} {port.linked_port && (
{component.type === 'switch' ? `↔ ${port.linked_port.component_name} : P${port.linked_port.port_number}${port.linked_port.label ? ` β†’ ${port.linked_port.label}` : ''}` : `↔ ${port.linked_port.component_name} : P${port.linked_port.port_number}` }
)} {port.last_edited_by && (
✎ {port.last_edited_by} {port.updated_at && ( <> · {new Date(port.updated_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} )}
)}
))}
)}
{/* Port Modal */} {portModal !== null && (
e.target === e.currentTarget && closePortModal()}>
{portModal.editPort ? `Edit ${component.type === 'patch_panel' ? 'PP' : 'SW'} Port ${portModal.editPort.port_number}` : `Add ${component.type === 'patch_panel' ? 'PP Port' : 'SW Port'}`} {portModal.editPort?.last_edited_by && ( ✎ {portModal.editPort.last_edited_by} {portModal.editPort.updated_at && ( <> · {new Date(portModal.editPort.updated_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} )} )}
setPortForm(f => ({ ...f, port_number: e.target.value }))} required disabled={!!portModal.editPort} /> {component.type === 'patch_panel' && component.port_count && !portModal.editPort && ( Port 1–{component.port_count} )}
setPortForm(f => ({ ...f, label: e.target.value }))} placeholder={component.type === 'patch_panel' ? 'e.g. Desktop 1, Printer, IP Camera' : 'e.g. Uplink-01'} autoFocus={!portModal.editPort} />
{component.type !== 'patch_panel' && (
)}
setPortForm(f => ({ ...f, notes: e.target.value }))} placeholder={component.type === 'patch_panel' ? 'e.g. Room A, Floor 2' : 'e.g. VLAN 10'} />
{component.type === 'switch' && patchPanels.length > 0 && ( setPortForm(f => ({ ...f, connected_to_port_id: id }))} /> )} {component.type === 'patch_panel' && ( )} {portError && (
⚠ {portError}
)}
)} {/* Notes */}

Documentation

); } function MetaField({ label, value }: { label: string; value?: string | number | null }) { if (!value && value !== 0) return null; return (
{label} {value}
); } function PpLinkPicker({ patchPanels, value, initialPpId, onChange, }: { patchPanels: Component[]; value: string; initialPpId?: string; onChange: (portId: string) => void; }) { const [selectedPpId, setSelectedPpId] = useState(initialPpId ?? ''); const [ppPorts, setPpPorts] = useState([]); const [loadingPp, setLoadingPp] = useState(false); useEffect(() => { if (!selectedPpId) { setPpPorts([]); return; } setLoadingPp(true); api.getComponent(selectedPpId).then(pp => { setPpPorts(pp.ports ?? []); setLoadingPp(false); }); }, [selectedPpId]); const selectedPort = ppPorts.find(p => p.id === value); return (
πŸ”— Link to Patch Panel
{selectedPpId && ( )}
{selectedPort?.label && (
↳ End device: {selectedPort.label}
)} {value && ( )}
); } function SwLinkPicker({ switches, initialSwitchId, initialPortNumber, currentLinkedPortId, onChange, }: { switches: Component[]; initialSwitchId?: string; initialPortNumber?: string; currentLinkedPortId?: string | null; onChange: (link: { switchId: string; portNumber: string; portId: string | null } | null) => void; }) { const [selectedSwitchId, setSelectedSwitchId] = useState(initialSwitchId ?? ''); const [switchData, setSwitchData] = useState(null); const [loadingSwitch, setLoadingSwitch] = useState(false); const [selectedPortNumber, setSelectedPortNumber] = useState(initialPortNumber ?? ''); useEffect(() => { if (!selectedSwitchId) { setSwitchData(null); return; } setLoadingSwitch(true); api.getComponent(selectedSwitchId).then(sw => { setSwitchData(sw); setLoadingSwitch(false); }); }, [selectedSwitchId]); const portCount = switchData?.port_count ?? 24; const handleSwitchChange = (swId: string) => { setSelectedSwitchId(swId); setSelectedPortNumber(''); onChange(null); }; const handlePortChange = (portNumberStr: string) => { setSelectedPortNumber(portNumberStr); if (!portNumberStr || !selectedSwitchId) { onChange(null); return; } const existingPort = (switchData?.ports ?? []).find(p => p.port_number === Number(portNumberStr)); onChange({ switchId: selectedSwitchId, portNumber: portNumberStr, portId: existingPort?.id ?? null }); }; return (
πŸ”— Link to Switch Port (optional)
{selectedSwitchId && !loadingSwitch && switchData && ( )} {loadingSwitch && Loading…} {switches.length === 0 && ( No switches in this rack )}
{selectedSwitchId && selectedPortNumber && ( )}
); }