diff --git a/app/(protected)/hse/incidents/[id]/page.tsx b/app/(protected)/hse/incidents/[id]/page.tsx index 2056672..fefa9f5 100644 --- a/app/(protected)/hse/incidents/[id]/page.tsx +++ b/app/(protected)/hse/incidents/[id]/page.tsx @@ -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) { )} + + {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 + ) && ( +
+ + Download JKKP 6 (PDF) + + + Download JKKP 7 (PDF) + +
+ )} ) } diff --git a/app/api/incidents/[id]/jkkp-pdf/route.ts b/app/api/incidents/[id]/jkkp-pdf/route.ts new file mode 100644 index 0000000..a61883b --- /dev/null +++ b/app/api/incidents/[id]/jkkp-pdf/route.ts @@ -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"`, + }, + }) +} diff --git a/lib/pdf/jkkp.ts b/lib/pdf/jkkp.ts new file mode 100644 index 0000000..8237979 --- /dev/null +++ b/lib/pdf/jkkp.ts @@ -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> +type Page = ReturnType + +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 { + 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 { + 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() +} diff --git a/package-lock.json b/package-lock.json index ee5a23b..9eeccc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ed861f6..3feac3c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/lib/pdf/jkkp.test.ts b/tests/lib/pdf/jkkp.test.ts new file mode 100644 index 0000000..33af8e2 --- /dev/null +++ b/tests/lib/pdf/jkkp.test.ts @@ -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') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 3872217..3e46418 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: [], + exclude: ['node_modules/**', 'node_modules.nosync/**', '.next/**'], }, resolve: { alias: {