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
+24 -3
View File
@@ -1,4 +1,4 @@
import { allCableTypes } from './diagram'
import { allCableTypes, manufacturerName } from './diagram'
import type { CableType, Catalog, Connection, Device, Diagram } from './types'
/** One length within a cable type's group — e.g. "3 × 10ft". `distance`
@@ -59,7 +59,7 @@ export interface Bom {
}
function deviceGroupKey(device: Device): string {
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}`
return device.templateId ?? `${device.category}:${device.manufacturerId ?? ''}:${device.model ?? device.name}`
}
/** A connection's cost for BOM purposes: its own override if set, else
@@ -82,8 +82,25 @@ function connectionCost(connection: Connection, cableType: CableType | undefined
return undefined
}
/** Every port id, across all devices, with a built-in/captive cable — a
* connection touching either end of one doesn't need a cable purchased
* (organized-ideas.md-adjacent decision: the cable's already paid for as
* part of the device, like a keyboard's USB cable or a budget AVR's power
* cord). Built once per computeBom call rather than searched per
* connection. */
function builtInPortIds(devices: Device[]): Set<string> {
const ids = new Set<string>()
for (const device of devices) {
for (const port of device.ports) {
if (port.builtInCable) ids.add(port.id)
}
}
return ids
}
export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
const builtIn = builtInPortIds(diagram.devices)
const cableLines = new Map<string, BomCableLine>()
// Nested by cableTypeId, then by distance (undefined key for "no length
@@ -93,6 +110,10 @@ export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const lengthLines = new Map<string, Map<number | undefined, BomCableLengthLine>>()
for (const connection of diagram.connections) {
// A built-in cable is already paid for as part of the device — no line
// for it here, not even a "missing cost" note; the canvas is where this
// connection's built-in-ness is visible, not the shopping list.
if (builtIn.has(connection.sourcePortId) || builtIn.has(connection.targetPortId)) continue
const cableType = cableTypes.get(connection.cableTypeId)
const name = cableType?.name ?? 'Unknown Cable'
const unit = cableType?.unit ?? 'ft'
@@ -161,7 +182,7 @@ export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
key,
name: device.name,
category: device.category,
manufacturer: device.manufacturer,
manufacturer: manufacturerName(catalog, device.manufacturerId),
model: device.model,
count: 0,
totalCost: 0,
+26 -2
View File
@@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid'
import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, PortType } from './types'
import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, Manufacturer, PortType } from './types'
export function allPortTypes(catalog: Catalog): PortType[] {
return catalog.portTypes
@@ -35,6 +35,22 @@ export function deviceCategoryName(catalog: Catalog, categoryId: string): string
return getDeviceCategory(catalog, categoryId)?.name ?? categoryId
}
export function allManufacturers(catalog: Catalog): Manufacturer[] {
return catalog.manufacturers
}
export function getManufacturer(catalog: Catalog, manufacturerId: string): Manufacturer | undefined {
return allManufacturers(catalog).find((m) => m.id === manufacturerId)
}
/** Friendly name for a manufacturer id — unlike `deviceCategoryName`,
* `manufacturerId` is optional (not every device has a known manufacturer),
* so this returns undefined rather than a fallback string when it's unset
* or, for older data, references a manufacturer that no longer exists. */
export function manufacturerName(catalog: Catalog, manufacturerId: string | undefined): string | undefined {
return manufacturerId ? getManufacturer(catalog, manufacturerId)?.name : undefined
}
export function getPortType(catalog: Catalog, portTypeId: string): PortType | undefined {
return allPortTypes(catalog).find((pt) => pt.id === portTypeId)
}
@@ -43,6 +59,14 @@ export function getCableType(catalog: Catalog, cableTypeId: string): CableType |
return allCableTypes(catalog).find((ct) => ct.id === cableTypeId)
}
/** Best-effort friendly label for a raw family string: the name of whichever
* port type owns that family, or the raw string itself if none matches
* (e.g. a family that only ever existed as hand-typed text, from before
* FamilySelect started sourcing new families from an existing port type). */
export function familyLabel(portTypes: PortType[], family: string): string {
return portTypes.find((pt) => pt.family === family)?.name ?? family
}
/** The set of connector families a port type will mate with: its own family
* plus anything it explicitly lists as also-compatible. Shared with
* `compatibility.ts` so "does A connect to B" and "which cable fits" agree. */
@@ -145,7 +169,7 @@ export function createDeviceFromTemplate(template: DeviceTemplate, position: { x
name: template.name,
templateId: template.id,
category: template.category,
manufacturer: template.manufacturer,
manufacturerId: template.manufacturerId,
model: template.model,
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
cost: template.cost,
+25 -2
View File
@@ -28,6 +28,16 @@ export interface DeviceCategoryDef {
custom?: boolean
}
/** A device manufacturer/brand, normalized the same way as DeviceCategoryDef
* (organized-ideas.md §3) rather than free text on each device — gives the
* Admin review queue's duplicate-detection nudge a reliable signal
* (matching manufacturer + similar model) that name-matching alone can't. */
export interface Manufacturer {
id: string
name: string
custom?: boolean
}
/**
* A kind of physical connector (HDMI, XLR, Cat6/RJ45, ...). Compatibility
* between two ports is decided by comparing `family` (plus optional
@@ -54,6 +64,14 @@ export interface Port {
name: string
direction: PortDirection
portTypeId: string
/** This port already has a captive/permanently-attached cable (a
* keyboard's USB cable, a budget AV receiver's power cord, ...) — a
* property of the physical port, not of any one wiring choice, so it
* lives here rather than on Connection. Any connection touching a port
* with this set doesn't need a separate purchased cable: the BOM omits
* it entirely, though the canvas still draws the link (styled
* differently) since the wiring topology is still real. */
builtInCable?: boolean
}
/** A reusable, non-instantiated device definition (shown in the palette). */
@@ -61,7 +79,9 @@ export interface DeviceTemplate {
id: string
name: string
category: DeviceCategory
manufacturer?: string
/** References a Manufacturer catalog entry — optional since not every
* device has a known/chosen manufacturer (e.g. DIY or generic gear). */
manufacturerId?: string
model?: string
ports: Port[]
/** Suggested default cost, copied onto each placed Device at creation time
@@ -82,7 +102,9 @@ export interface Device {
name: string
templateId?: string
category: DeviceCategory
manufacturer?: string
/** References a Manufacturer catalog entry, same as the template's —
* independently editable per instance afterward, like category/model. */
manufacturerId?: string
model?: string
notes?: string
ports: Port[]
@@ -156,5 +178,6 @@ export interface Catalog {
portTypes: PortType[]
cableTypes: CableType[]
deviceCategories: DeviceCategoryDef[]
manufacturers: Manufacturer[]
deviceTemplates: DeviceTemplate[]
}