feat: JKKP 6/7 PDF generation via pdf-lib, download from incident detail

Also excludes node_modules.nosync from vitest test discovery to fix pre-existing bleed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
2026-07-11 15:24:18 +08:00
co-authored by Claude Opus 4.8
parent 75fa2605bf
commit 54332d0297
7 changed files with 293 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
export type JkkpIncident = {
reference_no: string | null
incident_type: string
description: string
reported_at: string
severity: number | null
is_fatality: boolean
is_serious_bodily_injury: boolean
is_dangerous_occurrence: boolean
lost_days: number | null
site_name: string
reporter_name: string
}
type EmbeddedFont = Awaited<ReturnType<PDFDocument['embedFont']>>
type Page = ReturnType<PDFDocument['addPage']>
async function createBaseDoc(title: string): Promise<{
doc: PDFDocument
page: Page
font: EmbeddedFont
boldFont: EmbeddedFont
y: { value: number }
}> {
const doc = await PDFDocument.create()
const page = doc.addPage([595, 842]) // A4
const font = await doc.embedFont(StandardFonts.Helvetica)
const boldFont = await doc.embedFont(StandardFonts.HelveticaBold)
const y = { value: 800 }
page.drawText(title, { x: 50, y: y.value, size: 14, font: boldFont, color: rgb(0, 0, 0) })
y.value -= 8
page.drawLine({ start: { x: 50, y: y.value }, end: { x: 545, y: y.value }, thickness: 1, color: rgb(0, 0, 0) })
y.value -= 20
return { doc, page, font, boldFont, y }
}
function drawField(
page: Page,
label: string,
value: string,
font: EmbeddedFont,
boldFont: EmbeddedFont,
y: { value: number }
) {
page.drawText(label + ':', { x: 50, y: y.value, size: 9, font: boldFont, color: rgb(0.3, 0.3, 0.3) })
const lines = value.length > 80 ? [value.slice(0, 80), value.slice(80, 160)] : [value]
for (const line of lines) {
y.value -= 14
page.drawText(line || '—', { x: 50, y: y.value, size: 10, font, color: rgb(0, 0, 0) })
}
y.value -= 8
}
export async function buildJkkp6Pdf(incident: JkkpIncident): Promise<Uint8Array> {
const { doc, page, font, boldFont, y } = await createBaseDoc('BORANG JKKP 6 — Notis Kemalangan / Kejadian Berbahaya')
const reportedDate = new Date(incident.reported_at).toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })
drawField(page, 'Rujukan / Reference', incident.reference_no ?? '—', font, boldFont, y)
drawField(page, 'Tapak / Site', incident.site_name, font, boldFont, y)
drawField(page, 'Tarikh Laporan / Date Reported', reportedDate, font, boldFont, y)
drawField(page, 'Jenis Insiden / Incident Type', incident.incident_type.replace(/_/g, ' '), font, boldFont, y)
drawField(page, 'Dilaporkan Oleh / Reported By', incident.reporter_name, font, boldFont, y)
drawField(page, 'Penerangan / Description', incident.description, font, boldFont, y)
drawField(page, 'Kematian / Fatality', incident.is_fatality ? 'Yes' : 'No', font, boldFont, y)
drawField(page, 'Kecederaan Serius / Serious Bodily Injury', incident.is_serious_bodily_injury ? 'Yes' : 'No', font, boldFont, y)
drawField(page, 'Kejadian Berbahaya / Dangerous Occurrence', incident.is_dangerous_occurrence ? 'Yes' : 'No', font, boldFont, y)
drawField(page, 'Hari Hilang Kerja / Lost Days', incident.lost_days !== null ? String(incident.lost_days) : '—', font, boldFont, y)
y.value -= 20
page.drawText('Nota: Borang ini adalah draf yang dijana secara automatik. Sila semak sebelum dikemukakan ke DOSH.',
{ x: 50, y: y.value, size: 8, font, color: rgb(0.5, 0.5, 0.5) })
return doc.save()
}
export async function buildJkkp7Pdf(incident: JkkpIncident): Promise<Uint8Array> {
const { doc, page, font, boldFont, y } = await createBaseDoc('BORANG JKKP 7 — Laporan Siasatan Kemalangan')
const reportedDate = new Date(incident.reported_at).toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })
drawField(page, 'Rujukan / Reference', incident.reference_no ?? '—', font, boldFont, y)
drawField(page, 'Tapak / Site', incident.site_name, font, boldFont, y)
drawField(page, 'Tarikh Kemalangan / Incident Date', reportedDate, font, boldFont, y)
drawField(page, 'Jenis Insiden / Incident Type', incident.incident_type.replace(/_/g, ' '), font, boldFont, y)
drawField(page, 'Penerangan Kemalangan / Incident Description', incident.description, font, boldFont, y)
drawField(page, 'Keterukan / Severity', incident.severity !== null ? String(incident.severity) + ' / 5' : '—', font, boldFont, y)
drawField(page, 'Hari Hilang Kerja / Lost Days', incident.lost_days !== null ? String(incident.lost_days) : '—', font, boldFont, y)
y.value -= 20
page.drawText('Rumusan Punca Asas / Root Cause Summary:', { x: 50, y: y.value, size: 9, font: boldFont, color: rgb(0.3, 0.3, 0.3) })
y.value -= 14
page.drawText('[To be completed by HSE investigator]', { x: 50, y: y.value, size: 10, font, color: rgb(0.6, 0.6, 0.6) })
y.value -= 30
page.drawText('Nota: Borang ini adalah draf yang dijana secara automatik. Sila semak sebelum dikemukakan ke DOSH.',
{ x: 50, y: y.value, size: 8, font, color: rgb(0.5, 0.5, 0.5) })
return doc.save()
}