import { PDFDocument, StandardFonts, rgb } from 'pdf-lib' export type IncidentReportData = { referenceNo: string | null incidentType: string reportedAt: string siteName: string zoneName: string | null reporterName: string severity: number | null medicalStatus: string | null description: string injuryInvolved: boolean assetInvolved: boolean isFatality: boolean isSeriousBodilyInjury: boolean isDangerousOccurrence: boolean isOccupationalDisease: boolean lostDays: number | null triagedBy: string | null triagedAt: string | null triageNotes: string | null investigation: { method: string findingsText: string | null rootCauseSummary: string | null alcoholTestResult: string | null urineTestResult: string | null } | null capas: Array<{ description: string ownerName: string department: string dueDate: string priority: string status: string completedAt: string | null ownerNotes: string | null }> addenda: Array<{ authorName: string createdAt: string body: string }> closedAt: string | null } type EmbeddedFont = Awaited> type Page = ReturnType const MARGIN = 50 const PAGE_WIDTH = 595 const PAGE_HEIGHT = 842 const BODY_FONT_SIZE = 10 const LABEL_FONT_SIZE = 9 const SECTION_FONT_SIZE = 11 const LINE_HEIGHT = 16 const SECTION_GAP = 20 const BOTTOM_MARGIN = 60 type State = { doc: PDFDocument pages: Page[] font: EmbeddedFont boldFont: EmbeddedFont y: number } function currentPage(state: State): Page { return state.pages[state.pages.length - 1] } function newPage(state: State): void { const page = state.doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]) state.pages.push(page) state.y = PAGE_HEIGHT - MARGIN } function ensureSpace(state: State, needed: number): void { if (state.y - needed < BOTTOM_MARGIN) newPage(state) } function wrapText(text: string, maxWidth: number, font: EmbeddedFont, size: number): string[] { const words = text.split(' ') const lines: string[] = [] let current = '' for (const word of words) { const test = current ? `${current} ${word}` : word if (font.widthOfTextAtSize(test, size) > maxWidth && current) { lines.push(current) current = word } else { current = test } } if (current) lines.push(current) return lines.length ? lines : [''] } function drawSection(state: State, title: string): void { const yBefore = state.y ensureSpace(state, SECTION_GAP + LINE_HEIGHT + 6) // Only add gap if we didn't just start a new page if (state.y === yBefore) { state.y -= SECTION_GAP } currentPage(state).drawRectangle({ x: MARGIN, y: state.y - 4, width: PAGE_WIDTH - MARGIN * 2, height: LINE_HEIGHT + 4, color: rgb(0.9, 0.95, 0.9), }) currentPage(state).drawText(title.toUpperCase(), { x: MARGIN + 4, y: state.y, size: SECTION_FONT_SIZE, font: state.boldFont, color: rgb(0.05, 0.4, 0.1), }) state.y -= LINE_HEIGHT + 8 } function drawField(state: State, label: string, value: string | null): void { const v = value ?? '—' const maxW = PAGE_WIDTH - MARGIN * 2 - 120 const lines = wrapText(v, maxW, state.font, BODY_FONT_SIZE) ensureSpace(state, (lines.length + 1) * LINE_HEIGHT) currentPage(state).drawText(label + ':', { x: MARGIN, y: state.y, size: LABEL_FONT_SIZE, font: state.boldFont, color: rgb(0.3, 0.3, 0.3), }) for (const line of lines) { currentPage(state).drawText(line, { x: MARGIN + 120, y: state.y, size: BODY_FONT_SIZE, font: state.font, color: rgb(0, 0, 0), }) state.y -= LINE_HEIGHT } if (lines.length === 0) state.y -= LINE_HEIGHT } function fmt(iso: string | null): string { if (!iso) return '—' return new Date(iso).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur', hour12: false }) } function fmtDate(iso: string | null): string { if (!iso) return '—' return new Date(iso).toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' }) } export async function buildIncidentReportPdf(data: IncidentReportData): Promise { const doc = await PDFDocument.create() const font = await doc.embedFont(StandardFonts.Helvetica) const boldFont = await doc.embedFont(StandardFonts.HelveticaBold) const firstPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]) const state: State = { doc, pages: [firstPage], font, boldFont, y: PAGE_HEIGHT - MARGIN } // ── Header ────────────────────────────────────────────────────────────── currentPage(state).drawText('INCIDENT INVESTIGATION REPORT', { x: MARGIN, y: state.y, size: 16, font: boldFont, color: rgb(0.05, 0.4, 0.1), }) state.y -= 22 currentPage(state).drawText(`Reference: ${data.referenceNo ?? '—'}`, { x: MARGIN, y: state.y, size: 10, font, color: rgb(0.2, 0.2, 0.2), }) currentPage(state).drawText(`Generated: ${fmt(new Date().toISOString())}`, { x: PAGE_WIDTH - MARGIN - 160, y: state.y, size: 10, font, color: rgb(0.2, 0.2, 0.2), }) state.y -= 6 currentPage(state).drawLine({ start: { x: MARGIN, y: state.y }, end: { x: PAGE_WIDTH - MARGIN, y: state.y }, thickness: 1.5, color: rgb(0.05, 0.4, 0.1), }) state.y -= 12 // ── Incident Details ───────────────────────────────────────────────────── drawSection(state, 'Incident Details') drawField(state, 'Type', data.incidentType.replace(/_/g, ' ')) drawField(state, 'Reported At', fmt(data.reportedAt)) drawField(state, 'Site', data.siteName) drawField(state, 'Zone', data.zoneName) drawField(state, 'Reported By', data.reporterName) drawField(state, 'Severity', data.severity !== null ? String(data.severity) + ' / 5' : null) drawField(state, 'Medical Status', data.medicalStatus?.replace(/_/g, ' ') ?? null) drawField(state, 'Injury Involved', data.injuryInvolved ? 'Yes' : 'No') drawField(state, 'Asset Involved', data.assetInvolved ? 'Yes' : 'No') drawField(state, 'Fatality', data.isFatality ? 'Yes' : 'No') drawField(state, 'Serious Bodily Injury', data.isSeriousBodilyInjury ? 'Yes' : 'No') drawField(state, 'Dangerous Occurrence', data.isDangerousOccurrence ? 'Yes' : 'No') drawField(state, 'Occupational Disease', data.isOccupationalDisease ? 'Yes' : 'No') drawField(state, 'Lost Days', data.lostDays !== null ? String(data.lostDays) : null) drawField(state, 'Description', data.description) // ── Triage ─────────────────────────────────────────────────────────────── if (data.triagedBy) { drawSection(state, 'Triage') drawField(state, 'Triaged By', data.triagedBy) drawField(state, 'Triaged At', fmt(data.triagedAt)) drawField(state, 'Triage Notes', data.triageNotes) } // ── Investigation ──────────────────────────────────────────────────────── if (data.investigation) { const inv = data.investigation drawSection(state, 'Investigation') drawField(state, 'Method', inv.method.replace(/_/g, ' ')) drawField(state, 'Findings', inv.findingsText) drawField(state, 'Root Cause', inv.rootCauseSummary) drawField(state, 'Alcohol Test', inv.alcoholTestResult) drawField(state, 'Urine Test', inv.urineTestResult) } // ── CAPA Actions ───────────────────────────────────────────────────────── if (data.capas.length > 0) { drawSection(state, `CAPA Actions (${data.capas.length})`) for (let i = 0; i < data.capas.length; i++) { const c = data.capas[i] ensureSpace(state, LINE_HEIGHT * 4) currentPage(state).drawText(`#${i + 1}`, { x: MARGIN, y: state.y, size: BODY_FONT_SIZE, font: boldFont, color: rgb(0, 0, 0), }) state.y -= LINE_HEIGHT drawField(state, 'Action', c.description) drawField(state, 'Owner', c.ownerName) drawField(state, 'Department', c.department) drawField(state, 'Due Date', fmtDate(c.dueDate)) drawField(state, 'Priority', c.priority.toUpperCase()) drawField(state, 'Status', c.status.replace(/_/g, ' ')) if (c.completedAt) drawField(state, 'Completed At', fmt(c.completedAt)) if (c.ownerNotes) drawField(state, 'Owner Notes', c.ownerNotes) state.y -= 8 } } // ── Addenda ────────────────────────────────────────────────────────────── if (data.addenda.length > 0) { drawSection(state, `Addenda (${data.addenda.length})`) for (const a of data.addenda) { drawField(state, 'Author', a.authorName) drawField(state, 'Date', fmt(a.createdAt)) drawField(state, 'Note', a.body) state.y -= 8 } } // ── Closure ────────────────────────────────────────────────────────────── drawSection(state, 'Closure') drawField(state, 'Closed At', fmt(data.closedAt)) // ── Footer on all pages ────────────────────────────────────────────────── for (const page of state.pages) { page.drawText('Generated by IMS — Confidential', { x: MARGIN, y: 30, size: 8, font, color: rgb(0.5, 0.5, 0.5), }) page.drawLine({ start: { x: MARGIN, y: 42 }, end: { x: PAGE_WIDTH - MARGIN, y: 42 }, thickness: 0.5, color: rgb(0.8, 0.8, 0.8), }) } return doc.save() }