diff --git a/lib/pdf/incident-report.ts b/lib/pdf/incident-report.ts new file mode 100644 index 0000000..f48ce12 --- /dev/null +++ b/lib/pdf/incident-report.ts @@ -0,0 +1,263 @@ +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 { + ensureSpace(state, SECTION_GAP + LINE_HEIGHT + 6) + 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) + 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() +} diff --git a/tests/lib/pdf/incident-report.test.ts b/tests/lib/pdf/incident-report.test.ts new file mode 100644 index 0000000..b47e0f5 --- /dev/null +++ b/tests/lib/pdf/incident-report.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { buildIncidentReportPdf, type IncidentReportData } from '@/lib/pdf/incident-report' + +const MOCK: IncidentReportData = { + referenceNo: 'SC1-202607-0001', + incidentType: 'injury', + reportedAt: '2026-07-01T08:00:00.000Z', + siteName: 'Setia Warehouse KL', + zoneName: 'Loading Bay A', + reporterName: 'Ahmad Bin Ali', + severity: 3, + medicalStatus: 'first_aid', + description: 'Worker slipped on wet floor near loading bay.', + injuryInvolved: true, + assetInvolved: false, + isFatality: false, + isSeriousBodilyInjury: false, + isDangerousOccurrence: false, + isOccupationalDisease: false, + lostDays: null, + triagedBy: 'Supervisor Tan', + triagedAt: '2026-07-01T10:00:00.000Z', + triageNotes: 'Reviewed on-site. First aid applied.', + investigation: { + method: 'five_why', + findingsText: 'Floor was wet due to a leaking pipe.', + rootCauseSummary: 'Maintenance backlog on pipe inspection.', + alcoholTestResult: 'negative', + urineTestResult: 'negative', + }, + capas: [ + { + description: 'Install non-slip mats in loading bay.', + ownerName: 'Maintenance Dept', + department: 'Maintenance', + dueDate: '2026-07-15', + priority: 'high', + status: 'closed', + completedAt: '2026-07-14T09:00:00.000Z', + ownerNotes: 'Mats installed and inspected.', + }, + ], + addenda: [ + { + authorName: 'HSE Officer Lim', + createdAt: '2026-07-20T14:00:00.000Z', + body: 'Follow-up inspection passed. Area cleared.', + }, + ], + closedAt: '2026-07-20T15:00:00.000Z', +} + +describe('buildIncidentReportPdf', () => { + it('returns a Uint8Array starting with PDF magic bytes', async () => { + const result = await buildIncidentReportPdf(MOCK) + expect(result).toBeInstanceOf(Uint8Array) + // PDF files start with %PDF + const header = String.fromCharCode(...result.slice(0, 4)) + expect(header).toBe('%PDF') + }) + + it('handles incident with no investigation, CAPAs, or addenda', async () => { + const minimal: IncidentReportData = { + ...MOCK, + triagedBy: null, + triagedAt: null, + triageNotes: null, + investigation: null, + capas: [], + addenda: [], + } + const result = await buildIncidentReportPdf(minimal) + expect(result).toBeInstanceOf(Uint8Array) + const header = String.fromCharCode(...result.slice(0, 4)) + expect(header).toBe('%PDF') + }) +})