Add BOM cost tracking

Suggested-default cost on CableType/DeviceTemplate, copied onto
placed Device instances (same pattern as ports) and overridable
per-connection/per-device in the diagram. BOM view shows per-line
and grand-total cost, excluding (and flagging) anything without a
computable cost rather than treating it as zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-04 15:55:57 -05:00
co-authored by Claude Sonnet 5
parent 7cf786e107
commit e8654a39d6
9 changed files with 242 additions and 11 deletions
+45
View File
@@ -2,10 +2,17 @@ import { useMemo } from 'react'
import { computeBom } from '../../domain/bom'
import { useProjectStore } from '../../state/projectStore'
function formatCost(value: number): string {
return `$${value.toFixed(2)}`
}
export default function BomPanel() {
const project = useProjectStore((s) => s.project)
const bom = useMemo(() => computeBom(project), [project])
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>
@@ -14,6 +21,13 @@ export default function BomPanel() {
<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">
@@ -26,6 +40,14 @@ export default function BomPanel() {
)}
</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>
@@ -44,6 +66,7 @@ export default function BomPanel() {
<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>
@@ -59,12 +82,34 @@ export default function BomPanel() {
</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>
))}
</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>
)
}