Add BOM cost tracking

Suggested-default cost on CableType/DeviceTemplate, copied onto
placed Device instances (same pattern as ports) and overridable
per-connection/per-device in the diagram. BOM view shows per-line
and grand-total cost, excluding (and flagging) anything without a
computable cost rather than treating it as zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-04 15:55:57 -05:00
co-authored by Claude Sonnet 5
parent 7cf786e107
commit e8654a39d6
9 changed files with 242 additions and 11 deletions
+45
View File
@@ -2,10 +2,17 @@ import { useMemo } from 'react'
import { computeBom } from '../../domain/bom'
import { useProjectStore } from '../../state/projectStore'
function formatCost(value: number): string {
return `$${value.toFixed(2)}`
}
export default function BomPanel() {
const project = useProjectStore((s) => s.project)
const bom = useMemo(() => computeBom(project), [project])
const hasMissingCost =
bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0)
return (
<div className="space-y-4 p-3">
<section>
@@ -14,6 +21,13 @@ export default function BomPanel() {
<p className="text-xs italic text-slate-400">No devices on the canvas yet.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wide text-slate-400">
<th className="pb-1 font-medium">Device</th>
<th className="pb-1 text-right font-medium">Count</th>
<th className="pb-1 text-right font-medium">Cost</th>
</tr>
</thead>
<tbody>
{bom.devices.map((line) => (
<tr key={line.key} className="border-b border-slate-100 last:border-0">
@@ -26,6 +40,14 @@ export default function BomPanel() {
)}
</td>
<td className="py-1 text-right font-medium text-slate-600">×{line.count}</td>
<td className="py-1 text-right text-slate-600">
{line.totalCost > 0 ? formatCost(line.totalCost) : '—'}
{line.devicesMissingCost > 0 && (
<span className="ml-1 text-[10px] text-amber-500" title="Some devices are missing a cost">
({line.devicesMissingCost} unset)
</span>
)}
</td>
</tr>
))}
</tbody>
@@ -44,6 +66,7 @@ export default function BomPanel() {
<th className="pb-1 font-medium">Cable</th>
<th className="pb-1 text-right font-medium">Count</th>
<th className="pb-1 text-right font-medium">Total length</th>
<th className="pb-1 text-right font-medium">Cost</th>
</tr>
</thead>
<tbody>
@@ -59,12 +82,34 @@ export default function BomPanel() {
</span>
)}
</td>
<td className="py-1 text-right text-slate-600">
{line.totalCost > 0 ? formatCost(line.totalCost) : '—'}
{line.connectionsMissingCost > 0 && (
<span className="ml-1 text-[10px] text-amber-500" title="Some cables are missing a computable cost">
({line.connectionsMissingCost} unset)
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{(bom.devices.length > 0 || bom.cables.length > 0) && (
<section className="border-t border-slate-200 pt-2">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold uppercase tracking-wide text-slate-500">Grand total</span>
<span className="font-semibold text-slate-700">{formatCost(bom.grandTotalCost)}</span>
</div>
{hasMissingCost && (
<p className="mt-0.5 text-[10px] text-amber-500">
Some items are missing a cost and are excluded from this total.
</p>
)}
</section>
)}
</div>
)
}
@@ -20,6 +20,8 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
return sourcePortType && targetPortType ? cableTypesForConnection(project, sourcePortType, targetPortType) : []
}, [project, sourcePort, targetPort])
const cableCostPerUnit = availableCableTypes.find((ct) => ct.id === connection?.cableTypeId)?.costPerUnit
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
return (
@@ -76,6 +78,33 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
</div>
</label>
<label className="block text-xs font-medium text-slate-600">
Cost override
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-slate-500">$</span>
<input
type="number"
min={0}
step={0.01}
value={connection.cost ?? ''}
onChange={(e) =>
updateConnection(connection.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={
cableCostPerUnit !== undefined && connection.distance
? `computed: $${(cableCostPerUnit * connection.distance).toFixed(2)}`
: 'e.g. 24.99'
}
/>
</div>
<p className="mt-1 text-[10px] text-slate-400">
Overrides the cable type's per-unit cost for this run specifically. Leave blank to use cost/unit × distance.
</p>
</label>
<label className="block text-xs font-medium text-slate-600">
Label
<input
+18 -1
View File
@@ -49,7 +49,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="col-span-2 text-xs font-medium text-slate-600">
<label className="text-xs font-medium text-slate-600">
Model
<input
value={device.model ?? ''}
@@ -57,6 +57,23 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
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
+45 -4
View File
@@ -21,7 +21,13 @@ function CableTypeForm({
initial?: CableType
portTypes: PortType[]
onCancel: () => void
onSave: (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => void
onSave: (fields: {
name: string
endAPortTypeId: string
endBPortTypeId: string
unit: CableType['unit']
costPerUnit?: number
}) => void
}) {
const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined
const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined
@@ -30,12 +36,19 @@ function CableTypeForm({
const [endAPortTypeId, setEndAPortTypeId] = useState(initialEndA?.id ?? portTypes[0]?.id ?? '')
const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '')
const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft')
const [costPerUnit, setCostPerUnit] = useState(initial?.costPerUnit?.toString() ?? '')
const isAdapter = endAPortTypeId !== endBPortTypeId
const handleSave = () => {
if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return
onSave({ name: name.trim(), endAPortTypeId, endBPortTypeId, unit })
onSave({
name: name.trim(),
endAPortTypeId,
endBPortTypeId,
unit,
costPerUnit: costPerUnit.trim() === '' ? undefined : Number(costPerUnit),
})
}
return (
@@ -87,6 +100,19 @@ function CableTypeForm({
? 'Different connector on each end — this becomes an adapter cable, letting those two port types wire together even if nothing else declares them compatible.'
: 'Same connector on each end — a regular cable for that connector type.'}
</p>
<div className="flex items-center gap-1.5">
<span className="shrink-0 text-[10px] font-medium text-slate-500">Suggested cost/{unit}</span>
<span className="text-xs text-slate-400">$</span>
<input
type="number"
min={0}
step={0.01}
value={costPerUnit}
onChange={(e) => setCostPerUnit(e.target.value)}
placeholder="optional"
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel
@@ -126,7 +152,13 @@ export default function CableTypesPanel() {
return a === b ? a : `${a} ↔ ${b}`
}
const handleCreate = (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => {
const handleCreate = (fields: {
name: string
endAPortTypeId: string
endBPortTypeId: string
unit: CableType['unit']
costPerUnit?: number
}) => {
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
if (!endA || !endB) return
@@ -135,13 +167,20 @@ export default function CableTypesPanel() {
family: endA.family,
family2: endA.family === endB.family ? undefined : endB.family,
unit: fields.unit,
costPerUnit: fields.costPerUnit,
})
setCreating(false)
}
const handleUpdate = (
id: string,
fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] },
fields: {
name: string
endAPortTypeId: string
endBPortTypeId: string
unit: CableType['unit']
costPerUnit?: number
},
) => {
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
@@ -151,6 +190,7 @@ export default function CableTypesPanel() {
family: endA.family,
family2: endA.family === endB.family ? undefined : endB.family,
unit: fields.unit,
costPerUnit: fields.costPerUnit,
})
setEditingId(null)
}
@@ -197,6 +237,7 @@ export default function CableTypesPanel() {
<div className="font-medium text-slate-700">{ct.name}</div>
<div className="truncate text-[10px] text-slate-400">
{describeEnds(ct)} · {ct.unit}
{typeof ct.costPerUnit === 'number' && ` · $${ct.costPerUnit.toFixed(2)}/${ct.unit}`}
</div>
</div>
<button
@@ -45,6 +45,7 @@ export default function DeviceTemplateEditor({
const [category, setCategory] = useState<string>(template?.category ?? 'other')
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '')
const [model, setModel] = useState(template?.model ?? '')
const [cost, setCost] = useState(template?.cost?.toString() ?? '')
const [ports, setPorts] = useState<DraftPort[]>(
() =>
template?.ports.map((p) => ({ key: p.id, name: p.name, direction: p.direction, portTypeId: p.portTypeId })) ?? [
@@ -120,6 +121,7 @@ export default function DeviceTemplateEditor({
category,
manufacturer: manufacturer.trim() || undefined,
model: model.trim() || undefined,
cost: cost.trim() === '' ? undefined : Number(cost),
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })),
}
if (template) {
@@ -156,7 +158,7 @@ export default function DeviceTemplateEditor({
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="col-span-2 text-xs font-medium text-slate-600">
<label className="text-xs font-medium text-slate-600">
Model
<input
value={model}
@@ -164,6 +166,21 @@ export default function DeviceTemplateEditor({
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">
Suggested 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={cost}
onChange={(e) => setCost(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder="e.g. 249.00"
/>
</div>
</label>
</div>
<div>
+63 -3
View File
@@ -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,
}
}
+1
View File
@@ -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,
}
}
+15
View File
@@ -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
}
+8 -2
View File
@@ -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<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes'>>) => void
updateDevice: (
id: string,
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes' | 'cost'>>,
) => 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<Pick<Connection, 'cableTypeId' | 'distance' | 'label' | 'notes'>>) => void
updateConnection: (
id: string,
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
) => void
removeConnection: (id: string) => void
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'builtIn'>) => void