diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index fe682e6..279c6e6 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -238,76 +238,114 @@ export function DeckCostCalculator() { const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const pageW = doc.internal.pageSize.getWidth(); const pageH = doc.internal.pageSize.getHeight(); - const left = 40; - let y = 48; + const M = 48; + const rightX = pageW - M; + const contentW = pageW - M * 2; + let y = M + 8; - const ensure = (needed: number) => { - if (y + needed > pageH - 40) { doc.addPage(); y = 48; } - }; - const text = (str: string, size = 10, bold = false, gray = 20) => { - ensure(size + 5); + const ensure = (h: number) => { if (y + h > pageH - M) { doc.addPage(); y = M; } }; + const setText = (size: number, bold: boolean, gray: number) => { doc.setFontSize(size); doc.setFont('helvetica', bold ? 'bold' : 'normal'); doc.setTextColor(gray, gray, gray); - doc.text(str, left, y); - y += size + 5; }; - const heading = (str: string) => { y += 6; text(str.toUpperCase(), 10, true, 90); y += 2; }; - const row = (k: string, v: string) => { - ensure(13); - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.setTextColor(70, 70, 70); - doc.text(k, left, y); - doc.setTextColor(20, 20, 20); - doc.text(v, pageW - 40, y, { align: 'right' }); - y += 13; + const section = (title: string) => { + ensure(52); + y += 12; + setText(10, true, 40); doc.text(title.toUpperCase(), M, y); y += 6; + doc.setDrawColor(180, 220, 0); doc.setLineWidth(1.4); doc.line(M, y, M + 26, y); y += 12; + doc.setDrawColor(230, 230, 230); doc.setLineWidth(0.6); doc.line(M, y, rightX, y); y += 12; + }; + const kv = (label: string, value: string, accent = false) => { + ensure(15); + setText(9.5, false, 95); doc.text(label, M, y); + setText(9.5, true, accent ? 40 : 20); doc.text(value, rightX, y, { align: 'right' }); + y += 15; + }; + const item = (name: string, value: string, detail?: string) => { + ensure(detail ? 30 : 16); + setText(9.5, true, 20); doc.text(name, M, y); + setText(9.5, true, 20); doc.text(value, rightX, y, { align: 'right' }); + y += 12; + if (detail) { setText(8, false, 135); doc.text(detail, M, y); y += 12; } + y += 2; }; - text('SkateSpots — Deck cost calculator', 15, true); - text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), 9, false, 120); - row('Currency', cur === 'sats' ? 'sats (Bitcoin)' : cur); + // ── Header ── + doc.setFillColor(255, 0, 64); doc.rect(M, y - 12, 34, 3, 'F'); + setText(17, true, 15); doc.text('SkateSpots — Deck cost calculator', M, y + 8); y += 26; + setText(9, false, 120); + doc.text( + `${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur}`, + M, y, + ); + y += 10; - heading('Labor'); - row('Hourly rate', fmt(s.hourlyRate)); - row('Overhead factor', String(s.overheadFactor)); - row('Labor cost', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`); + section('Labor'); + kv('Hourly rate', fmt(s.hourlyRate)); + kv('Overhead factor', `${s.overheadFactor}\u00d7`); + kv('Labor cost', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`); - heading('Equipment'); - s.equipment.forEach((e) => row(e.name, `${fmt(e.cost)} · ${e.boards.toLocaleString()} boards → ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}`)); - row('Equipment per board', fmt(r.equipment)); + section('Equipment'); + s.equipment.forEach((e) => { + const per = e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0; + item(e.name, fmt(per), `${fmt(e.cost)}${e.setupHrs ? ` + ${e.setupHrs}h setup` : ''} \u00b7 ${e.boards.toLocaleString()} boards`); + }); + kv('Equipment per board', fmt(r.equipment), true); - heading('Operation expenses'); - row('Boards / month', String(s.boardsPerMonth)); - row('Rent / month', fmt(s.rent)); - row('Utilities / month', fmt(s.utilities)); - row('Subscriptions / month', fmt(s.subscriptions)); - row('Per board', fmt(r.operations)); + section('Operation expenses'); + kv('Boards / month', String(s.boardsPerMonth)); + kv('Rent / month', fmt(s.rent)); + kv('Utilities / month', fmt(s.utilities)); + kv('Subscriptions / month', fmt(s.subscriptions)); + kv('Per board', fmt(r.operations), true); - heading('Materials'); - s.materials.forEach((m) => row(m.name, `${fmt(m.orderCost)}${m.shipping ? ` + ${fmt(m.shipping)} ship` : ''} · ${m.boards.toLocaleString()} boards`)); - row('Materials per board', fmt(r.materials)); + section('Materials'); + s.materials.forEach((m) => { + const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0; + item(m.name, fmt(per), `${fmt(m.orderCost)}${m.shipping ? ` + ${fmt(m.shipping)} ship` : ''} \u00b7 ${m.boards.toLocaleString()} boards`); + }); + kv('Materials per board', fmt(r.materials), true); - heading('Process costs'); - s.processes.forEach((p) => row(p.name, `${p.minutes} min / ${p.boards} boards`)); - row('Processes per board', fmt(r.processes)); + section('Process costs'); + s.processes.forEach((p) => item(p.name, fmt(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0), `${p.minutes} min${p.boards > 1 ? ` / ${p.boards} boards` : ''}`)); + kv('Processes per board', fmt(r.processes), true); - heading('Packaging'); - s.packaging.forEach((p) => row(p.name, `${p.minutesPerBoard} min · ${fmt(p.materialsPerBoard)}`)); - row('Packaging per board', fmt(r.packaging)); + section('Packaging'); + s.packaging.forEach((p) => item(p.name, fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin), `${p.minutesPerBoard} min \u00b7 ${fmt(p.materialsPerBoard)} materials`)); + kv('Packaging per board', fmt(r.packaging), true); - heading('Result'); - row('Profit margin', `${s.profitMargin}×`); - row('Scrap', `${s.scrapPct}%`); - row('Cost / board (no packaging)', fmt(r.costNoPack)); - row('Cost / board (with packaging)', fmt(r.costWithPack)); + // ── Result (kept together) ── + ensure(180); + section('Result'); + kv('Profit margin', `${s.profitMargin}\u00d7`); + kv('Scrap', `${s.scrapPct}%`); + kv('Cost / board (no packaging)', fmt(r.costNoPack)); + kv('Cost / board (with packaging)', fmt(r.costWithPack)); + y += 6; + + const priceBox = (label: string, value: string) => { + ensure(36); + doc.setFillColor(244, 252, 218); doc.roundedRect(M, y - 2, contentW, 27, 4, 4, 'F'); + setText(9, false, 90); doc.text(label, M + 10, y + 16); + setText(14, true, 30); doc.text(value, rightX - 10, y + 17, { align: 'right' }); + y += 36; + }; + priceBox('Selling price (no packaging)', fmt(r.priceNoPack)); + priceBox('Selling price (with packaging)', fmt(r.priceWithPack)); + + ensure(30); y += 4; - text(`Selling price (no packaging): ${fmt(r.priceNoPack)}`, 12, true, 20); - text(`Selling price (with packaging): ${fmt(r.priceWithPack)}`, 12, true, 20); - text('Does not include shipping to the buyer.', 8, false, 120); + setText(8, false, 135); doc.text('Does not include shipping to the buyer.', M, y); y += 16; + setText(8, false, 155); doc.text('Generated by SkateSpots \u00b7 skate-spots.org', M, y); - y += 8; - text('Generated by SkateSpots · skate-spots.org', 8, false, 130); + // ── Page numbers ── + const pages = doc.getNumberOfPages(); + for (let i = 1; i <= pages; i++) { + doc.setPage(i); + setText(8, false, 160); + doc.text(`${i} / ${pages}`, rightX, pageH - 24, { align: 'right' }); + } doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`); };