diff --git a/src/components/canvas/CableEdge.tsx b/src/components/canvas/CableEdge.tsx index f14dde2..e46ee10 100644 --- a/src/components/canvas/CableEdge.tsx +++ b/src/components/canvas/CableEdge.tsx @@ -4,6 +4,26 @@ export type CableEdgeData = { cableTypeName: string distance?: number unit: string + /** Set when this edge stands in for more than one real connection between + * the same two (collapsed) devices of the same cable type — + * organized-ideas.md §4's "virtual cable". A per-connection distance + * doesn't generalize across the group, so it's omitted whenever this is + * set; expand either device to see the real per-cable lengths instead. */ + aggregateCount?: number + /** Set when either endpoint's port has a built-in/captive cable — the BOM + * omits this connection entirely (nothing to buy), but the wiring is + * still real, so it stays on the canvas with its own marker instead of + * looking identical to a connection that needs a cable purchased. Never + * true at the same time as aggregateCount>1 — FlowCanvas never merges a + * built-in connection with anything else. */ + builtIn?: boolean + /** Set when this leg is part of a CableBundle (a stereo pair, a snake) — + * never true alongside aggregateCount or builtIn, since a bundled + * connection never merges with anything (see FlowCanvas). Colors the + * edge and swaps the label to the bundle's own name, so every leg of the + * same physical cable reads as visually one thing. */ + bundleName?: string + bundleColor?: string } export type CableFlowEdge = Edge @@ -29,8 +49,15 @@ export default function CableEdge({ targetPosition, }) + const isAggregate = !!data?.aggregateCount && data.aggregateCount > 1 + const isBuiltIn = !!data?.builtIn + const bundleColor = data?.bundleName ? data.bundleColor : undefined const label = data - ? `${data.cableTypeName}${typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''}` + ? bundleColor + ? `${data.bundleName}${typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''}` + : `${data.cableTypeName}${ + isAggregate ? ` ×${data.aggregateCount}` : typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : '' + }${isBuiltIn ? ' · Built-in' : ''}` : '' return ( @@ -39,13 +66,22 @@ export default function CableEdge({ id={id} path={edgePath} markerEnd={markerEnd} - style={{ stroke: selected ? '#6366f1' : '#94a3b8', strokeWidth: selected ? 2.5 : 1.5 }} + style={{ + stroke: selected ? '#6366f1' : bundleColor ?? (isBuiltIn ? '#64748b' : '#94a3b8'), + strokeWidth: selected ? 2.5 : 1.5, + strokeDasharray: isAggregate ? '6 3' : undefined, + }} /> {label && (
{label}
diff --git a/src/components/canvas/DeviceNode.tsx b/src/components/canvas/DeviceNode.tsx index 4b55c08..80f3e00 100644 --- a/src/components/canvas/DeviceNode.tsx +++ b/src/components/canvas/DeviceNode.tsx @@ -1,6 +1,7 @@ import { Handle, Position, type Node, type NodeProps } from '@xyflow/react' -import type { Device, Port, PortType } from '../../domain/types' -import { buildHandleId } from './handleIds' +import type { Device, Port, PortDirection, PortType } from '../../domain/types' +import Chevron from '../common/Chevron' +import { buildGroupHandleId, buildHandleId } from './handleIds' export interface PortInfo { port: Port @@ -14,7 +15,14 @@ export type DeviceNodeData = { /** Resolved from the device's category id — see FlowCanvas — since a * custom category's id is an opaque uuid, not something to show raw. */ categoryName: string + /** Resolved from the device's manufacturerId, same reasoning as + * categoryName — undefined when no manufacturer is set. */ + manufacturerName: string | undefined portInfos: PortInfo[] + /** Per-device compact/expanded toggle (organized-ideas.md §4) — local UI + * state in FlowCanvas, not persisted, defaulting to compact. */ + expanded: boolean + onToggleExpanded: () => void } export type DeviceFlowNode = Node @@ -56,27 +64,134 @@ function PortHandle({ portInfo, position }: { portInfo: PortInfo; position: Posi ) } +/** The single attachment point a compact device exposes for one direction + * group — every real connection that would otherwise land on one of this + * group's individual ports attaches here instead (see FlowCanvas's edge + * computation). Always connectable; "full" doesn't apply to a group. */ +function GroupHandle({ direction, position }: { direction: PortDirection; position: Position }) { + // Absolutely positioned against the nearest `relative` ancestor (each + // caller below marks its row `relative` for this), with an explicit + // centering transform — rather than relying on React Flow's own + // `.react-flow__handle-*` classes to center it, since our layouts (a + // horizontal input/output row, a vertical bidirectional stack) don't + // match the single-port layout those classes assume, and partially + // overriding them via inline style leaves some of their rules (e.g. a + // stale `left: 50%`) still in effect with nothing to counter them. + const edgeStyle = + position === Position.Left + ? { left: -13, top: '50%', transform: 'translateY(-50%)' } + : position === Position.Right + ? { right: -13, top: '50%', transform: 'translateY(-50%)' } + : { bottom: -13, left: '50%', transform: 'translateX(-50%)' } + return ( + + ) +} + +function ExpandToggle({ expanded, onToggle }: { expanded: boolean; onToggle: () => void }) { + return ( + + ) +} + +/** "3 connected · 2 open" — omits either half when zero, so a fully-open or + * fully-connected group doesn't read as "0 connected · 3 open" noise. */ +function GroupSummary({ portInfos }: { portInfos: PortInfo[] }) { + const connected = portInfos.filter((p) => p.connectionCount > 0).length + const open = portInfos.length - connected + const parts: string[] = [] + if (connected > 0) parts.push(`${connected} connected`) + if (open > 0) parts.push(`${open} open`) + return {parts.join(' · ')} +} + function CountBadge({ count }: { count: number }) { if (count === 0) return null return {count} } -export default function DeviceNode({ data, selected }: NodeProps) { - const { device, categoryName, portInfos } = data +function CompactDeviceNode({ data }: { data: DeviceNodeData }) { + const { device, portInfos, expanded, onToggleExpanded } = data const inputs = portInfos.filter((p) => p.port.direction === 'input') const outputs = portInfos.filter((p) => p.port.direction === 'output') const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional') return ( -
-
-
{device.name}
-
- {[device.manufacturer, device.model].filter(Boolean).join(' ') || categoryName} +
+
+ +
{device.name}
+
+ {(inputs.length > 0 || outputs.length > 0 || bidirectional.length > 0) && ( +
+ {inputs.length > 0 && ( +
+ + In + +
+ )} + {outputs.length > 0 && ( +
+ Out + + +
+ )} + {bidirectional.length > 0 && ( +
+ I/O + + +
+ )} +
+ )} +
+ ) +} + +function ExpandedDeviceNode({ data }: { data: DeviceNodeData }) { + const { device, categoryName, manufacturerName, portInfos, expanded, onToggleExpanded } = data + const inputs = portInfos.filter((p) => p.port.direction === 'input') + const outputs = portInfos.filter((p) => p.port.direction === 'output') + const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional') + + return ( +
+
+ +
+
{device.name}
+
+ {[manufacturerName, device.model].filter(Boolean).join(' ') || categoryName} +
@@ -131,3 +246,14 @@ export default function DeviceNode({ data, selected }: NodeProps
) } + +export default function DeviceNode({ data, selected }: NodeProps) { + return ( +
+ {data.expanded ? : } +
+ ) +} diff --git a/src/components/canvas/FlowCanvas.tsx b/src/components/canvas/FlowCanvas.tsx index 5883508..370e416 100644 --- a/src/components/canvas/FlowCanvas.tsx +++ b/src/components/canvas/FlowCanvas.tsx @@ -3,6 +3,7 @@ import { ConnectionMode, Controls, MiniMap, + Panel, ReactFlow, ReactFlowProvider, useReactFlow, @@ -13,20 +14,44 @@ import { type NodeTypes, } from '@xyflow/react' import '@xyflow/react/dist/style.css' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { validateConnection } from '../../domain/compatibility' -import { allCableTypes, allPortTypes, cableTypesForConnection, deviceCategoryName, getPortType } from '../../domain/diagram' +import { + allCableTypes, + allPortTypes, + cableTypesForConnection, + deviceCategoryName, + getPortType, + manufacturerName, +} from '../../domain/diagram' import { useCatalogStore } from '../../state/catalogStore' import { useDiagramStore } from '../../state/diagramStore' import CableEdge, { type CableFlowEdge } from './CableEdge' import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode' -import { buildHandleId, parsePortId } from './handleIds' +import { buildGroupHandleId, buildHandleId, parsePortId } from './handleIds' export const TEMPLATE_DRAG_MIME = 'application/x-av-planner-template' const nodeTypes: NodeTypes = { device: DeviceNode } const edgeTypes: EdgeTypes = { cable: CableEdge } +// A virtual (merged) edge's id carries the real connection ids it stands in +// for, so removing or selecting it can be translated back to the underlying +// domain objects without a parallel id→group lookup table. +const VIRTUAL_EDGE_PREFIX = 'virtual:' +function connectionIdsForEdge(edgeId: string): string[] { + return edgeId.startsWith(VIRTUAL_EDGE_PREFIX) ? edgeId.slice(VIRTUAL_EDGE_PREFIX.length).split(',') : [edgeId] +} + +// Deterministic per-bundle color (cycling through a fixed palette by the +// bundle's position in the diagram) so every leg of the same stereo +// pair/snake reads as visually one thing on the canvas — see CableEdge. +const BUNDLE_EDGE_COLORS = ['#d97706', '#059669', '#0284c7', '#c026d3', '#e11d48', '#0d9488', '#ea580c', '#7c3aed'] +function bundleColorFor(bundleId: string, bundles: { id: string }[]): string { + const index = bundles.findIndex((b) => b.id === bundleId) + return BUNDLE_EDGE_COLORS[Math.max(index, 0) % BUNDLE_EDGE_COLORS.length] +} + function FlowCanvasInner() { const diagram = useDiagramStore((s) => s.diagram) const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId) @@ -48,6 +73,27 @@ function FlowCanvasInner() { // which point nothing is rendered yet anyway (AppShell's isLoaded gate). const canEdit = access?.myPermission !== 'view' + // Per-device compact/expanded toggle (organized-ideas.md §4) — purely a + // canvas display preference, so it's local component state rather than + // anything persisted: not on the device, not in localStorage. Every device + // starts compact (absent from the set); membership means "expanded". + // Toggling itself is always allowed even in a view-only diagram, since it + // doesn't touch diagram content — only `canEdit`-gated actions below do. + const [expandedDeviceIds, setExpandedDeviceIds] = useState>(new Set()) + const toggleExpanded = useCallback((id: string) => { + setExpandedDeviceIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + const expandAll = useCallback( + () => setExpandedDeviceIds(new Set(diagram.devices.map((d) => d.id))), + [diagram.devices], + ) + const collapseAll = useCallback(() => setExpandedDeviceIds(new Set()), []) + const portTypes = useMemo(() => allPortTypes(catalog), [catalog]) const cableTypes = useMemo(() => allCableTypes(catalog), [catalog]) @@ -67,34 +113,163 @@ function FlowCanvasInner() { type: 'device', position: device.position, selected: device.id === selectedDeviceId, - data: { device, categoryName: deviceCategoryName(catalog, device.category), portInfos }, - } - }), - [diagram, catalog, portTypes, selectedDeviceId], - ) - - const edges: CableFlowEdge[] = useMemo( - () => - diagram.connections.map((connection) => { - const cableType = cableTypes.find((ct) => ct.id === connection.cableTypeId) - return { - id: connection.id, - type: 'cable', - source: connection.sourceDeviceId, - sourceHandle: buildHandleId(connection.sourcePortId), - target: connection.targetDeviceId, - targetHandle: buildHandleId(connection.targetPortId), - selected: connection.id === selectedConnectionId, data: { - cableTypeName: cableType?.name ?? 'Unknown Cable', - distance: connection.distance, - unit: cableType?.unit ?? 'ft', + device, + categoryName: deviceCategoryName(catalog, device.category), + manufacturerName: manufacturerName(catalog, device.manufacturerId), + portInfos, + expanded: expandedDeviceIds.has(device.id), + onToggleExpanded: () => toggleExpanded(device.id), }, } }), - [diagram.connections, cableTypes, selectedConnectionId], + [diagram, catalog, portTypes, selectedDeviceId, expandedDeviceIds, toggleExpanded], ) + // Every real connection always gets its own domain object and its own BOM + // line (domain/bom.ts reads diagram.connections directly and never sees + // this component) — what collapses here is purely which handles an edge + // renders between. A connection whose device is expanded attaches to that + // device's real port handle as before; a connection whose device is + // collapsed attaches to that device's one handle for the port's direction + // instead. When *both* ends of two or more connections resolve to the same + // pair of (device, handle) points and the same cable type, they're drawn as + // a single dashed "virtual cable" edge with a ×N marker — the merge is + // undone the instant either device is expanded again, since the handles + // stop matching. A connection touching a built-in/captive port (bom.ts + // excludes these from the shopping list entirely) never merges with + // anything, even another built-in one — there's no quantity-to-buy for a + // ×N badge to mean anything about, so it always keeps its own edge. + const edges: CableFlowEdge[] = useMemo(() => { + type Resolved = { + connectionId: string + cableTypeId: string + distance: number | undefined + source: string + sourceHandle: string + target: string + targetHandle: string + builtIn: boolean + bundleId: string | undefined + } + + const resolved: Resolved[] = [] + for (const connection of diagram.connections) { + const sourceDevice = diagram.devices.find((d) => d.id === connection.sourceDeviceId) + const targetDevice = diagram.devices.find((d) => d.id === connection.targetDeviceId) + const sourcePort = sourceDevice?.ports.find((p) => p.id === connection.sourcePortId) + const targetPort = targetDevice?.ports.find((p) => p.id === connection.targetPortId) + if (!sourceDevice || !targetDevice || !sourcePort || !targetPort) continue + + const sourceHandle = expandedDeviceIds.has(sourceDevice.id) + ? buildHandleId(sourcePort.id) + : buildGroupHandleId(sourcePort.direction) + const targetHandle = expandedDeviceIds.has(targetDevice.id) + ? buildHandleId(targetPort.id) + : buildGroupHandleId(targetPort.direction) + + resolved.push({ + connectionId: connection.id, + cableTypeId: connection.cableTypeId, + distance: connection.distance, + source: sourceDevice.id, + sourceHandle, + target: targetDevice.id, + targetHandle, + builtIn: !!sourcePort.builtInCable || !!targetPort.builtInCable, + bundleId: connection.bundleId, + }) + } + + const edges: CableFlowEdge[] = [] + + // Group by the unordered pair of (device, handle) endpoints + cable type + // — unordered because which real connection got recorded as "source" vs + // "target" is an accident of creation order, not something two cables + // between the same two collapsed devices should be split on. + const groups = new Map() + for (const r of resolved) { + // A bundled leg never merges either, same reasoning as built-in — it + // needs to keep its own edge so its bundle color/name actually shows, + // and a ×N aggregate badge wouldn't mean anything for cables that + // aren't interchangeable purchases in the first place. + if (r.builtIn || r.bundleId) continue + const a = `${r.source}::${r.sourceHandle}` + const b = `${r.target}::${r.targetHandle}` + const key = [a, b].sort().join('|') + '|' + r.cableTypeId + const group = groups.get(key) + if (group) group.push(r) + else groups.set(key, [r]) + } + + for (const group of groups.values()) { + const cableType = cableTypes.find((ct) => ct.id === group[0].cableTypeId) + const isAggregate = group.length > 1 + const representative = group[0] + edges.push({ + id: isAggregate + ? `${VIRTUAL_EDGE_PREFIX}${group.map((r) => r.connectionId).join(',')}` + : representative.connectionId, + type: 'cable', + source: representative.source, + sourceHandle: representative.sourceHandle, + target: representative.target, + targetHandle: representative.targetHandle, + selected: !isAggregate && representative.connectionId === selectedConnectionId, + data: { + cableTypeName: cableType?.name ?? 'Unknown Cable', + distance: isAggregate ? undefined : representative.distance, + unit: cableType?.unit ?? 'ft', + aggregateCount: isAggregate ? group.length : undefined, + }, + }) + } + + for (const r of resolved) { + if (!r.builtIn) continue + const cableType = cableTypes.find((ct) => ct.id === r.cableTypeId) + edges.push({ + id: r.connectionId, + type: 'cable', + source: r.source, + sourceHandle: r.sourceHandle, + target: r.target, + targetHandle: r.targetHandle, + selected: r.connectionId === selectedConnectionId, + data: { + cableTypeName: cableType?.name ?? 'Unknown Cable', + distance: r.distance, + unit: cableType?.unit ?? 'ft', + builtIn: true, + }, + }) + } + + for (const r of resolved) { + if (!r.bundleId) continue + const cableType = cableTypes.find((ct) => ct.id === r.cableTypeId) + const bundle = diagram.bundles.find((b) => b.id === r.bundleId) + edges.push({ + id: r.connectionId, + type: 'cable', + source: r.source, + sourceHandle: r.sourceHandle, + target: r.target, + targetHandle: r.targetHandle, + selected: r.connectionId === selectedConnectionId, + data: { + cableTypeName: cableType?.name ?? 'Unknown Cable', + distance: r.distance, + unit: cableType?.unit ?? 'ft', + bundleName: bundle?.name ?? 'Bundle', + bundleColor: bundleColorFor(r.bundleId, diagram.bundles), + }, + }) + } + + return edges + }, [diagram.devices, diagram.connections, diagram.bundles, cableTypes, selectedConnectionId, expandedDeviceIds]) + const isValidConnection = useCallback( (edgeOrConnection: Connection | { source: string | null; sourceHandle?: string | null; target: string | null; targetHandle?: string | null }) => { const c = edgeOrConnection as Connection @@ -125,7 +300,19 @@ function FlowCanvasInner() { const targetPortType = targetPort && getPortType(catalog, targetPort.portTypeId) const defaultCableType = sourcePortType && targetPortType && cableTypesForConnection(catalog, sourcePortType, targetPortType)[0] - if (!defaultCableType) return + if (!defaultCableType) { + // isValidConnection above already confirmed the two port *types* are + // compatible (or bridgeable) — reaching here with no cable type + // means compatibility exists on paper but nobody's ever defined an + // actual cable for it, which otherwise fails completely silently + // (the drag just does nothing, with no clue why). + if (sourcePortType && targetPortType) { + window.alert( + `${sourcePortType.name} and ${targetPortType.name} are compatible, but there's no cable type defined for them yet.\n\nAdd one from the device palette's Library ▾ → Connectors → Cable Types tab, with a family matching one of these two, then try again.`, + ) + } + return + } addConnection(catalog, { sourceDeviceId: connection.source, sourcePortId, @@ -155,7 +342,9 @@ function FlowCanvasInner() { (changes: EdgeChange[]) => { if (!canEdit) return for (const change of changes) { - if (change.type === 'remove') removeConnection(change.id) + // Deleting a merged edge (the ×N marker) removes every real + // connection it represents, not just a display artifact. + if (change.type === 'remove') connectionIdsForEdge(change.id).forEach(removeConnection) } }, [canEdit, removeConnection], @@ -197,7 +386,11 @@ function FlowCanvasInner() { nodesConnectable={canEdit} connectionMode={ConnectionMode.Loose} onNodeClick={(_, node) => selectDevice(node.id)} - onEdgeClick={(_, edge) => selectConnection(edge.id)} + onEdgeClick={(_, edge) => { + // A merged edge doesn't correspond to one Connection — expand + // either endpoint to select and edit the individual cables. + if (!edge.id.startsWith(VIRTUAL_EDGE_PREFIX)) selectConnection(edge.id) + }} onPaneClick={() => { selectDevice(null) }} @@ -207,6 +400,20 @@ function FlowCanvasInner() { + + + +
) diff --git a/src/components/canvas/handleIds.ts b/src/components/canvas/handleIds.ts index 00a5590..5ff4499 100644 --- a/src/components/canvas/handleIds.ts +++ b/src/components/canvas/handleIds.ts @@ -1,3 +1,5 @@ +import type { PortDirection } from '../../domain/types' + /** * React Flow handle ids encode which port they belong to. We use a single * handle per port (not two stacked source/target handles) combined with @@ -15,3 +17,21 @@ export function parsePortId(handleId: string | null | undefined): string | null if (!handleId) return null return handleId.startsWith(PREFIX) ? handleId.slice(PREFIX.length) : handleId } + +/** + * A compact device node (organized-ideas.md §4) doesn't render individual + * ports, so there's nothing for an edge to attach to at the per-port level — + * instead each direction group (input/output/bidirectional) gets exactly + * one handle, and every real connection touching that device on that side + * attaches there instead. Distinct prefix from port handles so the two + * schemes can never collide. + */ +const GROUP_PREFIX = 'group:' + +export function buildGroupHandleId(direction: PortDirection): string { + return `${GROUP_PREFIX}${direction}` +} + +export function isGroupHandleId(handleId: string | null | undefined): boolean { + return !!handleId && handleId.startsWith(GROUP_PREFIX) +} diff --git a/src/components/inspector/ConnectionInspector.tsx b/src/components/inspector/ConnectionInspector.tsx index a111688..99962b3 100644 --- a/src/components/inspector/ConnectionInspector.tsx +++ b/src/components/inspector/ConnectionInspector.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react' -import { cableTypesForConnection, getPortType } from '../../domain/diagram' +import { useMemo, useState } from 'react' +import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram' import { useCatalogStore } from '../../state/catalogStore' import { useDiagramStore } from '../../state/diagramStore' @@ -10,6 +10,14 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st const canEdit = access?.myPermission !== 'view' const updateConnection = useDiagramStore((s) => s.updateConnection) const removeConnection = useDiagramStore((s) => s.removeConnection) + const startBundle = useDiagramStore((s) => s.startBundle) + const addConnectionToBundle = useDiagramStore((s) => s.addConnectionToBundle) + const removeConnectionFromBundle = useDiagramStore((s) => s.removeConnectionFromBundle) + const updateBundle = useDiagramStore((s) => s.updateBundle) + const selectConnection = useDiagramStore((s) => s.selectConnection) + const [startingBundle, setStartingBundle] = useState(false) + const [newBundleName, setNewBundleName] = useState('') + const [newBundleCableTypeId, setNewBundleCableTypeId] = useState('') const connection = diagram.connections.find((c) => c.id === connectionId) const sourceDevice = diagram.devices.find((d) => d.id === connection?.sourceDeviceId) @@ -26,6 +34,25 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st const cableCostPerUnit = availableCableTypes.find((ct) => ct.id === connection?.cableTypeId)?.costPerUnit + // Every cable type, unfiltered by port compatibility — a bundle's own + // cable type is a separate, BOM-only choice from any member's individually + // validated cableTypeId (see CableBundle's own doc comment), so it isn't + // constrained to what fits this specific port pair. + const allBundleCableTypes = useMemo( + () => [...allCableTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)), + [catalog], + ) + + const bundle = diagram.bundles.find((b) => b.id === connection?.bundleId) + const otherBundles = diagram.bundles.filter((b) => b.id !== connection?.bundleId) + const otherMembers = diagram.connections.filter((c) => c.bundleId === bundle?.id && c.id !== connectionId) + + const describeConnectionEnd = (deviceId: string, portId: string) => { + const device = diagram.devices.find((d) => d.id === deviceId) + const port = device?.ports.find((p) => p.id === portId) + return device && port ? `${device.name} · ${port.name}` : 'Unknown' + } + if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null return ( @@ -126,6 +153,165 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st /> +
+

Bundle

+ {bundle ? ( +
+

+ Part of a bundle with {otherMembers.length} other cable{otherMembers.length === 1 ? '' : 's'} — counted + as one item on the BOM, using this bundle's own cable type/length/cost below rather than each leg's. +

+ + +
+ + +
+
+

Other cables in this bundle:

+
    + {otherMembers.map((m) => ( +
  • + +
  • + ))} +
+
+ +
+ ) : ( +
+

+ Group this with other cables that are physically one item — a stereo pair, a snake — so the BOM counts + them as a single purchase instead of one per leg. +

+ {otherBundles.length > 0 && ( + + )} + {!startingBundle ? ( + + ) : ( +
+ setNewBundleName(e.target.value)} + placeholder="e.g. Stereo Pair" + className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs" + /> + +
+ + +
+
+ )} +
+ )} +
+