Add manufacturer catalog, category/manufacturer library management, and built-in cable flag

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
This commit is contained in:
2026-09-28 10:04:05 -05:00
co-authored by Claude Sonnet 5
parent 00777077c2
commit 29c3ed46a3
28 changed files with 2201 additions and 320 deletions
+37 -22
View File
@@ -5,6 +5,7 @@ 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)
@@ -47,10 +48,9 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</label>
<label className="text-xs font-medium text-slate-600">
Manufacturer
<input
value={device.manufacturer ?? ''}
onChange={(e) => updateDevice(device.id, { manufacturer: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
<ManufacturerSelect
value={device.manufacturerId}
onChange={(manufacturerId) => updateDevice(device.id, { manufacturerId })}
/>
</label>
<label className="text-xs font-medium text-slate-600">
@@ -95,7 +95,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
{device.ports.map((port) => {
const inUse = connectionCountForPort(port.id) > 0
return (
<div key={port.id} className="flex items-center gap-1.5">
<div key={port.id} className="flex flex-wrap items-center gap-1.5">
<input
ref={(el) => {
if (el && focusPortId === port.id) {
@@ -129,23 +129,38 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</option>
))}
</select>
<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>
{/* 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>
)
})}