Compare commits

...
10 Commits
Author SHA1 Message Date
admin 3fd8dfe294 fix(pdf): sanitise non-WinAnsi chars, handle newlines in wrap 2026-07-28 17:53:04 +08:00
admin 740c6da3ba fix(pdf): sanitise Content-Disposition filename, fix triagedByName test fixture 2026-07-28 17:11:16 +08:00
admin 28929f40e2 feat(ui): add Print Report button on closed incidents 2026-07-28 17:06:33 +08:00
adminandClaude Sonnet 4.6 1b0941ec69 feat(pdf): add incident report PDF API route
GET /api/incidents/[id]/report-pdf — hse/admin only, closed incidents only.
Joins incidents + sites + zones + users + investigations + capaActions + addenda,
then delegates to buildIncidentReportPdf() and returns application/pdf download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEYxFQiCyxJvnBCoZeYzB9
2026-07-28 17:03:00 +08:00
adminandClaude Sonnet 4.6 e7d82f3a5a fix(pdf): skip section gap on new page, reserve space for CAPA label
- Fix 1: drawSection now checks if ensureSpace triggered a page break by
  comparing state.y before/after. Only subtract SECTION_GAP if we're not
  at the top of a fresh page, avoiding wasting 20px after page breaks.

- Fix 2: CAPA loop now reserves LINE_HEIGHT * 4 instead of just LINE_HEIGHT
  to keep the label and at least a few fields together on the same page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEYxFQiCyxJvnBCoZeYzB9
2026-07-28 16:56:13 +08:00
admin 8bbdd4f843 feat(pdf): add incident report PDF builder 2026-07-28 16:52:31 +08:00
admin dbc66460c4 fix(dashboard): include all CAPAs in on-time rate, count open+not-overdue as on-time 2026-07-28 16:36:57 +08:00
admin 87251b3fc6 fix(dashboard): compute CAPA overdue count from due_date instead of status field 2026-07-28 16:31:45 +08:00
admin 1103d842d5 fix(deploy): use sudo for systemctl restart ims 2026-07-28 16:26:58 +08:00
admin 6b427b840d fix(capa): compute overdue status at render time in CAPA board page 2026-07-28 16:21:23 +08:00
9 changed files with 648 additions and 13 deletions
+4 -1
View File
@@ -33,6 +33,7 @@ export default async function CapaListPage() {
.orderBy(asc(capaActions.dueDate)) .orderBy(asc(capaActions.dueDate))
) )
const today = new Date().toISOString().slice(0, 10)
const capas = rows.map(r => ({ const capas = rows.map(r => ({
id: r.id, id: r.id,
incident_id: r.incidentId, incident_id: r.incidentId,
@@ -40,7 +41,9 @@ export default async function CapaListPage() {
department: r.department, department: r.department,
due_date: r.dueDate, due_date: r.dueDate,
priority: r.priority, priority: r.priority,
status: r.status, status: (r.status === 'open' || r.status === 'in_progress') && r.dueDate && r.dueDate < today
? 'overdue' as const
: r.status,
incidents: { reference_no: r.incidentRefNo ?? null }, incidents: { reference_no: r.incidentRefNo ?? null },
owner: r.ownerName ? { name: r.ownerName } : null, owner: r.ownerName ? { name: r.ownerName } : null,
})) }))
+9 -9
View File
@@ -74,9 +74,9 @@ export default async function HseDashboardPage({
dueDate: capaActions.dueDate, dueDate: capaActions.dueDate,
completedAt: capaActions.completedAt, completedAt: capaActions.completedAt,
verifiedAt: capaActions.verifiedAt, verifiedAt: capaActions.verifiedAt,
status: capaActions.status,
}) })
.from(capaActions) .from(capaActions)
.where(isNotNull(capaActions.completedAt))
), ),
asAdmin(db => asAdmin(db =>
db.select({ id: doshReports.id }) db.select({ id: doshReports.id })
@@ -131,16 +131,16 @@ export default async function HseDashboardPage({
.slice(0, 10) .slice(0, 10)
const zoneMax = by_zone[0]?.count ?? 1 const zoneMax = by_zone[0]?.count ?? 1
const today = now.toISOString().slice(0, 10)
const capas = completedCapas const capas = completedCapas
const onTime = capas.filter(c => { const onTime = capas.filter(c => {
if (!c.dueDate) return false if (!c.dueDate) return true
const due = new Date(c.dueDate) if (c.completedAt) {
const done = c.verifiedAt const done = c.verifiedAt ? new Date(c.verifiedAt) : new Date(c.completedAt)
? new Date(c.verifiedAt) return done <= new Date(c.dueDate)
: c.completedAt }
? new Date(c.completedAt) // Still open — on time if not yet past due
: null return c.dueDate >= today
return done !== null && done <= due
}) })
const capaOnTimeRate = capas.length > 0 const capaOnTimeRate = capas.length > 0
? Math.round((onTime.length / capas.length) * 100) ? Math.round((onTime.length / capas.length) * 100)
@@ -188,6 +188,21 @@ export default async function HseIncidentDetailPage({ params }: Props) {
</a> </a>
</div> </div>
)} )}
{status === 'closed' && (
<div className="mt-4 flex gap-3 flex-wrap">
<a
href={`/api/incidents/${id}/report-pdf`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 text-white text-xs font-semibold rounded-lg hover:bg-green-700 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Print Report
</a>
</div>
)}
<ClosurePanel incidentId={id} status={status} canClose canAddAddenda /> <ClosurePanel incidentId={id} status={status} canClose canAddAddenda />
<SimilarIncidentsPanel incidentId={id} /> <SimilarIncidentsPanel incidentId={id} />
</main> </main>
+6 -2
View File
@@ -4,7 +4,7 @@ import { asAdmin } from '@/lib/db/with-user'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session' import { getSession } from '@/lib/auth/get-session'
import { incidents, sites, capaActions } from '@/lib/db/schema' import { incidents, sites, capaActions } from '@/lib/db/schema'
import { eq, gte, lt, and } from 'drizzle-orm' import { eq, gte, lt, and, inArray } from 'drizzle-orm'
import { StatCard } from '@/components/dashboard/stat-card' import { StatCard } from '@/components/dashboard/stat-card'
import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel' import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel'
@@ -14,6 +14,7 @@ export default async function ManagementPage() {
if (!['management', 'admin'].includes(session.role)) redirect('/') if (!['management', 'admin'].includes(session.role)) redirect('/')
const now = new Date() const now = new Date()
const today = now.toISOString().slice(0, 10)
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString() const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString() const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString()
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString() const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()
@@ -49,7 +50,10 @@ export default async function ManagementPage() {
asAdmin(db => asAdmin(db =>
db.select({ id: capaActions.id }) db.select({ id: capaActions.id })
.from(capaActions) .from(capaActions)
.where(eq(capaActions.status, 'overdue')) .where(and(
inArray(capaActions.status, ['open', 'in_progress']),
lt(capaActions.dueDate, today)
))
), ),
]) ])
+160
View File
@@ -0,0 +1,160 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { aliasedTable, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth/get-session'
import { withUser } from '@/lib/db/with-user'
import { incidents, sites, zones, users, investigations, capaActions, incidentAddenda } from '@/lib/db/schema'
import { buildIncidentReportPdf } from '@/lib/pdf/incident-report'
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const reporterAlias = aliasedTable(users, 'reporter')
const triagedByAlias = aliasedTable(users, 'triaged_by_user')
const ownerAlias = aliasedTable(users, 'capa_owner')
const addendaAuthorAlias = aliasedTable(users, 'addenda_author')
const [incidentRows, investigationRows, capaRows, addendaRows] = await withUser(
session.sub,
async tx => Promise.all([
tx.select({
id: incidents.id,
referenceNo: incidents.referenceNo,
incidentType: incidents.incidentType,
reportedAt: incidents.reportedAt,
status: incidents.status,
siteName: sites.name,
zoneName: zones.name,
reporterName: reporterAlias.name,
severity: incidents.severity,
medicalStatus: incidents.medicalStatus,
description: incidents.description,
injuryInvolved: incidents.injuryInvolved,
assetInvolved: incidents.assetInvolved,
isFatality: incidents.isFatality,
isSeriousBodilyInjury: incidents.isSeriousBodilyInjury,
isDangerousOccurrence: incidents.isDangerousOccurrence,
isOccupationalDisease: incidents.isOccupationalDisease,
lostDays: incidents.lostDays,
triagedByName: triagedByAlias.name,
triagedAt: incidents.triagedAt,
triageNotes: incidents.triageNotes,
closedAt: incidents.closedAt,
})
.from(incidents)
.leftJoin(sites, eq(incidents.siteId, sites.id))
.leftJoin(zones, eq(incidents.zoneId, zones.id))
.leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id))
.leftJoin(triagedByAlias, eq(incidents.triagedBy, triagedByAlias.id))
.where(eq(incidents.id, id))
.limit(1),
tx.select({
method: investigations.method,
findingsText: investigations.findingsText,
rootCauseSummary: investigations.rootCauseSummary,
alcoholTestResult: investigations.alcoholTestResult,
urineTestResult: investigations.urineTestResult,
})
.from(investigations)
.where(eq(investigations.incidentId, id))
.limit(1),
tx.select({
description: capaActions.description,
ownerName: ownerAlias.name,
department: capaActions.department,
dueDate: capaActions.dueDate,
priority: capaActions.priority,
status: capaActions.status,
completedAt: capaActions.completedAt,
ownerNotes: capaActions.ownerNotes,
})
.from(capaActions)
.leftJoin(ownerAlias, eq(capaActions.ownerUserId, ownerAlias.id))
.where(eq(capaActions.incidentId, id)),
tx.select({
body: incidentAddenda.body,
createdAt: incidentAddenda.createdAt,
authorName: addendaAuthorAlias.name,
})
.from(incidentAddenda)
.leftJoin(addendaAuthorAlias, eq(incidentAddenda.author, addendaAuthorAlias.id))
.where(eq(incidentAddenda.incidentId, id)),
])
)
const incident = incidentRows[0]
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (incident.status !== 'closed')
return NextResponse.json({ error: 'Incident is not closed' }, { status: 400 })
const inv = investigationRows[0] ?? null
const pdfBytes = await buildIncidentReportPdf({
referenceNo: incident.referenceNo,
incidentType: incident.incidentType,
reportedAt: incident.reportedAt instanceof Date ? incident.reportedAt.toISOString() : (incident.reportedAt as string),
siteName: incident.siteName ?? 'Unknown',
zoneName: incident.zoneName ?? null,
reporterName: incident.reporterName ?? 'Unknown',
severity: incident.severity ?? null,
medicalStatus: incident.medicalStatus ?? null,
description: incident.description,
injuryInvolved: incident.injuryInvolved,
assetInvolved: incident.assetInvolved,
isFatality: incident.isFatality,
isSeriousBodilyInjury: incident.isSeriousBodilyInjury,
isDangerousOccurrence: incident.isDangerousOccurrence,
isOccupationalDisease: incident.isOccupationalDisease,
lostDays: incident.lostDays ?? null,
triagedBy: incident.triagedByName ?? null,
triagedAt: incident.triagedAt instanceof Date ? incident.triagedAt.toISOString() : (incident.triagedAt as string | null),
triageNotes: incident.triageNotes ?? null,
investigation: inv ? {
method: inv.method,
findingsText: inv.findingsText ?? null,
rootCauseSummary: inv.rootCauseSummary ?? null,
alcoholTestResult: inv.alcoholTestResult ?? null,
urineTestResult: inv.urineTestResult ?? null,
} : null,
capas: capaRows.map(c => ({
description: c.description,
ownerName: c.ownerName ?? 'Unknown',
department: c.department,
dueDate: c.dueDate,
priority: c.priority,
status: c.status,
completedAt: c.completedAt instanceof Date ? c.completedAt.toISOString() : (c.completedAt as string | null),
ownerNotes: c.ownerNotes ?? null,
})),
addenda: addendaRows.map(a => ({
authorName: a.authorName ?? 'Unknown',
createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : (a.createdAt as string),
body: a.body,
})),
closedAt: incident.closedAt instanceof Date ? incident.closedAt.toISOString() : (incident.closedAt as string | null),
})
const safeRef = (incident.referenceNo ?? id).replace(/[^A-Za-z0-9\-_.]/g, '_')
const filename = `incident-report-${safeRef}.pdf`
return new NextResponse(Buffer.from(pdfBytes), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(pdfBytes.byteLength),
},
})
}
+1 -1
View File
@@ -17,6 +17,6 @@ echo "==> Syncing to VPS..."
rsync -az --delete --exclude='.env' -e "ssh -p ${VPS_SSH_PORT}" .next/standalone/ "$VPS:$REMOTE_DIR/" rsync -az --delete --exclude='.env' -e "ssh -p ${VPS_SSH_PORT}" .next/standalone/ "$VPS:$REMOTE_DIR/"
echo "==> Restarting service on VPS..." echo "==> Restarting service on VPS..."
ssh -p "${VPS_SSH_PORT}" "$VPS" "systemctl restart ims" ssh -p "${VPS_SSH_PORT}" "$VPS" "sudo systemctl restart ims"
echo "==> Done. https://ims.setia.com.my/" echo "==> Done. https://ims.setia.com.my/"
+281
View File
@@ -0,0 +1,281 @@
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()
}
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock auth
vi.mock('@/lib/auth/get-session', () => ({
getSession: vi.fn(),
}))
// Mock db
vi.mock('@/lib/db/with-user', () => ({
withUser: vi.fn(),
}))
// Mock pdf builder
vi.mock('@/lib/pdf/incident-report', () => ({
buildIncidentReportPdf: vi.fn().mockResolvedValue(new Uint8Array([37, 80, 68, 70])), // %PDF
}))
import { GET } from '@/app/api/incidents/[id]/report-pdf/route'
import { getSession } from '@/lib/auth/get-session'
import { withUser } from '@/lib/db/with-user'
import { buildIncidentReportPdf } from '@/lib/pdf/incident-report'
import { NextRequest } from 'next/server'
const mockSession = { sub: 'user-1', role: 'hse' as const }
const MOCK_INCIDENT = {
id: 'inc-1',
referenceNo: 'SC1-202607-0001',
incidentType: 'injury',
reportedAt: new Date('2026-07-01T08:00:00Z'),
status: 'closed',
siteName: 'Warehouse KL',
zoneName: 'Bay A',
reporterName: 'Ahmad',
severity: 3,
medicalStatus: 'first_aid',
description: 'Slip and fall',
injuryInvolved: true,
assetInvolved: false,
isFatality: false,
isSeriousBodilyInjury: false,
isDangerousOccurrence: false,
isOccupationalDisease: false,
lostDays: null,
triagedByName: 'Supervisor',
triagedAt: new Date('2026-07-01T10:00:00Z'),
triageNotes: 'Reviewed',
closedAt: new Date('2026-07-20T15:00:00Z'),
}
function makeRequest(id: string) {
return new NextRequest(`http://localhost/api/incidents/${id}/report-pdf`)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/incidents/[id]/report-pdf', () => {
it('returns 401 when not authenticated', async () => {
vi.mocked(getSession).mockResolvedValue(null)
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
expect(res.status).toBe(401)
})
it('returns 403 for non-hse/admin roles', async () => {
vi.mocked(getSession).mockResolvedValue({ ...mockSession, role: 'reporter' as never })
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
expect(res.status).toBe(403)
})
it('returns 404 when incident not found', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession)
vi.mocked(withUser).mockResolvedValue([[], [], [], []])
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
expect(res.status).toBe(404)
})
it('returns 400 when incident is not closed', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession)
vi.mocked(withUser).mockResolvedValue([[{ ...MOCK_INCIDENT, status: 'investigating' }], [], [], []])
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
expect(res.status).toBe(400)
})
it('returns PDF with correct headers for a closed incident', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession)
vi.mocked(withUser).mockResolvedValue([[MOCK_INCIDENT], [], [], []])
const res = await GET(makeRequest('inc-1'), { params: Promise.resolve({ id: 'inc-1' }) })
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toContain('SC1-202607-0001')
expect(buildIncidentReportPdf).toHaveBeenCalledOnce()
})
})
+77
View File
@@ -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')
})
})