feat(pdf): add incident report PDF builder
This commit is contained in:
@@ -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<ReturnType<PDFDocument['embedFont']>>
|
||||
type Page = ReturnType<PDFDocument['addPage']>
|
||||
|
||||
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<Uint8Array> {
|
||||
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()
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user