- 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>
208 lines
9.2 KiB
TypeScript
208 lines
9.2 KiB
TypeScript
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();
|
||
}
|