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.
170 lines
8.0 KiB
TypeScript
170 lines
8.0 KiB
TypeScript
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>
|
||
)
|
||
}
|