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
@@ -18,6 +18,7 @@ export default async function HseIncidentDetailPage({ params }: Props) {
.select(`
id, reference_no, incident_type, description, severity, status,
injury_involved, asset_involved, medical_status, lost_days,
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
reported_at, closed_at,
sites (id, name),
zones (id, name),
@@ -69,6 +70,30 @@ export default async function HseIncidentDetailPage({ params }: Props) {
</Link>
</div>
)}
{Boolean(
(incident as { is_fatality?: boolean }).is_fatality ||
(incident as { is_serious_bodily_injury?: boolean }).is_serious_bodily_injury ||
(incident as { is_dangerous_occurrence?: boolean }).is_dangerous_occurrence ||
((incident as { lost_days?: number | null }).lost_days ?? 0) >= 4
) && (
<div className="mt-4 flex gap-3">
<a
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
target="_blank"
className="inline-block bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900"
>
Download JKKP 6 (PDF)
</a>
<a
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
target="_blank"
className="inline-block bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700"
>
Download JKKP 7 (PDF)
</a>
</div>
)}
</main>
)
}
+75
View File
@@ -0,0 +1,75 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { buildJkkp6Pdf, buildJkkp7Pdf, type JkkpIncident } from '@/lib/pdf/jkkp'
import { computeDoshObligation } from '@/lib/incidents/dosh'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const form = request.nextUrl.searchParams.get('form')
if (form !== 'jkkp6' && form !== 'jkkp7')
return NextResponse.json({ error: 'form must be jkkp6 or jkkp7' }, { status: 422 })
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data: incident } = await supabase
.from('incidents')
.select(`
reference_no, incident_type, description, reported_at, severity, lost_days,
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
sites (name),
reporter:users!reported_by (name)
`)
.eq('id', id)
.single()
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const dosh = computeDoshObligation({
is_fatality: (incident as { is_fatality: boolean }).is_fatality,
is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury,
is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence,
is_occupational_disease: (incident as { is_occupational_disease: boolean }).is_occupational_disease,
lost_days: (incident as { lost_days: number | null }).lost_days,
})
const required = form === 'jkkp6' ? dosh.requires_jkkp6 : dosh.requires_jkkp7
if (!required) return NextResponse.json({ error: 'This form is not required for this incident' }, { status: 400 })
const jkkpIncident: JkkpIncident = {
reference_no: (incident as { reference_no: string | null }).reference_no,
incident_type: (incident as { incident_type: string }).incident_type,
description: (incident as { description: string }).description,
reported_at: (incident as { reported_at: string }).reported_at,
severity: (incident as { severity: number | null }).severity,
is_fatality: (incident as { is_fatality: boolean }).is_fatality,
is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury,
is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence,
lost_days: (incident as { lost_days: number | null }).lost_days,
site_name: (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown',
reporter_name: (incident.reporter as unknown as { name: string } | null)?.name ?? 'Unknown',
}
const pdfBytes = form === 'jkkp6'
? await buildJkkp6Pdf(jkkpIncident)
: await buildJkkp7Pdf(jkkpIncident)
const ref = jkkpIncident.reference_no ?? id
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${form}-${ref}.pdf"`,
},
})
}
+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()
}
+43
View File
@@ -11,6 +11,7 @@
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.110.2",
"next": "^15.5.20",
"pdf-lib": "^1.17.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"resend": "^6.17.2"
@@ -2008,6 +2009,24 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -7305,6 +7324,12 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7365,6 +7390,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.110.2",
"next": "^15.5.20",
"pdf-lib": "^1.17.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"resend": "^6.17.2"
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { buildJkkp6Pdf, buildJkkp7Pdf } from '@/lib/pdf/jkkp'
const mockIncident = {
reference_no: 'TEST-202607-0001',
incident_type: 'injury',
description: 'Worker fell from platform',
reported_at: new Date('2026-07-01T08:00:00Z').toISOString(),
severity: 4,
is_fatality: false,
is_serious_bodily_injury: true,
is_dangerous_occurrence: false,
lost_days: 5,
site_name: 'Setia Corporation Warehouse 1',
reporter_name: 'John Doe',
}
describe('buildJkkp6Pdf', () => {
it('returns a non-empty Uint8Array', async () => {
const pdf = await buildJkkp6Pdf(mockIncident)
expect(pdf).toBeInstanceOf(Uint8Array)
expect(pdf.length).toBeGreaterThan(100)
})
it('PDF bytes start with %PDF', async () => {
const pdf = await buildJkkp6Pdf(mockIncident)
const header = new TextDecoder().decode(pdf.slice(0, 4))
expect(header).toBe('%PDF')
})
})
describe('buildJkkp7Pdf', () => {
it('returns a non-empty Uint8Array', async () => {
const pdf = await buildJkkp7Pdf(mockIncident)
expect(pdf).toBeInstanceOf(Uint8Array)
expect(pdf.length).toBeGreaterThan(100)
})
it('PDF bytes start with %PDF', async () => {
const pdf = await buildJkkp7Pdf(mockIncident)
const header = new TextDecoder().decode(pdf.slice(0, 4))
expect(header).toBe('%PDF')
})
})
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: [],
exclude: ['node_modules/**', 'node_modules.nosync/**', '.next/**'],
},
resolve: {
alias: {