Add BOM cable length sub-grouping
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.
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react'
|
import { Fragment, useMemo, useState } from 'react'
|
||||||
import { computeBom } from '../../domain/bom'
|
import { computeBom } from '../../domain/bom'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
import Chevron from '../common/Chevron'
|
||||||
|
|
||||||
function formatCost(value: number): string {
|
function formatCost(value: number): string {
|
||||||
return `$${value.toFixed(2)}`
|
return `$${value.toFixed(2)}`
|
||||||
@@ -11,6 +12,19 @@ export default function BomPanel() {
|
|||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const bom = useMemo(() => computeBom(project, catalog), [project, 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<Set<string>>(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 =
|
const hasMissingCost =
|
||||||
bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0)
|
bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0)
|
||||||
@@ -72,28 +86,66 @@ export default function BomPanel() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{bom.cables.map((line) => (
|
{bom.cables.map((line) => {
|
||||||
<tr key={line.cableTypeId} className="border-b border-slate-100 last:border-0">
|
const expandable = line.lengths.length > 1
|
||||||
<td className="py-1 pr-2 font-medium text-slate-700">{line.cableTypeName}</td>
|
const expanded = expandable && expandedCableTypeIds.has(line.cableTypeId)
|
||||||
<td className="py-1 text-right text-slate-600">×{line.count}</td>
|
return (
|
||||||
<td className="py-1 text-right text-slate-600">
|
<Fragment key={line.cableTypeId}>
|
||||||
{line.totalLength > 0 ? `${line.totalLength}${line.unit}` : '—'}
|
<tr className="border-b border-slate-100 last:border-0">
|
||||||
{line.connectionsMissingLength > 0 && (
|
<td className="py-1 pr-2 font-medium text-slate-700">
|
||||||
<span className="ml-1 text-[10px] text-amber-500" title="Some cables are missing a distance">
|
{expandable ? (
|
||||||
({line.connectionsMissingLength} unset)
|
<button
|
||||||
</span>
|
onClick={() => toggleCableType(line.cableTypeId)}
|
||||||
)}
|
className="flex items-center gap-1 hover:text-indigo-700"
|
||||||
</td>
|
>
|
||||||
<td className="py-1 text-right text-slate-600">
|
<Chevron expanded={expanded} className="text-slate-400" />
|
||||||
{line.totalCost > 0 ? formatCost(line.totalCost) : '—'}
|
{line.cableTypeName}
|
||||||
{line.connectionsMissingCost > 0 && (
|
</button>
|
||||||
<span className="ml-1 text-[10px] text-amber-500" title="Some cables are missing a computable cost">
|
) : (
|
||||||
({line.connectionsMissingCost} unset)
|
<span className="pl-[1.1rem]">{line.cableTypeName}</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</td>
|
||||||
</td>
|
<td className="py-1 text-right text-slate-600">×{line.count}</td>
|
||||||
</tr>
|
<td className="py-1 text-right text-slate-600">
|
||||||
))}
|
{line.totalLength > 0 ? `${line.totalLength}${line.unit}` : '—'}
|
||||||
|
{line.connectionsMissingLength > 0 && (
|
||||||
|
<span className="ml-1 text-[10px] text-amber-500" title="Some cables are missing a distance">
|
||||||
|
({line.connectionsMissingLength} unset)
|
||||||
|
</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>
|
||||||
|
{expanded &&
|
||||||
|
line.lengths.map((lengthLine) => (
|
||||||
|
<tr key={`${line.cableTypeId}-${lengthLine.distance ?? 'unset'}`} className="border-b border-slate-50 last:border-0">
|
||||||
|
<td className="py-0.5 pl-6 pr-2 text-slate-500">
|
||||||
|
{lengthLine.distance !== undefined ? `${lengthLine.distance}${line.unit}` : 'No length set'}
|
||||||
|
</td>
|
||||||
|
<td className="py-0.5 text-right text-slate-500">×{lengthLine.count}</td>
|
||||||
|
<td className="py-0.5 text-right text-slate-500">
|
||||||
|
{lengthLine.totalLength > 0 ? `${lengthLine.totalLength}${line.unit}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-0.5 text-right text-slate-500">
|
||||||
|
{lengthLine.totalCost > 0 ? formatCost(lengthLine.totalCost) : '—'}
|
||||||
|
{lengthLine.connectionsMissingCost > 0 && (
|
||||||
|
<span className="ml-1 text-[10px] text-amber-500" title="Some cables are missing a computable cost">
|
||||||
|
({lengthLine.connectionsMissingCost} unset)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+57
-2
@@ -1,6 +1,18 @@
|
|||||||
import { allCableTypes } from './project'
|
import { allCableTypes } from './project'
|
||||||
import type { CableType, Catalog, Connection, Device, Project } from './types'
|
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 {
|
export interface BomCableLine {
|
||||||
cableTypeId: string
|
cableTypeId: string
|
||||||
cableTypeName: string
|
cableTypeName: string
|
||||||
@@ -14,6 +26,10 @@ export interface BomCableLine {
|
|||||||
* excluded rather than counted as zero. */
|
* excluded rather than counted as zero. */
|
||||||
totalCost: number
|
totalCost: number
|
||||||
connectionsMissingCost: 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 {
|
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 cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
|
||||||
|
|
||||||
const cableLines = new Map<string, BomCableLine>()
|
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) {
|
for (const connection of project.connections) {
|
||||||
const cableType = cableTypes.get(connection.cableTypeId)
|
const cableType = cableTypes.get(connection.cableTypeId)
|
||||||
const name = cableType?.name ?? 'Unknown Cable'
|
const name = cableType?.name ?? 'Unknown Cable'
|
||||||
@@ -83,10 +105,13 @@ export function computeBom(project: Project, catalog: Catalog): Bom {
|
|||||||
connectionsMissingLength: 0,
|
connectionsMissingLength: 0,
|
||||||
totalCost: 0,
|
totalCost: 0,
|
||||||
connectionsMissingCost: 0,
|
connectionsMissingCost: 0,
|
||||||
|
lengths: [],
|
||||||
}
|
}
|
||||||
existing.count += 1
|
existing.count += 1
|
||||||
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) {
|
const distance =
|
||||||
existing.totalLength += connection.distance
|
typeof connection.distance === 'number' && !Number.isNaN(connection.distance) ? connection.distance : undefined
|
||||||
|
if (distance !== undefined) {
|
||||||
|
existing.totalLength += distance
|
||||||
} else {
|
} else {
|
||||||
existing.connectionsMissingLength += 1
|
existing.connectionsMissingLength += 1
|
||||||
}
|
}
|
||||||
@@ -97,6 +122,36 @@ export function computeBom(project: Project, catalog: Catalog): Bom {
|
|||||||
existing.connectionsMissingCost += 1
|
existing.connectionsMissingCost += 1
|
||||||
}
|
}
|
||||||
cableLines.set(connection.cableTypeId, existing)
|
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>()
|
const deviceLines = new Map<string, BomDeviceLine>()
|
||||||
|
|||||||
Reference in New Issue
Block a user