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:
parent
daeb3fbf3e
commit
1a8c483682
4 changed files with 314 additions and 8 deletions
13
AGENTS.md
13
AGENTS.md
|
|
@ -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
104
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue