Files
ims/lib/pdf/incident-report.ts
T

282 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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']>
// WinAnsi (Windows-1252) cannot encode chars outside its range.
// Replace common typographic Unicode with ASCII equivalents, strip the rest.
function sanitize(str: string): string {
return str
.replace(/[‘’]/g, "'")
.replace(/[“”]/g, '"')
.replace(/|—/g, '-')
.replace(/…/g, '...')
.replace(/ /g, ' ')
.replace(/[^\x20-\xFF]/g, '?')
}
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 result: string[] = []
for (const paragraph of text.split('\n')) {
const words = paragraph.split(' ')
let current = ''
for (const word of words) {
const test = current ? `${current} ${word}` : word
if (font.widthOfTextAtSize(test, size) > maxWidth && current) {
result.push(current)
current = word
} else {
current = test
}
}
if (current) result.push(current)
}
return result.length ? result : ['']
}
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 = sanitize(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 * 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()
}