335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
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';
|
||
import { useAuth } from '../auth/AuthContext';
|
||
import { PaywallModal } from '../payments/PaywallModal';
|
||
|
||
/**
|
||
* 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 { user } = useAuth();
|
||
const [showPaywall, setShowPaywall] = useState(false);
|
||
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 requirePremium = (action: () => void) => {
|
||
if (!user || user.purchased_slots === 0) {
|
||
setShowPaywall(true);
|
||
} else {
|
||
action();
|
||
}
|
||
};
|
||
|
||
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={() => requirePremium(() => downloadBomCsv(bom, projectName))} title="Download as CSV">
|
||
Export CSV 🔒
|
||
</ActionBtn>
|
||
<ActionBtn onClick={() => requirePremium(() => 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 && requirePremium(() => exportToGLTF(scene, `${projectName}.gltf`))}
|
||
title={scene ? "Export 3D layout as glTF" : "Scene loading..."}
|
||
disabled={!scene}
|
||
>
|
||
Export glTF 🔒
|
||
</ActionBtn>
|
||
<ActionBtn
|
||
onClick={() => scene && requirePremium(() => 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>
|
||
{showPaywall && <PaywallModal onClose={() => setShowPaywall(false)} />}
|
||
</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>
|
||
);
|
||
}
|