mega-plant/lib/job-card-pdf.ts
David 1ec52961ee Cash float card, Office role + Slips tab, job card PDF, manual close/delete
- Cash Floats: ledger card (opening balance, top-ups, slips), manual entries, slip photos
- Office access level; clock app Slips tab (photograph a slip, popup for supplier/cost
  code/comments/amount); only Office sees it; per-role route table in lib/clock-access
- Job cards: Machine Service PDF (with Oil block), JC numbers, manual Close job, Delete
- Hide Expenses > Vehicle Costs and Finance (Bastion) from the menu

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:09:57 +02:00

208 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { formatMinutes, jobTypeLabel } from "./job-cards";
/** Everything the printed job card shows. All optional except the card number: a blank form prints too. */
export type JobCardPdfData = {
jcNo?: number | null;
plantNo?: string;
plantName?: string;
site?: string;
mechanic?: string;
machineHours?: string;
date?: string;
startedAt?: string;
endedAt?: string;
type?: string;
minutes?: number;
note?: string;
/** Oil booked out to this job, e.g. "Hydraulic 68 - 5 L" — its own block above Spares. */
oil?: string[];
/** Parts used — written in by hand today, so empty unless supplied. */
spares?: string[];
};
const A4: [number, number] = [595.28, 841.89];
const dmy = (stamp: string | undefined) => {
if (!stamp) return "";
const d = new Date(stamp.replace(" ", "T"));
if (Number.isNaN(d.getTime())) return "";
const p = new Intl.DateTimeFormat("en-ZA", { timeZone: "Africa/Johannesburg", day: "2-digit", month: "2-digit", year: "numeric" }).formatToParts(d);
const g = (t: string) => p.find((x) => x.type === t)?.value ?? "";
return `${g("day")}/${g("month")}/${g("year")}`;
};
const hm = (stamp: string | undefined) => {
if (!stamp) return "";
const d = new Date(stamp.replace(" ", "T"));
return Number.isNaN(d.getTime()) ? "" : new Intl.DateTimeFormat("en-ZA", { timeZone: "Africa/Johannesburg", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(d);
};
/** Break text into lines no wider than `max` points. */
function wrap(text: string, widthOf: (s: string) => number, max: number): string[] {
const out: string[] = [];
for (const para of text.split(/\r?\n/)) {
let line = "";
for (const word of para.split(/\s+/).filter(Boolean)) {
const next = line ? `${line} ${word}` : word;
if (widthOf(next) <= max) line = next;
else { if (line) out.push(line); line = word; }
}
out.push(line);
}
return out;
}
/**
* The Machine Service job card, laid out like the paper pad the mechanics fill in by hand
* (Section 07, Plant Moving Equipment Record). A real vector PDF, built in the browser with
* pdf-lib. Fields we do not hold (Doc No, Next Service Due, signatures …) print as empty
* boxes to be completed by hand.
*/
export async function buildJobCardPdf(data: JobCardPdfData = {}): Promise<Uint8Array> {
const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib");
const doc = await PDFDocument.create();
const page = doc.addPage(A4);
const reg = await doc.embedFont(StandardFonts.Helvetica);
const bold = await doc.embedFont(StandardFonts.HelveticaBold);
const NAVY = rgb(0.16, 0.2, 0.35);
const INK = rgb(0.08, 0.08, 0.1);
const GOLD = rgb(0.93, 0.68, 0.29);
const RED = rgb(0.78, 0.2, 0.3);
const LINE = rgb(0.25, 0.28, 0.4);
const RULE = rgb(0.72, 0.74, 0.8);
const X = 34, W = A4[0] - 68;
const H = A4[1];
const box = (x: number, y: number, w: number, h: number, width = 0.9) =>
page.drawRectangle({ x, y, width: w, height: h, borderColor: LINE, borderWidth: width });
const text = (s: string, x: number, y: number, size = 9, font = reg, color = INK) =>
page.drawText(s, { x, y, size, font, color });
const hline = (y: number, x1 = X, x2 = X + W, color = RULE) =>
page.drawLine({ start: { x: x1, y }, end: { x: x2, y }, thickness: 0.5, color });
// ---- Header: logo, title, document box
let y = H - 34;
const headH = 62;
box(X, y - headH, W, headH, 1);
const titleX = X + 215, docX = X + W - 150;
page.drawLine({ start: { x: titleX, y }, end: { x: titleX, y: y - headH }, thickness: 1, color: LINE });
page.drawLine({ start: { x: docX, y }, end: { x: docX, y: y - headH }, thickness: 1, color: LINE });
try {
const res = await fetch("/brand/logo.png");
if (res.ok) {
const img = await doc.embedPng(new Uint8Array(await res.arrayBuffer()));
page.drawImage(img, { x: X + 8, y: y - headH + 12, width: 38, height: 38 });
}
} catch { /* the wordmark below still identifies the form */ }
const megaW = bold.widthOfTextAtSize("MEGA", 15), plantW = bold.widthOfTextAtSize("PLANT", 15);
text("MEGA", X + 54, y - 34, 15, bold, GOLD);
text("PLANT", X + 54 + megaW + 5, y - 34, 15, bold, INK);
text("(Pty) Ltd", X + 54 + megaW + 5 + plantW + 3, y - 34, 7, bold, INK);
const title = "Machine Service";
text(title, titleX + (docX - titleX - bold.widthOfTextAtSize(title, 15)) / 2, y - 36, 15, bold);
["Doc. No.", "Page No.", "Rev.", "Date:"].forEach((l, i) => {
const ry = y - (i + 1) * (headH / 4);
if (i) page.drawLine({ start: { x: docX, y: ry + headH / 4 }, end: { x: X + W, y: ry + headH / 4 }, thickness: 0.6, color: LINE });
text(l, docX + 6, ry + 5, 8, bold);
});
page.drawLine({ start: { x: docX + 62, y }, end: { x: docX + 62, y: y - headH }, thickness: 0.6, color: LINE });
// ---- Job Card strip
y -= headH + 10;
box(X, y - 26, W, 26, 1);
text("Job Card", X + W / 2 - 60, y - 17, 13, bold);
text("JC No:", X + W - 140, y - 17, 11, bold);
text(data.jcNo != null ? String(data.jcNo).padStart(4, "0") : "", X + W - 90, y - 17, 13, bold, RED);
// ---- Details grid
y -= 36;
const gridH = 104;
box(X, y - gridH, W, gridH, 1);
const midX = X + W * 0.52;
page.drawLine({ start: { x: midX - 8, y }, end: { x: midX - 8, y: y - gridH }, thickness: 0.6, color: LINE });
const row = (label: string, value: string, x: number, ry: number, lw: number, maxW: number) => {
text(label, x, ry, 8.5, bold);
hline(ry - 3, x + lw, x + lw + maxW, RULE);
let v = value, size = 10.5;
while (v && reg.widthOfTextAtSize(v, size) > maxW - 4 && size > 6.5) size -= 0.5;
if (v) text(v, x + lw + 3, ry, size, reg);
};
const lx = X + 8, rx = midX;
const rowGap = 26;
const r1 = y - 20;
row("Plant No:", [data.plantNo, data.plantName].filter(Boolean).join(" · "), lx, r1, 70, midX - lx - 80);
row("Plant Location / Site:", data.site ?? "", lx, r1 - rowGap, 105, midX - lx - 115);
row("Mechanic:", data.mechanic ?? "", lx, r1 - rowGap * 2, 55, midX - lx - 65);
row("Last Service Date:", "", lx, r1 - rowGap * 3, 90, midX - lx - 100);
const rw = X + W - rx - 14;
row("Machine Hours:", data.machineHours ?? "", rx, r1, 80, rw - 80);
row("Date:", data.date ?? dmy(data.startedAt), rx, r1 - rowGap, 80, rw - 80);
row("Next Service Due:", "", rx, r1 - rowGap * 2, 80, rw - 80);
row("Last Service Hours:", "", rx, r1 - rowGap * 3, 80, rw - 80);
// ---- Work done table
y -= gridH + 10;
const colDate = X + W - 160, colTime = X + W - 75;
const workH = 190;
box(X, y - workH, W, workH, 1);
text("Work Done / Hours / KM", X + 6, y - 14, 9, bold);
text("Date", colDate + 28, y - 14, 9, bold);
text("Time", colTime + 24, y - 14, 9, bold);
page.drawLine({ start: { x: colDate, y }, end: { x: colDate, y: y - workH }, thickness: 0.7, color: LINE });
page.drawLine({ start: { x: colTime, y }, end: { x: colTime, y: y - workH }, thickness: 0.7, color: LINE });
hline(y - 20, X, X + W, LINE);
const lineGap = 19;
for (let ly = y - 20 - lineGap; ly > y - workH + 2; ly -= lineGap) hline(ly);
// Type + note, written on the ruled lines.
const entries: string[] = [];
if (data.type) entries.push(`${jobTypeLabel(data.type) || data.type}${data.minutes ? ` — ${formatMinutes(data.minutes)} on the job` : ""}`);
if (data.note) entries.push(...wrap(data.note, (s) => reg.widthOfTextAtSize(s, 10), colDate - X - 14));
entries.slice(0, 8).forEach((line, i) => text(line, X + 6, y - 20 - lineGap * (i + 1) + 5, 10));
if (data.startedAt) {
text(dmy(data.startedAt), colDate + 6, y - 20 - lineGap + 5, 9);
const t = [hm(data.startedAt), hm(data.endedAt)].filter(Boolean).join("–");
if (t) text(t, colTime + 4, y - 20 - lineGap + 5, 9);
}
// ---- Spares / Charges
const section = (label: string, top: number, h: number, lines: string[]) => {
box(X, top - h, W, h, 1);
text(label, X + 6, top - 14, 9, bold);
page.drawLine({ start: { x: colDate, y: top }, end: { x: colDate, y: top - h }, thickness: 0.7, color: LINE });
page.drawLine({ start: { x: colTime, y: top }, end: { x: colTime, y: top - h }, thickness: 0.7, color: LINE });
hline(top - 20, X, X + W, LINE);
for (let ly = top - 20 - lineGap; ly > top - h + 2; ly -= lineGap) hline(ly);
lines.slice(0, 5).forEach((l, i) => text(l, X + 6, top - 20 - lineGap * (i + 1) + 5, 10));
};
y -= workH + 8;
section("Oil:", y, 78, data.oil ?? []);
y -= 78 + 8;
section("Spares:", y, 90, data.spares ?? []);
y -= 90 + 8;
section("Charges:", y, 66, []);
// ---- Signatures
y -= 66 + 10;
box(X, y - 62, W, 62, 1);
hline(y - 31, X, X + W, LINE);
const sigX = X + W * 0.58;
text("Mechanic Signature:", X + 6, y - 19, 9, bold);
text("Date:", sigX, y - 19, 9, bold);
text("Manager Checked:", X + 6, y - 50, 9, bold);
text("Date:", sigX, y - 50, 9, bold);
hline(y - 22, X + 100, sigX - 14);
hline(y - 53, X + 100, sigX - 14);
hline(y - 22, sigX + 28, X + W - 10);
hline(y - 53, sigX + 28, X + W - 10);
// ---- Footer
y -= 62 + 10;
box(X, y - 22, W, 22, 1);
page.drawLine({ start: { x: X + 120, y }, end: { x: X + 120, y: y - 22 }, thickness: 0.8, color: LINE });
text("Section 07", X + 38, y - 14, 9, bold);
text("SHEQ System documentation: Operations - Plant Moving Equipment Record", X + 128, y - 14, 8.5, bold);
void NAVY;
return doc.save();
}