Fix the deck-cost PDF layout

The first pass overflowed the right margin (long value strings), had no section
separation, and split the Result across pages. Rebuilt the generator:
- accent rule + title, date/currency subtitle;
- each section gets a lime underline + a hairline rule;
- rows are two-line items (name + per-board value, then a grey detail line with
  cost/boards/setup), so nothing runs past the margin;
- Result section is kept together (page-break guard) and the two selling prices
  render in highlighted boxes;
- page numbers (n / N).
This commit is contained in:
SkateSpots 2026-10-05 10:39:35 +07:00
parent 71f53bcc51
commit 0387d005d0

View file

@ -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`);
};