From cbbe96f575ac7ccb6e3814fd289d8f5a03d5b904 Mon Sep 17 00:00:00 2001 From: aarbit Date: Fri, 11 Sep 2026 11:54:33 -0500 Subject: [PATCH] Add BOM cable length sub-grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/bom/BomPanel.tsx | 98 +++++++++++++++++++++++++-------- src/domain/bom.ts | 59 +++++++++++++++++++- 2 files changed, 132 insertions(+), 25 deletions(-) diff --git a/src/components/bom/BomPanel.tsx b/src/components/bom/BomPanel.tsx index 05f949a..bd2574d 100644 --- a/src/components/bom/BomPanel.tsx +++ b/src/components/bom/BomPanel.tsx @@ -1,7 +1,8 @@ -import { useMemo } from 'react' +import { Fragment, useMemo, useState } from 'react' import { computeBom } from '../../domain/bom' import { useCatalogStore } from '../../state/catalogStore' import { useProjectStore } from '../../state/projectStore' +import Chevron from '../common/Chevron' function formatCost(value: number): string { return `$${value.toFixed(2)}` @@ -11,6 +12,19 @@ export default function BomPanel() { const project = useProjectStore((s) => s.project) const catalog = useCatalogStore((s) => s.catalog) const bom = useMemo(() => computeBom(project, catalog), [project, catalog]) + // Collapsed by default, per cable type — most diagrams only need the + // per-type totals; the length breakdown is there when you're ready to + // actually go buy cables. + const [expandedCableTypeIds, setExpandedCableTypeIds] = useState>(new Set()) + + const toggleCableType = (cableTypeId: string) => { + setExpandedCableTypeIds((prev) => { + const next = new Set(prev) + if (next.has(cableTypeId)) next.delete(cableTypeId) + else next.add(cableTypeId) + return next + }) + } const hasMissingCost = bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0) @@ -72,28 +86,66 @@ export default function BomPanel() { - {bom.cables.map((line) => ( - - {line.cableTypeName} - ×{line.count} - - {line.totalLength > 0 ? `${line.totalLength}${line.unit}` : '—'} - {line.connectionsMissingLength > 0 && ( - - ({line.connectionsMissingLength} unset) - - )} - - - {line.totalCost > 0 ? formatCost(line.totalCost) : '—'} - {line.connectionsMissingCost > 0 && ( - - ({line.connectionsMissingCost} unset) - - )} - - - ))} + {bom.cables.map((line) => { + const expandable = line.lengths.length > 1 + const expanded = expandable && expandedCableTypeIds.has(line.cableTypeId) + return ( + + + + {expandable ? ( + + ) : ( + {line.cableTypeName} + )} + + ×{line.count} + + {line.totalLength > 0 ? `${line.totalLength}${line.unit}` : '—'} + {line.connectionsMissingLength > 0 && ( + + ({line.connectionsMissingLength} unset) + + )} + + + {line.totalCost > 0 ? formatCost(line.totalCost) : '—'} + {line.connectionsMissingCost > 0 && ( + + ({line.connectionsMissingCost} unset) + + )} + + + {expanded && + line.lengths.map((lengthLine) => ( + + + {lengthLine.distance !== undefined ? `${lengthLine.distance}${line.unit}` : 'No length set'} + + ×{lengthLine.count} + + {lengthLine.totalLength > 0 ? `${lengthLine.totalLength}${line.unit}` : '—'} + + + {lengthLine.totalCost > 0 ? formatCost(lengthLine.totalCost) : '—'} + {lengthLine.connectionsMissingCost > 0 && ( + + ({lengthLine.connectionsMissingCost} unset) + + )} + + + ))} + + ) + })} )} diff --git a/src/domain/bom.ts b/src/domain/bom.ts index ac47d78..6bb1b39 100644 --- a/src/domain/bom.ts +++ b/src/domain/bom.ts @@ -1,6 +1,18 @@ 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 @@ -14,6 +26,10 @@ export interface BomCableLine { * 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 { @@ -70,6 +86,12 @@ export function computeBom(project: Project, catalog: Catalog): Bom { const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct])) const cableLines = new Map() + // 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>() + for (const connection of project.connections) { const cableType = cableTypes.get(connection.cableTypeId) const name = cableType?.name ?? 'Unknown Cable' @@ -83,10 +105,13 @@ export function computeBom(project: Project, catalog: Catalog): Bom { connectionsMissingLength: 0, totalCost: 0, connectionsMissingCost: 0, + lengths: [], } existing.count += 1 - if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) { - existing.totalLength += connection.distance + const distance = + typeof connection.distance === 'number' && !Number.isNaN(connection.distance) ? connection.distance : undefined + if (distance !== undefined) { + existing.totalLength += distance } else { existing.connectionsMissingLength += 1 } @@ -97,6 +122,36 @@ export function computeBom(project: Project, catalog: Catalog): Bom { existing.connectionsMissingCost += 1 } cableLines.set(connection.cableTypeId, existing) + + const byLength = lengthLines.get(connection.cableTypeId) ?? new Map() + 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()