Deck cost calculator: quote PDF, PNG, XLSX, copy, supplier import

Export ▾ now offers: PDF (full sheet), PDF (client quote — uses an optional
businessName), PNG summary card (canvas), XLSX multi-sheet workbook (lazy
SheetJS), CSV, Markdown, JSON, and Copy summary (clipboard).

Import auto-detects the CSV shape: a `name,order_cost,…` header is treated as a
supplier price list and replaces the Materials table; otherwise the CSV
round-trips the whole sheet. JSON still loads a backup.
This commit is contained in:
SkateSpots 2026-10-05 10:54:37 +07:00
parent daeb3fbf3e
commit 1a8c483682
4 changed files with 314 additions and 8 deletions

View file

@ -840,11 +840,14 @@ session with **no other memory of this project**. So:
export remains (for Import/backup); Import reads JSON. The default **Materials**
list is a full deck-build checklist (veneer/edge tape/logs, glue/epoxy/filler,
mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
paint/clearcoat/grip). **Export formats (2026-10-05):** an **Export ▾** menu —
**PDF** (quote/print), **CSV** (spreadsheet, `section,item,field,value`),
**Markdown**, **JSON** (backup) — and **Import** auto-detects `.json`/`.csv`
(CSV round-trips the whole sheet; hand-rolled CSV parse/escape so commas in
names are safe). Next: write our own
paint/clearcoat/grip). **Export/Import (2026-10-05):** **Export ▾** —
**PDF (full sheet)**, **PDF (client quote, uses an optional `businessName`)**,
**PNG** summary card (canvas), **XLSX** multi-sheet workbook (lazy `xlsx`),
**CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy
summary** (clipboard). **Import** auto-detects `.json`/`.csv`: a CSV whose
header is `name,order_cost,…` is treated as a **supplier price list** (replaces
the Materials table), otherwise it round-trips the whole sheet. Hand-rolled CSV
parse/escape so commas in names are safe. Next: write our own
step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards
live.
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be

104
package-lock.json generated
View file

@ -107,6 +107,7 @@
"three": "^0.185.1",
"vaul": "^0.9.3",
"websocket-ts": "^2.2.1",
"xlsx": "^0.18.5",
"zod": "^3.25.71"
},
"devDependencies": {
@ -5306,6 +5307,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
@ -6383,6 +6393,19 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
@ -6610,6 +6633,15 @@
"typescript": "^5"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -6723,6 +6755,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -7949,6 +7993,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -11510,6 +11563,18 @@
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -13356,6 +13421,24 @@
"bs58check": "^4.0.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -13548,6 +13631,27 @@
"node": ">=10.0.0"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View file

@ -126,6 +126,7 @@
"three": "^0.185.1",
"vaul": "^0.9.3",
"websocket-ts": "^2.2.1",
"xlsx": "^0.18.5",
"zod": "^3.25.71"
},
"devDependencies": {

View file

@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useBtcRates } from '@/hooks/useExchangeRate';
import { useToast } from '@/hooks/useToast';
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { cn } from '@/lib/utils';
@ -83,6 +84,8 @@ function currencyLabel(cur: string): string {
interface CalcState {
currency: CurrencyCode;
/** Optional business/shop name, used on client quotes. */
businessName: string;
hourlyRate: number;
overheadFactor: number;
boardsPerMonth: number;
@ -101,6 +104,7 @@ const uid = () => Math.random().toString(36).slice(2, 9);
const DEFAULT_CALC: CalcState = {
currency: 'USD',
businessName: '',
hourlyRate: 20,
overheadFactor: 2.5,
boardsPerMonth: 160,
@ -302,9 +306,35 @@ function calcStateFromCsv(text: string): CalcState | null {
return out;
}
/** Supplier price-list shape: `name,order_cost,shipping,boards,order_min,unload_min`. */
function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
const rows = parseCsv(text);
if (rows.length < 2) return null;
const header = rows[0].map((h) => h.trim().toLowerCase());
const idx = (n: string) => header.indexOf(n);
if (idx('name') !== 0 || idx('order_cost') < 0) return null;
const out: MaterialRow[] = [];
for (const r of rows.slice(1)) {
const name = (r[idx('name')] ?? '').trim();
if (!name) continue;
const n = (col: string) => (idx(col) >= 0 ? Number(r[idx(col)]) || 0 : 0);
out.push({
id: uid(),
name,
orderCost: n('order_cost'),
shipping: n('shipping'),
boards: n('boards') || 1,
orderMin: n('order_min'),
unloadMin: n('unload_min'),
});
}
return out.length > 0 ? out : null;
}
export function DeckCostCalculator() {
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC);
const { data: rates } = useBtcRates();
const { toast } = useToast();
const [pickerOpen, setPickerOpen] = useState(false);
const [query, setQuery] = useState('');
const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []);
@ -516,6 +546,154 @@ export function DeckCostCalculator() {
doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`);
};
// Customer-facing quote (optional business name).
const exportQuotePdf = async () => {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth();
const M = 48;
const rightX = pageW - M;
let y = 84;
doc.setFillColor(255, 0, 64); doc.rect(M, y - 24, 40, 4, 'F');
doc.setFont('helvetica', 'bold'); doc.setFontSize(20); doc.setTextColor(15, 15, 15);
doc.text(s.businessName.trim() || 'SkateSpots', M, y); y += 20;
doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(120, 120, 120);
doc.text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), M, y); y += 44;
doc.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15);
doc.text('QUOTE', M, y); y += 26;
doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 26;
doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(20, 20, 20);
doc.text('Custom skateboard deck', M, y);
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 18;
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
doc.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 30;
doc.setDrawColor(220, 220, 220); doc.line(M, y, rightX, y); y += 24;
doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20);
doc.text('Total', M, y);
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 32;
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
doc.text('Does not include shipping. Quote valid 30 days.', M, y); y += 14;
if (s.businessName.trim()) { doc.text(`Prepared by ${s.businessName.trim()}.`, M, y); y += 14; }
doc.text('Generated with SkateSpots · skate-spots.org', M, y);
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
};
// Shareable PNG summary card (canvas).
const exportPng = () => {
const W = 1000;
const H = 660;
const c = document.createElement('canvas');
c.width = W; c.height = H;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#ff0040'; ctx.fillRect(48, 46, 64, 6);
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 34px system-ui, sans-serif';
ctx.fillText('Deck cost', 48, 110);
ctx.fillStyle = '#9ca3af'; ctx.font = '16px system-ui, sans-serif';
ctx.fillText(`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`, 48, 140);
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206);
ctx.fillStyle = '#ccff00'; ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText(fmt(r.priceWithPack), 48, 256);
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
ctx.fillText('SELLING PRICE · NO PACKAGING', 540, 206);
ctx.fillStyle = '#e5e7eb'; ctx.font = 'bold 32px system-ui, sans-serif';
ctx.fillText(fmt(r.priceNoPack), 540, 250);
const rows: [string, number][] = [
['Equipment', r.equipment],
['Operations', r.operations],
['Materials', r.materials],
['Processes', r.processes],
['Packaging', r.packaging],
];
let y = 330;
ctx.font = '15px system-ui, sans-serif';
for (const [label, val] of rows) {
ctx.fillStyle = '#9ca3af'; ctx.fillText(label, 48, y);
ctx.fillStyle = '#e5e7eb'; ctx.textAlign = 'right'; ctx.fillText(fmt(val), W - 48, y); ctx.textAlign = 'left';
y += 30;
}
y += 12;
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
ctx.fillText('COST / BOARD (WITH PACKAGING)', 48, y);
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fmt(r.costWithPack), 48, y + 32);
ctx.fillStyle = '#6b7280'; ctx.font = '12px system-ui, sans-serif';
ctx.fillText('Generated with SkateSpots · skate-spots.org', 48, H - 40);
const a = document.createElement('a');
a.href = c.toDataURL('image/png');
a.download = 'deck-cost.png';
a.click();
};
// Multi-sheet XLSX workbook (lazy — SheetJS is only loaded on demand).
const exportXlsx = async () => {
const XLSX = await import('xlsx');
const wb = XLSX.utils.book_new();
const num = (v: number) => Number(v.toFixed(4));
const summary: (string | number)[][] = [
['SkateSpots — Deck cost calculator'],
[],
['Currency', cur],
['Date', new Date().toISOString().slice(0, 10)],
[],
['Cost / board (no packaging)', num(r.costNoPack)],
['Cost / board (with packaging)', num(r.costWithPack)],
['Selling price (no packaging)', num(r.priceNoPack)],
['Selling price (with packaging)', num(r.priceWithPack)],
[],
['Equipment per board', num(r.equipment)],
['Operations per board', num(r.operations)],
['Materials per board', num(r.materials)],
['Processes per board', num(r.processes)],
['Packaging per board', num(r.packaging)],
];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), 'Summary');
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
['Equipment', 'Cost', 'Setup (h)', 'Boards', 'Per board'],
...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]),
]), 'Equipment');
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
['Material', 'Order cost', 'Shipping', 'Boards', 'Order (min)', 'Unload (min)', 'Per board'],
...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]),
]), 'Materials');
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
['Process', 'Time (min)', 'Boards', 'Per board'],
...s.processes.map((p) => [p.name, p.minutes, p.boards, num(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0)]),
]), 'Processes');
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
['Packaging', 'Time/board (min)', 'Materials/board', 'Per board'],
...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]),
]), 'Packaging');
XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`);
};
const copySummary = async () => {
const text = [
`Deck cost — ${currencyLabel(cur)}`,
`Cost / board (with packaging): ${fmt(r.costWithPack)}`,
`Selling price (no packaging): ${fmt(r.priceNoPack)}`,
`Selling price (with packaging): ${fmt(r.priceWithPack)}`,
'',
`Equipment ${fmt(r.equipment)} · Operations ${fmt(r.operations)} · Materials ${fmt(r.materials)} · Processes ${fmt(r.processes)} · Packaging ${fmt(r.packaging)}`,
].join('\n');
try {
await navigator.clipboard.writeText(text);
toast({ title: 'Summary copied' });
} catch {
toast({ title: 'Could not copy', variant: 'destructive' });
}
};
const download = (filename: string, content: string, mime: string) => {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
@ -580,8 +758,12 @@ export function DeckCostCalculator() {
try {
const text = String(reader.result);
if (isCsv) {
const parsed = calcStateFromCsv(text);
if (parsed) setS(parsed);
const mats = materialsFromSupplierCsv(text);
if (mats) setS((prev) => ({ ...prev, materials: mats }));
else {
const parsed = calcStateFromCsv(text);
if (parsed) setS(parsed);
}
} else {
setS({ ...freshDefault(), ...(JSON.parse(text) as CalcState) });
}
@ -836,6 +1018,18 @@ export function DeckCostCalculator() {
</div>
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
{/* Optional business name — appears on the client quote PDF. */}
<div className="mt-3">
<label className="mb-1 block text-[11px] font-semibold text-gray-400" htmlFor="calc-business">Quote name (optional — for the client PDF)</label>
<Input
id="calc-business"
value={s.businessName}
onChange={(e) => patch({ businessName: e.target.value })}
placeholder="Your name / shop"
className="max-w-xs bg-black/40 border-white/10"
/>
</div>
{/* Turn the number into an action: list it at this price, or find someone
to build it. */}
<div className="mt-4 flex flex-wrap gap-2">
@ -882,10 +1076,14 @@ export function DeckCostCalculator() {
</PopoverTrigger>
<PopoverContent align="start" className="w-52 p-1">
{[
{ label: 'PDF (print / quote)', run: () => void exportPdf() },
{ label: 'PDF — full sheet', run: () => void exportPdf() },
{ label: 'PDF — client quote', run: () => void exportQuotePdf() },
{ label: 'PNG image', run: exportPng },
{ label: 'XLSX workbook', run: () => void exportXlsx() },
{ label: 'CSV (spreadsheet)', run: exportCsv },
{ label: 'Markdown', run: exportMarkdown },
{ label: 'JSON (backup)', run: exportJson },
{ label: 'Copy summary', run: () => void copySummary() },
].map((o) => (
<button key={o.label} type="button" onClick={o.run} className="flex w-full min-h-0 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold text-gray-300 hover:bg-white/5">
{o.label}