Files
hydro-builder-app/src/bom/BomPanel.tsx

335 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&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={() => 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>
);
}