Initial project import
This commit is contained in:
321
src/bom/BomPanel.tsx
Normal file
321
src/bom/BomPanel.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { computeBom, formatDiameter, formatInches, formatMoney } from './computeBom';
|
||||
import { bomToText, downloadBomCsv } from './csv';
|
||||
import { downloadBomPdf } from './pdf';
|
||||
import type { BomLine, CutList } from './types';
|
||||
import { exportToGLTF, exportToOBJ } from '../utils/cadExporter';
|
||||
|
||||
/**
|
||||
* BomPanel — self-contained Bill of Materials drawer.
|
||||
*
|
||||
* Renders a fixed-position overlay (no portals) with a right-hand drawer:
|
||||
* summary header, per-category line-item table, expandable cut lists, and
|
||||
* Export CSV / Copy as text / Print actions. Recomputes via useMemo whenever
|
||||
* the parts in the builder store change.
|
||||
*
|
||||
* Mount it conditionally, e.g.:
|
||||
* {bomOpen && <BomPanel onClose={() => setBomOpen(false)} />}
|
||||
*/
|
||||
export function BomPanel({ onClose }: { onClose: () => void }) {
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
const projectName = useBuilder((s) => s.projectName);
|
||||
const scene = useBuilder((s) => s.scene);
|
||||
|
||||
const roomParts = useMemo(() => {
|
||||
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
|
||||
}, [parts, activeRoomId]);
|
||||
|
||||
const bom = useMemo(() => computeBom(roomParts), [roomParts]);
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(bomToText(bom));
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable (permissions) — ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
const win = window.open('', '_blank', 'width=720,height=900');
|
||||
if (!win) return;
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
win.document.write(
|
||||
`<!doctype html><title>${esc(projectName)} — Bill of Materials</title>` +
|
||||
`<pre style="font: 12px/1.5 ui-monospace, Menlo, monospace; padding: 24px;">` +
|
||||
esc(bomToText(bom)) +
|
||||
`</pre>`,
|
||||
);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
win.print();
|
||||
};
|
||||
|
||||
// Group lines by category for sectioned rendering.
|
||||
const sections = useMemo(() => {
|
||||
const map = new Map<string, BomLine[]>();
|
||||
for (const line of bom.lines) {
|
||||
const arr = map.get(line.category);
|
||||
if (arr) arr.push(line);
|
||||
else map.set(line.category, [line]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}, [bom]);
|
||||
|
||||
const subtotalOf = (name: string) =>
|
||||
bom.categories.find((c) => c.name === name)?.subtotal ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="relative flex h-full w-full max-w-xl flex-col border-l border-zinc-800 bg-zinc-950 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 border-b border-zinc-800 px-5 py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-wide text-zinc-100">
|
||||
Bill of Materials
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">{projectName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close (Esc)"
|
||||
className="rounded-md px-2 py-1 text-sm text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-end justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-sky-400 tabular-nums">
|
||||
{formatMoney(bom.totalCost)}
|
||||
<span className="ml-1.5 text-xs font-medium text-zinc-500">est. total</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 tabular-nums">
|
||||
{bom.partCount} parts · {bom.lines.length} line items
|
||||
{bom.cutLists.length > 0 &&
|
||||
` · ${bom.cutLists.reduce((s, c) => s + c.sticksToBuy, 0)} PVC sticks`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 justify-end">
|
||||
<ActionBtn onClick={() => downloadBomCsv(bom, projectName)} title="Download as CSV">
|
||||
Export CSV
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={() => downloadBomPdf(bom, projectName)} title="Download as PDF">
|
||||
Export PDF
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={handleCopy} title="Copy shopping list to clipboard">
|
||||
{copied ? 'Copied' : 'Copy as text'}
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={handlePrint} title="Print shopping list">
|
||||
Print
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() => scene && exportToGLTF(scene, `${projectName}.gltf`)}
|
||||
title={scene ? "Export 3D layout as glTF" : "Scene loading..."}
|
||||
disabled={!scene}
|
||||
>
|
||||
Export glTF
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() => scene && exportToOBJ(scene, `${projectName}.obj`)}
|
||||
title={scene ? "Export 3D layout as OBJ" : "Scene loading..."}
|
||||
disabled={!scene}
|
||||
>
|
||||
Export OBJ
|
||||
</ActionBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{bom.partCount === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-zinc-600">
|
||||
Nothing here yet — place some parts in the scene to build a shopping list.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{sections.map(([category, lines]) => (
|
||||
<section key={category} className="mb-5">
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
{category}
|
||||
</h3>
|
||||
<span className="text-[11px] font-semibold text-zinc-400 tabular-nums">
|
||||
{formatMoney(subtotalOf(category))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800">
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={line.key}
|
||||
className={`flex items-start justify-between gap-3 px-3 py-2 ${
|
||||
i % 2 === 1 ? 'bg-zinc-900/40' : 'bg-zinc-900/10'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-zinc-200">
|
||||
{line.description}
|
||||
{line.qty > 1 || line.unit === 'stick' ? (
|
||||
<span className="ml-1.5 text-zinc-500">×{line.qty}</span>
|
||||
) : null}
|
||||
{line.estimated && (
|
||||
<span className="ml-1.5 rounded bg-amber-950/80 px-1 py-px text-[9px] font-semibold text-amber-400 ring-1 ring-amber-900">
|
||||
est.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{line.spec && (
|
||||
<div className="mt-0.5 text-[10px] text-zinc-500">{line.spec}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-xs font-semibold text-zinc-200 tabular-nums">
|
||||
{formatMoney(line.totalCost)}
|
||||
</div>
|
||||
{line.qty > 1 && (
|
||||
<div className="text-[10px] text-zinc-500 tabular-nums">
|
||||
{formatMoney(line.unitCost)} / {line.unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{bom.cutLists.length > 0 && (
|
||||
<section className="mb-5">
|
||||
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Cut list — 10 ft sticks
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{bom.cutLists.map((list) => (
|
||||
<CutListCard key={list.diameterIn} list={list} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0 border-t border-zinc-800 px-5 py-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-500">
|
||||
Prices are rough US retail estimates, not quotes.
|
||||
</span>
|
||||
<span className="font-bold text-zinc-100 tabular-nums">
|
||||
{formatMoney(bom.totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Expandable per-diameter cut plan card. */
|
||||
function CutListCard({ list }: { list: CutList }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/30">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left transition hover:bg-zinc-800/50"
|
||||
>
|
||||
<span className="text-xs font-semibold text-zinc-200">
|
||||
{formatDiameter(list.diameterIn)} PVC
|
||||
<span className="ml-2 font-normal text-zinc-500">
|
||||
buy {list.sticksToBuy} stick{list.sticksToBuy === 1 ? '' : 's'} · {list.cuts.length}{' '}
|
||||
cuts · {formatInches(list.totalLeftoverIn)} scrap
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-500">{open ? 'Hide' : 'Show'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-zinc-800 px-3 py-2">
|
||||
{list.sticks.map((stick) => (
|
||||
<div key={stick.index} className="flex items-baseline gap-2 py-0.5">
|
||||
<span className="w-14 shrink-0 text-[10px] font-semibold text-zinc-500">
|
||||
Stick {stick.index}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-300 tabular-nums">
|
||||
{stick.cuts.map((c) => formatInches(c)).join(', ')}
|
||||
<span className="ml-1.5 text-zinc-500">
|
||||
— {formatInches(stick.leftoverIn)} left
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{list.spliceCount > 0 && (
|
||||
<p className="mt-1 text-[10px] text-amber-400/90">
|
||||
{list.spliceCount} coupling{list.spliceCount === 1 ? '' : 's'} needed to splice runs
|
||||
longer than 10 ft.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBtn({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition ${
|
||||
disabled
|
||||
? 'bg-zinc-800 text-zinc-500 ring-1 ring-zinc-700 cursor-not-allowed opacity-50'
|
||||
: 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-700 hover:bg-sky-500/25'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user