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 { 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<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 =
|
||||
bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0)
|
||||
@@ -72,28 +86,66 @@ export default function BomPanel() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bom.cables.map((line) => (
|
||||
<tr key={line.cableTypeId} className="border-b border-slate-100 last:border-0">
|
||||
<td className="py-1 pr-2 font-medium text-slate-700">{line.cableTypeName}</td>
|
||||
<td className="py-1 text-right text-slate-600">×{line.count}</td>
|
||||
<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>
|
||||
))}
|
||||
{bom.cables.map((line) => {
|
||||
const expandable = line.lengths.length > 1
|
||||
const expanded = expandable && expandedCableTypeIds.has(line.cableTypeId)
|
||||
return (
|
||||
<Fragment key={line.cableTypeId}>
|
||||
<tr className="border-b border-slate-100 last:border-0">
|
||||
<td className="py-1 pr-2 font-medium text-slate-700">
|
||||
{expandable ? (
|
||||
<button
|
||||
onClick={() => toggleCableType(line.cableTypeId)}
|
||||
className="flex items-center gap-1 hover:text-indigo-700"
|
||||
>
|
||||
<Chevron expanded={expanded} className="text-slate-400" />
|
||||
{line.cableTypeName}
|
||||
</button>
|
||||
) : (
|
||||
<span className="pl-[1.1rem]">{line.cableTypeName}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 text-right text-slate-600">×{line.count}</td>
|
||||
<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>
|
||||
</table>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user