Per organized-ideas.md §7: cables of the same type sharing the same length now get their own subtotal (count + total length + cost), revealed by expanding that cable type's row rather than only showing one blended total. Closes out the last open item in §7 — cost input, per-diagram override, and the grand total were already in place. - domain/bom.ts: computeBom groups each cable type's connections by distance too (a separate bucket for connections with no length entered), sorted shortest first. - BomPanel: a chevron toggles the breakdown, only shown when a cable type actually has more than one distinct length group (nothing to reveal otherwise). Verified: tsc -b and oxlint clean. No backend involved.
192 lines
6.7 KiB
TypeScript
192 lines
6.7 KiB
TypeScript
import { allCableTypes } from './project'
|
||
import type { CableType, Catalog, Connection, Device, Project } from './types'
|
||
|
||
/** One length within a cable type's group — e.g. "3 × 10ft". `distance`
|
||
* is undefined for the bucket of connections that have no length entered
|
||
* (kept separate rather than folded into a 0ft bucket, same reasoning as
|
||
* connectionsMissingLength on the parent line). */
|
||
export interface BomCableLengthLine {
|
||
distance: number | undefined
|
||
count: number
|
||
totalLength: number
|
||
totalCost: number
|
||
connectionsMissingCost: number
|
||
}
|
||
|
||
export interface BomCableLine {
|
||
cableTypeId: string
|
||
cableTypeName: string
|
||
unit: string
|
||
count: number
|
||
/** 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
|
||
/** This type's connections grouped by length, most common use case for
|
||
* "how many N-foot cables do I need to buy" — see organized-ideas.md §7.
|
||
* Sorted shortest first, with the no-length-entered bucket (if any) last. */
|
||
lengths: BomCableLengthLine[]
|
||
}
|
||
|
||
export interface BomDeviceLine {
|
||
key: string
|
||
name: string
|
||
category: string
|
||
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, catalog: Catalog): Bom {
|
||
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
|
||
|
||
const cableLines = new Map<string, BomCableLine>()
|
||
// Nested by cableTypeId, then by distance (undefined key for "no length
|
||
// entered") — a separate map from cableLines since a Map key can't itself
|
||
// be a nested Map, and it's convenient to keep the length-line objects
|
||
// addressable by (cableTypeId, distance) while building them up.
|
||
const lengthLines = new Map<string, Map<number | undefined, BomCableLengthLine>>()
|
||
|
||
for (const connection of project.connections) {
|
||
const cableType = cableTypes.get(connection.cableTypeId)
|
||
const name = cableType?.name ?? 'Unknown Cable'
|
||
const unit = cableType?.unit ?? 'ft'
|
||
const existing = cableLines.get(connection.cableTypeId) ?? {
|
||
cableTypeId: connection.cableTypeId,
|
||
cableTypeName: name,
|
||
unit,
|
||
count: 0,
|
||
totalLength: 0,
|
||
connectionsMissingLength: 0,
|
||
totalCost: 0,
|
||
connectionsMissingCost: 0,
|
||
lengths: [],
|
||
}
|
||
existing.count += 1
|
||
const distance =
|
||
typeof connection.distance === 'number' && !Number.isNaN(connection.distance) ? connection.distance : undefined
|
||
if (distance !== undefined) {
|
||
existing.totalLength += distance
|
||
} else {
|
||
existing.connectionsMissingLength += 1
|
||
}
|
||
const cost = connectionCost(connection, cableType)
|
||
if (cost !== undefined) {
|
||
existing.totalCost += cost
|
||
} else {
|
||
existing.connectionsMissingCost += 1
|
||
}
|
||
cableLines.set(connection.cableTypeId, existing)
|
||
|
||
const byLength = lengthLines.get(connection.cableTypeId) ?? new Map<number | undefined, BomCableLengthLine>()
|
||
const lengthLine = byLength.get(distance) ?? {
|
||
distance,
|
||
count: 0,
|
||
totalLength: 0,
|
||
totalCost: 0,
|
||
connectionsMissingCost: 0,
|
||
}
|
||
lengthLine.count += 1
|
||
if (distance !== undefined) lengthLine.totalLength += distance
|
||
if (cost !== undefined) {
|
||
lengthLine.totalCost += cost
|
||
} else {
|
||
lengthLine.connectionsMissingCost += 1
|
||
}
|
||
byLength.set(distance, lengthLine)
|
||
lengthLines.set(connection.cableTypeId, byLength)
|
||
}
|
||
|
||
for (const [cableTypeId, line] of cableLines) {
|
||
const byLength = [...(lengthLines.get(cableTypeId)?.values() ?? [])]
|
||
// Shortest first; the no-length-entered bucket (distance undefined)
|
||
// always sorts last rather than mixing in as "smallest".
|
||
byLength.sort((a, b) => {
|
||
if (a.distance === undefined) return 1
|
||
if (b.distance === undefined) return -1
|
||
return a.distance - b.distance
|
||
})
|
||
line.lengths = byLength
|
||
}
|
||
|
||
const deviceLines = new Map<string, BomDeviceLine>()
|
||
for (const device of project.devices) {
|
||
const key = deviceGroupKey(device)
|
||
const existing = deviceLines.get(key) ?? {
|
||
key,
|
||
name: device.name,
|
||
category: device.category,
|
||
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,
|
||
devices,
|
||
totalCableCost,
|
||
totalDeviceCost,
|
||
grandTotalCost: totalCableCost + totalDeviceCost,
|
||
}
|
||
}
|