Normalizes manufacturer as a shared catalog entity (like device categories) instead of free text on each device, giving the admin duplicate-detection nudge a reliable signal. Adds full CRUD (including Admin direct-publish, bypassing the submission queue) for categories, manufacturers, port types, and cable types, plus a Categories & Manufacturers library modal and a browse-by-manufacturer/search view in the device palette. Adds Port.builtInCable so a captive/permanently-attached cable (a keyboard's USB lead, a budget AVR's power cord) can be flagged and excluded from the BOM. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
195 lines
8.0 KiB
TypeScript
195 lines
8.0 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { PORT_DIRECTIONS } from '../../domain/constants'
|
|
import { allPortTypes } from '../../domain/diagram'
|
|
import type { PortDirection } from '../../domain/types'
|
|
import { useCatalogStore } from '../../state/catalogStore'
|
|
import { useDiagramStore } from '../../state/diagramStore'
|
|
import CategorySelect from '../common/CategorySelect'
|
|
import ManufacturerSelect from '../common/ManufacturerSelect'
|
|
|
|
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
|
const diagram = useDiagramStore((s) => s.diagram)
|
|
const catalog = useCatalogStore((s) => s.catalog)
|
|
const access = useDiagramStore((s) => s.access)
|
|
const canEdit = access?.myPermission !== 'view'
|
|
const updateDevice = useDiagramStore((s) => s.updateDevice)
|
|
const removeDevice = useDiagramStore((s) => s.removeDevice)
|
|
const addPort = useDiagramStore((s) => s.addPort)
|
|
const clonePort = useDiagramStore((s) => s.clonePort)
|
|
const updatePort = useDiagramStore((s) => s.updatePort)
|
|
const removePort = useDiagramStore((s) => s.removePort)
|
|
const [focusPortId, setFocusPortId] = useState<string | null>(null)
|
|
|
|
const device = diagram.devices.find((d) => d.id === deviceId)
|
|
const portTypes = useMemo(
|
|
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
|
|
[catalog],
|
|
)
|
|
|
|
if (!device) return null
|
|
|
|
const connectionCountForPort = (portId: string) =>
|
|
diagram.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length
|
|
|
|
return (
|
|
<fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 p-3">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<label className="col-span-2 text-xs font-medium text-slate-600">
|
|
Name
|
|
<input
|
|
value={device.name}
|
|
onChange={(e) => updateDevice(device.id, { name: e.target.value })}
|
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="text-xs font-medium text-slate-600">
|
|
Category
|
|
<CategorySelect value={device.category} onChange={(category) => updateDevice(device.id, { category })} />
|
|
</label>
|
|
<label className="text-xs font-medium text-slate-600">
|
|
Manufacturer
|
|
<ManufacturerSelect
|
|
value={device.manufacturerId}
|
|
onChange={(manufacturerId) => updateDevice(device.id, { manufacturerId })}
|
|
/>
|
|
</label>
|
|
<label className="text-xs font-medium text-slate-600">
|
|
Model
|
|
<input
|
|
value={device.model ?? ''}
|
|
onChange={(e) => updateDevice(device.id, { model: e.target.value })}
|
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="text-xs font-medium text-slate-600">
|
|
Cost
|
|
<div className="mt-1 flex items-center gap-1">
|
|
<span className="text-xs text-slate-500">$</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step={0.01}
|
|
value={device.cost ?? ''}
|
|
onChange={(e) =>
|
|
updateDevice(device.id, { cost: e.target.value === '' ? undefined : Number(e.target.value) })
|
|
}
|
|
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
|
placeholder="e.g. 249.00"
|
|
/>
|
|
</div>
|
|
</label>
|
|
<label className="col-span-2 text-xs font-medium text-slate-600">
|
|
Notes
|
|
<textarea
|
|
value={device.notes ?? ''}
|
|
onChange={(e) => updateDevice(device.id, { notes: e.target.value })}
|
|
rows={2}
|
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4>
|
|
<div className="space-y-1.5">
|
|
{device.ports.map((port) => {
|
|
const inUse = connectionCountForPort(port.id) > 0
|
|
return (
|
|
<div key={port.id} className="flex flex-wrap items-center gap-1.5">
|
|
<input
|
|
ref={(el) => {
|
|
if (el && focusPortId === port.id) {
|
|
el.focus()
|
|
setFocusPortId(null)
|
|
}
|
|
}}
|
|
value={port.name}
|
|
onChange={(e) => updatePort(device.id, port.id, { name: e.target.value })}
|
|
className="w-24 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
|
|
/>
|
|
<select
|
|
value={port.direction}
|
|
onChange={(e) => updatePort(device.id, port.id, { direction: e.target.value as PortDirection })}
|
|
className="rounded border border-slate-300 px-1 py-1 text-xs"
|
|
>
|
|
{PORT_DIRECTIONS.map((d) => (
|
|
<option key={d.value} value={d.value}>
|
|
{d.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={port.portTypeId}
|
|
onChange={(e) => updatePort(device.id, port.id, { portTypeId: e.target.value })}
|
|
className="w-28 min-w-0 flex-1 rounded border border-slate-300 px-1 py-1 text-xs"
|
|
>
|
|
{portTypes.map((pt) => (
|
|
<option key={pt.id} value={pt.id}>
|
|
{pt.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{/* Grouped so these three wrap to their own line as a unit
|
|
(aligned to the row's end) rather than each one reflowing
|
|
separately if the row ever gets tighter than it can fit. */}
|
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
<button
|
|
onClick={() => updatePort(device.id, port.id, { builtInCable: !port.builtInCable })}
|
|
tabIndex={-1}
|
|
title="Built-in cable"
|
|
className={`shrink-0 rounded px-1 ${
|
|
port.builtInCable ? 'bg-amber-100 opacity-100 ring-1 ring-amber-300' : 'opacity-30 hover:opacity-60'
|
|
}`}
|
|
>
|
|
🔌
|
|
</button>
|
|
<button
|
|
onClick={() => setFocusPortId(clonePort(device.id, port.id))}
|
|
tabIndex={-1}
|
|
title="Duplicate this port"
|
|
className="shrink-0 rounded px-1 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600"
|
|
>
|
|
⧉
|
|
</button>
|
|
<button
|
|
onClick={() => removePort(device.id, port.id)}
|
|
disabled={inUse}
|
|
tabIndex={-1}
|
|
title={inUse ? 'Disconnect its cable first' : 'Remove port'}
|
|
className="shrink-0 rounded px-1 text-slate-400 hover:bg-red-50 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-30"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
<button
|
|
onClick={() =>
|
|
setFocusPortId(
|
|
addPort(device.id, {
|
|
name: `Port ${device.ports.length + 1}`,
|
|
direction: 'output',
|
|
portTypeId: portTypes[0]?.id ?? '',
|
|
}),
|
|
)
|
|
}
|
|
className="mt-1.5 text-[11px] font-medium text-indigo-600 hover:underline"
|
|
>
|
|
+ Add port
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-t border-slate-100 pt-3">
|
|
<button
|
|
onClick={() => removeDevice(device.id)}
|
|
className="w-full rounded border border-red-200 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
|
>
|
|
Delete device
|
|
</button>
|
|
</div>
|
|
</fieldset>
|
|
)
|
|
}
|