Initial project import

This commit is contained in:
drjones
2026-06-13 17:36:44 -07:00
commit ad2a18cc8d
18471 changed files with 4497570 additions and 0 deletions

321
src/bom/BomPanel.tsx Normal file
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>
);
}