Files
av-planner/src/components/bom/BomPanel.tsx
T
aarbit cbbe96f575 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.
2026-09-11 11:54:33 -05:00

170 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)}`
}
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)
return (
<div className="space-y-4 p-3">
<section>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h4>
{bom.devices.length === 0 ? (
<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">
<td className="py-1 pr-2">
<div className="font-medium text-slate-700">{line.name}</div>
{(line.manufacturer || line.model) && (
<div className="text-[10px] text-slate-400">
{[line.manufacturer, line.model].filter(Boolean).join(' ')}
</div>
)}
</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>
</table>
)}
</section>
<section>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Cables</h4>
{bom.cables.length === 0 ? (
<p className="text-xs italic text-slate-400">No cables wired up 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">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>
{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>
)}
</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>
)
}