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:
@@ -2,10 +2,17 @@ import { useMemo } from 'react'
|
|||||||
import { computeBom } from '../../domain/bom'
|
import { computeBom } from '../../domain/bom'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
|
||||||
|
function formatCost(value: number): string {
|
||||||
|
return `$${value.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function BomPanel() {
|
export default function BomPanel() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const bom = useMemo(() => computeBom(project), [project])
|
const bom = useMemo(() => computeBom(project), [project])
|
||||||
|
|
||||||
|
const hasMissingCost =
|
||||||
|
bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-3">
|
<div className="space-y-4 p-3">
|
||||||
<section>
|
<section>
|
||||||
@@ -14,6 +21,13 @@ export default function BomPanel() {
|
|||||||
<p className="text-xs italic text-slate-400">No devices on the canvas yet.</p>
|
<p className="text-xs italic text-slate-400">No devices on the canvas yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<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>
|
<tbody>
|
||||||
{bom.devices.map((line) => (
|
{bom.devices.map((line) => (
|
||||||
<tr key={line.key} className="border-b border-slate-100 last:border-0">
|
<tr key={line.key} className="border-b border-slate-100 last:border-0">
|
||||||
@@ -26,6 +40,14 @@ export default function BomPanel() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1 text-right font-medium text-slate-600">×{line.count}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -44,6 +66,7 @@ export default function BomPanel() {
|
|||||||
<th className="pb-1 font-medium">Cable</th>
|
<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">Count</th>
|
||||||
<th className="pb-1 text-right font-medium">Total length</th>
|
<th className="pb-1 text-right font-medium">Total length</th>
|
||||||
|
<th className="pb-1 text-right font-medium">Cost</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -59,12 +82,34 @@ export default function BomPanel() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
|
|||||||
return sourcePortType && targetPortType ? cableTypesForConnection(project, sourcePortType, targetPortType) : []
|
return sourcePortType && targetPortType ? cableTypesForConnection(project, sourcePortType, targetPortType) : []
|
||||||
}, [project, sourcePort, targetPort])
|
}, [project, sourcePort, targetPort])
|
||||||
|
|
||||||
|
const cableCostPerUnit = availableCableTypes.find((ct) => ct.id === connection?.cableTypeId)?.costPerUnit
|
||||||
|
|
||||||
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
|
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +78,33 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-xs font-medium text-slate-600">
|
||||||
|
Cost override
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={connection.cost ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConnection(connection.id, {
|
||||||
|
cost: e.target.value === '' ? undefined : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
placeholder={
|
||||||
|
cableCostPerUnit !== undefined && connection.distance
|
||||||
|
? `computed: $${(cableCostPerUnit * connection.distance).toFixed(2)}`
|
||||||
|
: 'e.g. 24.99'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-slate-400">
|
||||||
|
Overrides the cable type's per-unit cost for this run specifically. Leave blank to use cost/unit × distance.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="block text-xs font-medium text-slate-600">
|
<label className="block text-xs font-medium text-slate-600">
|
||||||
Label
|
Label
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
|||||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="col-span-2 text-xs font-medium text-slate-600">
|
<label className="text-xs font-medium text-slate-600">
|
||||||
Model
|
Model
|
||||||
<input
|
<input
|
||||||
value={device.model ?? ''}
|
value={device.model ?? ''}
|
||||||
@@ -57,6 +57,23 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
|||||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="text-xs font-medium text-slate-600">
|
||||||
|
Cost
|
||||||
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
<span className="text-xs text-slate-500">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={device.cost ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDevice(device.id, { cost: e.target.value === '' ? undefined : Number(e.target.value) })
|
||||||
|
}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
placeholder="e.g. 249.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<label className="col-span-2 text-xs font-medium text-slate-600">
|
<label className="col-span-2 text-xs font-medium text-slate-600">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ function CableTypeForm({
|
|||||||
initial?: CableType
|
initial?: CableType
|
||||||
portTypes: PortType[]
|
portTypes: PortType[]
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onSave: (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => void
|
onSave: (fields: {
|
||||||
|
name: string
|
||||||
|
endAPortTypeId: string
|
||||||
|
endBPortTypeId: string
|
||||||
|
unit: CableType['unit']
|
||||||
|
costPerUnit?: number
|
||||||
|
}) => void
|
||||||
}) {
|
}) {
|
||||||
const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined
|
const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined
|
||||||
const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined
|
const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined
|
||||||
@@ -30,12 +36,19 @@ function CableTypeForm({
|
|||||||
const [endAPortTypeId, setEndAPortTypeId] = useState(initialEndA?.id ?? portTypes[0]?.id ?? '')
|
const [endAPortTypeId, setEndAPortTypeId] = useState(initialEndA?.id ?? portTypes[0]?.id ?? '')
|
||||||
const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '')
|
const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '')
|
||||||
const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft')
|
const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft')
|
||||||
|
const [costPerUnit, setCostPerUnit] = useState(initial?.costPerUnit?.toString() ?? '')
|
||||||
|
|
||||||
const isAdapter = endAPortTypeId !== endBPortTypeId
|
const isAdapter = endAPortTypeId !== endBPortTypeId
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return
|
if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return
|
||||||
onSave({ name: name.trim(), endAPortTypeId, endBPortTypeId, unit })
|
onSave({
|
||||||
|
name: name.trim(),
|
||||||
|
endAPortTypeId,
|
||||||
|
endBPortTypeId,
|
||||||
|
unit,
|
||||||
|
costPerUnit: costPerUnit.trim() === '' ? undefined : Number(costPerUnit),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,6 +100,19 @@ function CableTypeForm({
|
|||||||
? 'Different connector on each end — this becomes an adapter cable, letting those two port types wire together even if nothing else declares them compatible.'
|
? 'Different connector on each end — this becomes an adapter cable, letting those two port types wire together even if nothing else declares them compatible.'
|
||||||
: 'Same connector on each end — a regular cable for that connector type.'}
|
: 'Same connector on each end — a regular cable for that connector type.'}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="shrink-0 text-[10px] font-medium text-slate-500">Suggested cost/{unit}</span>
|
||||||
|
<span className="text-xs text-slate-400">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={costPerUnit}
|
||||||
|
onChange={(e) => setCostPerUnit(e.target.value)}
|
||||||
|
placeholder="optional"
|
||||||
|
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button onClick={onCancel} className="text-[11px] text-slate-500">
|
<button onClick={onCancel} className="text-[11px] text-slate-500">
|
||||||
Cancel
|
Cancel
|
||||||
@@ -126,7 +152,13 @@ export default function CableTypesPanel() {
|
|||||||
return a === b ? a : `${a} ↔ ${b}`
|
return a === b ? a : `${a} ↔ ${b}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => {
|
const handleCreate = (fields: {
|
||||||
|
name: string
|
||||||
|
endAPortTypeId: string
|
||||||
|
endBPortTypeId: string
|
||||||
|
unit: CableType['unit']
|
||||||
|
costPerUnit?: number
|
||||||
|
}) => {
|
||||||
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
|
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
|
||||||
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
|
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
|
||||||
if (!endA || !endB) return
|
if (!endA || !endB) return
|
||||||
@@ -135,13 +167,20 @@ export default function CableTypesPanel() {
|
|||||||
family: endA.family,
|
family: endA.family,
|
||||||
family2: endA.family === endB.family ? undefined : endB.family,
|
family2: endA.family === endB.family ? undefined : endB.family,
|
||||||
unit: fields.unit,
|
unit: fields.unit,
|
||||||
|
costPerUnit: fields.costPerUnit,
|
||||||
})
|
})
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = (
|
const handleUpdate = (
|
||||||
id: string,
|
id: string,
|
||||||
fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] },
|
fields: {
|
||||||
|
name: string
|
||||||
|
endAPortTypeId: string
|
||||||
|
endBPortTypeId: string
|
||||||
|
unit: CableType['unit']
|
||||||
|
costPerUnit?: number
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
|
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
|
||||||
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
|
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
|
||||||
@@ -151,6 +190,7 @@ export default function CableTypesPanel() {
|
|||||||
family: endA.family,
|
family: endA.family,
|
||||||
family2: endA.family === endB.family ? undefined : endB.family,
|
family2: endA.family === endB.family ? undefined : endB.family,
|
||||||
unit: fields.unit,
|
unit: fields.unit,
|
||||||
|
costPerUnit: fields.costPerUnit,
|
||||||
})
|
})
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
}
|
}
|
||||||
@@ -197,6 +237,7 @@ export default function CableTypesPanel() {
|
|||||||
<div className="font-medium text-slate-700">{ct.name}</div>
|
<div className="font-medium text-slate-700">{ct.name}</div>
|
||||||
<div className="truncate text-[10px] text-slate-400">
|
<div className="truncate text-[10px] text-slate-400">
|
||||||
{describeEnds(ct)} · {ct.unit}
|
{describeEnds(ct)} · {ct.unit}
|
||||||
|
{typeof ct.costPerUnit === 'number' && ` · $${ct.costPerUnit.toFixed(2)}/${ct.unit}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function DeviceTemplateEditor({
|
|||||||
const [category, setCategory] = useState<string>(template?.category ?? 'other')
|
const [category, setCategory] = useState<string>(template?.category ?? 'other')
|
||||||
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '')
|
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '')
|
||||||
const [model, setModel] = useState(template?.model ?? '')
|
const [model, setModel] = useState(template?.model ?? '')
|
||||||
|
const [cost, setCost] = useState(template?.cost?.toString() ?? '')
|
||||||
const [ports, setPorts] = useState<DraftPort[]>(
|
const [ports, setPorts] = useState<DraftPort[]>(
|
||||||
() =>
|
() =>
|
||||||
template?.ports.map((p) => ({ key: p.id, name: p.name, direction: p.direction, portTypeId: p.portTypeId })) ?? [
|
template?.ports.map((p) => ({ key: p.id, name: p.name, direction: p.direction, portTypeId: p.portTypeId })) ?? [
|
||||||
@@ -120,6 +121,7 @@ export default function DeviceTemplateEditor({
|
|||||||
category,
|
category,
|
||||||
manufacturer: manufacturer.trim() || undefined,
|
manufacturer: manufacturer.trim() || undefined,
|
||||||
model: model.trim() || undefined,
|
model: model.trim() || undefined,
|
||||||
|
cost: cost.trim() === '' ? undefined : Number(cost),
|
||||||
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })),
|
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })),
|
||||||
}
|
}
|
||||||
if (template) {
|
if (template) {
|
||||||
@@ -156,7 +158,7 @@ export default function DeviceTemplateEditor({
|
|||||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="col-span-2 text-xs font-medium text-slate-600">
|
<label className="text-xs font-medium text-slate-600">
|
||||||
Model
|
Model
|
||||||
<input
|
<input
|
||||||
value={model}
|
value={model}
|
||||||
@@ -164,6 +166,21 @@ export default function DeviceTemplateEditor({
|
|||||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="text-xs font-medium text-slate-600">
|
||||||
|
Suggested cost
|
||||||
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
<span className="text-xs text-slate-500">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={cost}
|
||||||
|
onChange={(e) => setCost(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
placeholder="e.g. 249.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+63
-3
@@ -1,5 +1,5 @@
|
|||||||
import { allCableTypes } from './project'
|
import { allCableTypes } from './project'
|
||||||
import type { Device, Project } from './types'
|
import type { CableType, Connection, Device, Project } from './types'
|
||||||
|
|
||||||
export interface BomCableLine {
|
export interface BomCableLine {
|
||||||
cableTypeId: string
|
cableTypeId: string
|
||||||
@@ -9,6 +9,11 @@ export interface BomCableLine {
|
|||||||
/** Sum of user-entered distances; connections with no distance are excluded. */
|
/** Sum of user-entered distances; connections with no distance are excluded. */
|
||||||
totalLength: number
|
totalLength: number
|
||||||
connectionsMissingLength: number
|
connectionsMissingLength: number
|
||||||
|
/** Sum of each connection's cost (its own override, or `costPerUnit *
|
||||||
|
* distance` when both are known); connections with no computable cost are
|
||||||
|
* excluded rather than counted as zero. */
|
||||||
|
totalCost: number
|
||||||
|
connectionsMissingCost: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BomDeviceLine {
|
export interface BomDeviceLine {
|
||||||
@@ -18,17 +23,49 @@ export interface BomDeviceLine {
|
|||||||
manufacturer?: string
|
manufacturer?: string
|
||||||
model?: string
|
model?: string
|
||||||
count: number
|
count: number
|
||||||
|
/** Sum of `device.cost` across instances; devices with no cost set are
|
||||||
|
* excluded rather than counted as zero. */
|
||||||
|
totalCost: number
|
||||||
|
devicesMissingCost: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Bom {
|
export interface Bom {
|
||||||
cables: BomCableLine[]
|
cables: BomCableLine[]
|
||||||
devices: BomDeviceLine[]
|
devices: BomDeviceLine[]
|
||||||
|
/** Sum of all cable line totals (missing-cost connections already excluded). */
|
||||||
|
totalCableCost: number
|
||||||
|
/** Sum of all device line totals (missing-cost devices already excluded). */
|
||||||
|
totalDeviceCost: number
|
||||||
|
/** `totalCableCost + totalDeviceCost`. Missing costs are excluded, not
|
||||||
|
* treated as zero, so this is a lower bound whenever anything is flagged
|
||||||
|
* as missing below. */
|
||||||
|
grandTotalCost: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceGroupKey(device: Device): string {
|
function deviceGroupKey(device: Device): string {
|
||||||
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}`
|
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A connection's cost for BOM purposes: its own override if set, else
|
||||||
|
* `costPerUnit * distance` if both the cable type's rate and the
|
||||||
|
* connection's distance are known. Undefined means "not computable" —
|
||||||
|
* callers must exclude it from totals rather than treating it as zero. */
|
||||||
|
function connectionCost(connection: Connection, cableType: CableType | undefined): number | undefined {
|
||||||
|
if (typeof connection.cost === 'number' && !Number.isNaN(connection.cost)) {
|
||||||
|
return connection.cost
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
cableType &&
|
||||||
|
typeof cableType.costPerUnit === 'number' &&
|
||||||
|
!Number.isNaN(cableType.costPerUnit) &&
|
||||||
|
typeof connection.distance === 'number' &&
|
||||||
|
!Number.isNaN(connection.distance)
|
||||||
|
) {
|
||||||
|
return cableType.costPerUnit * connection.distance
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function computeBom(project: Project): Bom {
|
export function computeBom(project: Project): Bom {
|
||||||
const cableTypes = new Map(allCableTypes(project).map((ct) => [ct.id, ct]))
|
const cableTypes = new Map(allCableTypes(project).map((ct) => [ct.id, ct]))
|
||||||
|
|
||||||
@@ -44,6 +81,8 @@ export function computeBom(project: Project): Bom {
|
|||||||
count: 0,
|
count: 0,
|
||||||
totalLength: 0,
|
totalLength: 0,
|
||||||
connectionsMissingLength: 0,
|
connectionsMissingLength: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
connectionsMissingCost: 0,
|
||||||
}
|
}
|
||||||
existing.count += 1
|
existing.count += 1
|
||||||
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) {
|
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) {
|
||||||
@@ -51,6 +90,12 @@ export function computeBom(project: Project): Bom {
|
|||||||
} else {
|
} else {
|
||||||
existing.connectionsMissingLength += 1
|
existing.connectionsMissingLength += 1
|
||||||
}
|
}
|
||||||
|
const cost = connectionCost(connection, cableType)
|
||||||
|
if (cost !== undefined) {
|
||||||
|
existing.totalCost += cost
|
||||||
|
} else {
|
||||||
|
existing.connectionsMissingCost += 1
|
||||||
|
}
|
||||||
cableLines.set(connection.cableTypeId, existing)
|
cableLines.set(connection.cableTypeId, existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +109,28 @@ export function computeBom(project: Project): Bom {
|
|||||||
manufacturer: device.manufacturer,
|
manufacturer: device.manufacturer,
|
||||||
model: device.model,
|
model: device.model,
|
||||||
count: 0,
|
count: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
devicesMissingCost: 0,
|
||||||
}
|
}
|
||||||
existing.count += 1
|
existing.count += 1
|
||||||
|
if (typeof device.cost === 'number' && !Number.isNaN(device.cost)) {
|
||||||
|
existing.totalCost += device.cost
|
||||||
|
} else {
|
||||||
|
existing.devicesMissingCost += 1
|
||||||
|
}
|
||||||
deviceLines.set(key, existing)
|
deviceLines.set(key, existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cables = [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName))
|
||||||
|
const devices = [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
const totalCableCost = cables.reduce((sum, line) => sum + line.totalCost, 0)
|
||||||
|
const totalDeviceCost = devices.reduce((sum, line) => sum + line.totalCost, 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cables: [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName)),
|
cables,
|
||||||
devices: [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
devices,
|
||||||
|
totalCableCost,
|
||||||
|
totalDeviceCost,
|
||||||
|
grandTotalCost: totalCableCost + totalDeviceCost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export function createDeviceFromTemplate(
|
|||||||
manufacturer: template.manufacturer,
|
manufacturer: template.manufacturer,
|
||||||
model: template.model,
|
model: template.model,
|
||||||
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
|
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
|
||||||
|
cost: template.cost,
|
||||||
position,
|
position,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export interface DeviceTemplate {
|
|||||||
manufacturer?: string
|
manufacturer?: string
|
||||||
model?: string
|
model?: string
|
||||||
ports: Port[]
|
ports: Port[]
|
||||||
|
/** Suggested default cost, copied onto each placed Device at creation time
|
||||||
|
* (same as ports) and independently editable per instance afterward. */
|
||||||
|
cost?: number
|
||||||
builtIn?: boolean
|
builtIn?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +80,10 @@ export interface Device {
|
|||||||
model?: string
|
model?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
ports: Port[]
|
ports: Port[]
|
||||||
|
/** Copied from the template's suggested cost when placed (like ports),
|
||||||
|
* then independently editable — editing an instance never mutates the
|
||||||
|
* shared template. */
|
||||||
|
cost?: number
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +102,9 @@ export interface CableType {
|
|||||||
* not just where this particular cable is actually used). */
|
* not just where this particular cable is actually used). */
|
||||||
family2?: string
|
family2?: string
|
||||||
unit: 'ft' | 'm'
|
unit: 'ft' | 'm'
|
||||||
|
/** Suggested default cost per unit length (in `unit`). Each Connection can
|
||||||
|
* override the computed cost for its specific run (`Connection.cost`),
|
||||||
|
* which always wins — public catalog prices drift and vary by vendor. */
|
||||||
costPerUnit?: number
|
costPerUnit?: number
|
||||||
custom?: boolean
|
custom?: boolean
|
||||||
}
|
}
|
||||||
@@ -110,6 +120,11 @@ export interface Connection {
|
|||||||
/** User-entered run length, in the CableType's unit. Optional because
|
/** User-entered run length, in the CableType's unit. Optional because
|
||||||
* real-world layout isn't modeled (no floor plan / scale). */
|
* real-world layout isn't modeled (no floor plan / scale). */
|
||||||
distance?: number
|
distance?: number
|
||||||
|
/** Override for this specific run's total cost — takes precedence over
|
||||||
|
* computing from the CableType's `costPerUnit * distance`, since a
|
||||||
|
* diagram-level price should always win over the library's suggested
|
||||||
|
* default. */
|
||||||
|
cost?: number
|
||||||
label?: string
|
label?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ interface ProjectStoreState {
|
|||||||
|
|
||||||
addDeviceFromTemplate: (templateId: string, position: { x: number; y: number }) => void
|
addDeviceFromTemplate: (templateId: string, position: { x: number; y: number }) => void
|
||||||
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
|
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
|
||||||
updateDevice: (id: string, patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes'>>) => void
|
updateDevice: (
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes' | 'cost'>>,
|
||||||
|
) => void
|
||||||
removeDevice: (id: string) => void
|
removeDevice: (id: string) => void
|
||||||
|
|
||||||
/** Returns the new port's id, so the caller can move keyboard focus to it. */
|
/** Returns the new port's id, so the caller can move keyboard focus to it. */
|
||||||
@@ -46,7 +49,10 @@ interface ProjectStoreState {
|
|||||||
targetPortId: string
|
targetPortId: string
|
||||||
cableTypeId: string
|
cableTypeId: string
|
||||||
}) => boolean
|
}) => boolean
|
||||||
updateConnection: (id: string, patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'label' | 'notes'>>) => void
|
updateConnection: (
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
|
||||||
|
) => void
|
||||||
removeConnection: (id: string) => void
|
removeConnection: (id: string) => void
|
||||||
|
|
||||||
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'builtIn'>) => void
|
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'builtIn'>) => void
|
||||||
|
|||||||
Reference in New Issue
Block a user