diff --git a/src/domain/bom.ts b/src/domain/bom.ts
index 426ecc9..c80f0e1 100644
--- a/src/domain/bom.ts
+++ b/src/domain/bom.ts
@@ -1,5 +1,5 @@
import { allCableTypes } from './project'
-import type { Device, Project } from './types'
+import type { CableType, Connection, Device, Project } from './types'
export interface BomCableLine {
cableTypeId: string
@@ -9,6 +9,11 @@ export interface BomCableLine {
/** Sum of user-entered distances; connections with no distance are excluded. */
totalLength: number
connectionsMissingLength: number
+ /** Sum of each connection's cost (its own override, or `costPerUnit *
+ * distance` when both are known); connections with no computable cost are
+ * excluded rather than counted as zero. */
+ totalCost: number
+ connectionsMissingCost: number
}
export interface BomDeviceLine {
@@ -18,17 +23,49 @@ export interface BomDeviceLine {
manufacturer?: string
model?: string
count: number
+ /** Sum of `device.cost` across instances; devices with no cost set are
+ * excluded rather than counted as zero. */
+ totalCost: number
+ devicesMissingCost: number
}
export interface Bom {
cables: BomCableLine[]
devices: BomDeviceLine[]
+ /** Sum of all cable line totals (missing-cost connections already excluded). */
+ totalCableCost: number
+ /** Sum of all device line totals (missing-cost devices already excluded). */
+ totalDeviceCost: number
+ /** `totalCableCost + totalDeviceCost`. Missing costs are excluded, not
+ * treated as zero, so this is a lower bound whenever anything is flagged
+ * as missing below. */
+ grandTotalCost: number
}
function deviceGroupKey(device: Device): string {
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}`
}
+/** A connection's cost for BOM purposes: its own override if set, else
+ * `costPerUnit * distance` if both the cable type's rate and the
+ * connection's distance are known. Undefined means "not computable" —
+ * callers must exclude it from totals rather than treating it as zero. */
+function connectionCost(connection: Connection, cableType: CableType | undefined): number | undefined {
+ if (typeof connection.cost === 'number' && !Number.isNaN(connection.cost)) {
+ return connection.cost
+ }
+ if (
+ cableType &&
+ typeof cableType.costPerUnit === 'number' &&
+ !Number.isNaN(cableType.costPerUnit) &&
+ typeof connection.distance === 'number' &&
+ !Number.isNaN(connection.distance)
+ ) {
+ return cableType.costPerUnit * connection.distance
+ }
+ return undefined
+}
+
export function computeBom(project: Project): Bom {
const cableTypes = new Map(allCableTypes(project).map((ct) => [ct.id, ct]))
@@ -44,6 +81,8 @@ export function computeBom(project: Project): Bom {
count: 0,
totalLength: 0,
connectionsMissingLength: 0,
+ totalCost: 0,
+ connectionsMissingCost: 0,
}
existing.count += 1
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) {
@@ -51,6 +90,12 @@ export function computeBom(project: Project): Bom {
} else {
existing.connectionsMissingLength += 1
}
+ const cost = connectionCost(connection, cableType)
+ if (cost !== undefined) {
+ existing.totalCost += cost
+ } else {
+ existing.connectionsMissingCost += 1
+ }
cableLines.set(connection.cableTypeId, existing)
}
@@ -64,13 +109,28 @@ export function computeBom(project: Project): Bom {
manufacturer: device.manufacturer,
model: device.model,
count: 0,
+ totalCost: 0,
+ devicesMissingCost: 0,
}
existing.count += 1
+ if (typeof device.cost === 'number' && !Number.isNaN(device.cost)) {
+ existing.totalCost += device.cost
+ } else {
+ existing.devicesMissingCost += 1
+ }
deviceLines.set(key, existing)
}
+ const cables = [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName))
+ const devices = [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name))
+ const totalCableCost = cables.reduce((sum, line) => sum + line.totalCost, 0)
+ const totalDeviceCost = devices.reduce((sum, line) => sum + line.totalCost, 0)
+
return {
- cables: [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName)),
- devices: [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name)),
+ cables,
+ devices,
+ totalCableCost,
+ totalDeviceCost,
+ grandTotalCost: totalCableCost + totalDeviceCost,
}
}
diff --git a/src/domain/project.ts b/src/domain/project.ts
index 97990ce..a03dfd3 100644
--- a/src/domain/project.ts
+++ b/src/domain/project.ts
@@ -172,6 +172,7 @@ export function createDeviceFromTemplate(
manufacturer: template.manufacturer,
model: template.model,
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
+ cost: template.cost,
position,
}
}
diff --git a/src/domain/types.ts b/src/domain/types.ts
index 4445ee5..73af5aa 100644
--- a/src/domain/types.ts
+++ b/src/domain/types.ts
@@ -63,6 +63,9 @@ export interface DeviceTemplate {
manufacturer?: string
model?: string
ports: Port[]
+ /** Suggested default cost, copied onto each placed Device at creation time
+ * (same as ports) and independently editable per instance afterward. */
+ cost?: number
builtIn?: boolean
}
@@ -77,6 +80,10 @@ export interface Device {
model?: string
notes?: string
ports: Port[]
+ /** Copied from the template's suggested cost when placed (like ports),
+ * then independently editable — editing an instance never mutates the
+ * shared template. */
+ cost?: number
position: { x: number; y: number }
}
@@ -95,6 +102,9 @@ export interface CableType {
* not just where this particular cable is actually used). */
family2?: string
unit: 'ft' | 'm'
+ /** Suggested default cost per unit length (in `unit`). Each Connection can
+ * override the computed cost for its specific run (`Connection.cost`),
+ * which always wins — public catalog prices drift and vary by vendor. */
costPerUnit?: number
custom?: boolean
}
@@ -110,6 +120,11 @@ export interface Connection {
/** User-entered run length, in the CableType's unit. Optional because
* real-world layout isn't modeled (no floor plan / scale). */
distance?: number
+ /** Override for this specific run's total cost — takes precedence over
+ * computing from the CableType's `costPerUnit * distance`, since a
+ * diagram-level price should always win over the library's suggested
+ * default. */
+ cost?: number
label?: string
notes?: string
}
diff --git a/src/state/projectStore.ts b/src/state/projectStore.ts
index 1cd812b..8842ea9 100644
--- a/src/state/projectStore.ts
+++ b/src/state/projectStore.ts
@@ -28,7 +28,10 @@ interface ProjectStoreState {
addDeviceFromTemplate: (templateId: string, position: { x: number; y: number }) => void
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
- updateDevice: (id: string, patch: Partial
>) => void
+ updateDevice: (
+ id: string,
+ patch: Partial>,
+ ) => void
removeDevice: (id: string) => void
/** Returns the new port's id, so the caller can move keyboard focus to it. */
@@ -46,7 +49,10 @@ interface ProjectStoreState {
targetPortId: string
cableTypeId: string
}) => boolean
- updateConnection: (id: string, patch: Partial>) => void
+ updateConnection: (
+ id: string,
+ patch: Partial>,
+ ) => void
removeConnection: (id: string) => void
addCustomDeviceTemplate: (template: Omit) => void