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:
parent
71f53bcc51
commit
0387d005d0
1 changed files with 92 additions and 54 deletions
|
|
@ -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`);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue