- supabase/config.toml + supabase/.gitignore from supabase init (needed for supabase db push / local dev) - phase 2 and phase 4 implementation plans referenced by the SDD progress ledger but never committed - ignore local tooling dirs (node_modules.nosync, graphify-out, .claude) and iCloud "name 2.ext" sync-conflict copies Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
90 KiB
IMS Phase 2 — Investigation, CAPA & Compliance Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build triage panel, investigation workspace, CAPA board with overdue escalation, verification flow, and JKKP 6/7 PDF generation — closing the loop from incident report to verified corrective action.
Architecture: HSE triages incidents (severity 1–5 + DOSH regulatory checklist), leads investigation (5-Why or fishbone RCA, creates investigations record), creates CAPA actions with owners/due dates, CAPA owners upload completion evidence, HSE verifies effectiveness. System auto-escalates overdue CAPAs via email. DOSH-reportable incidents generate JKKP 6/7 draft PDFs for download.
Tech Stack: Next.js 15 App Router, TypeScript, Supabase (Postgres + RLS), Resend (email), pdf-lib (JKKP PDF), Vitest + React Testing Library
Global Constraints
- Never commit
.env.local— only.env.local.exampleas template - All Claude API calls server-side only (
/app/api/...) - RLS enforced on every table — never bypass with service-role key in app code
- Evidence files: never hard-delete — soft delete only via
deletedflag - Incident records lock on closure — no further edits, only addenda
export const dynamic = 'force-dynamic'on every server page/route calling Supabase- Safe auth pattern:
const { data, error: authError } = await supabase.auth.getUser(); if (authError || !data?.user) - Supabase join casts: always
as unknown as SpecificType | null— never bareas any(ESLint blocks it) and neveras DirectTypewithoutunknownintermediary (TSC rejects non-overlapping) - Audit every DB mutation:
await supabase.rpc('write_audit_log', { p_table_name, p_record_id, p_action, p_new_value }) - Incident reference format:
SITE-YYYYMM-#### basePath: '/ims'— all internalhrefvalues are relative (no/imsprefix in code)npm run buildmust pass with zero TypeScript and ESLint errors before deploy- Vitest 4.1.10 + jsdom;
@/alias resolves to project root
File Map
New files — migrations
supabase/migrations/20260711000011_phase2_triage_rca.sql— triage fields on incidents, RCA structured data on investigations, write_audit_log RPC
New files — lib
lib/incidents/dosh.ts— pure functioncomputeDoshObligationlib/pdf/jkkp.ts— pdf-lib JKKP 6/7 PDF builderlib/notifications/capa-escalation.ts— overdue CAPA escalation email logic
New files — components
components/incidents/triage-form.tsx— severity slider + DOSH checklist + notescomponents/incidents/investigation-form.tsx— method picker + 5-Why / fishbone RCA formcomponents/capa/capa-board.tsx— Kanban + table toggle, groups by statuscomponents/capa/capa-form.tsx— create CAPA formcomponents/capa/capa-detail.tsx— single CAPA view + verify/reopen actionscomponents/capa/verify-form.tsx— HSE verification + evidence upload
New files — API routes
app/api/incidents/[id]/triage/route.ts— PATCH: set triage fields, transition reported→triagedapp/api/incidents/[id]/investigation/route.ts— POST: create investigation; PATCH: updateapp/api/capa/route.ts— GET: list CAPAs; POST: createapp/api/capa/[id]/route.ts— GET: single CAPA; PATCH: update status/fieldsapp/api/capa/[id]/verify/route.ts— POST: HSE verificationapp/api/incidents/[id]/jkkp-pdf/route.ts— GET: generate + stream JKKP 6 or 7 PDFapp/api/cron/capa-escalation/route.ts— GET: protected byCRON_SECRETheader
New files — pages
app/(protected)/hse/incidents/[id]/triage/page.tsxapp/(protected)/hse/incidents/[id]/investigation/page.tsxapp/(protected)/hse/incidents/[id]/capa/new/page.tsxapp/(protected)/hse/capa/page.tsxapp/(protected)/hse/capa/[id]/page.tsx
New files — tests
tests/lib/incidents/dosh.test.tstests/lib/pdf/jkkp.test.tstests/lib/notifications/capa-escalation.test.tstests/api/triage.test.tstests/api/capa.test.ts
Modified files
app/(protected)/hse/incidents/[id]/page.tsx— add "Triage" / "Investigate" / "Add CAPA" action buttons based on current statuscomponents/incidents/incident-detail.tsx— add triage fields display + DOSH obligations badge
Task 1: DB Migration — triage fields, RCA structured data, write_audit_log RPC
Files:
- Create:
supabase/migrations/20260711000011_phase2_triage_rca.sql
Interfaces:
-
Produces:
incidents.is_fatality,incidents.is_serious_bodily_injury,incidents.is_dangerous_occurrence,incidents.is_occupational_disease,incidents.triage_notes,incidents.triaged_by,incidents.triaged_at -
Produces:
investigations.five_why_steps JSONB,investigations.fishbone_categories JSONB -
Produces: RPC
public.write_audit_log(p_table_name TEXT, p_record_id UUID, p_action TEXT, p_new_value JSONB DEFAULT NULL, p_old_value JSONB DEFAULT NULL) -
Step 1: Write migration SQL
-- supabase/migrations/20260711000011_phase2_triage_rca.sql
-- Triage classification fields on incidents
ALTER TABLE incidents
ADD COLUMN IF NOT EXISTS is_fatality BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_serious_bodily_injury BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_dangerous_occurrence BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_occupational_disease BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS triage_notes TEXT,
ADD COLUMN IF NOT EXISTS triaged_by UUID REFERENCES users(id),
ADD COLUMN IF NOT EXISTS triaged_at TIMESTAMPTZ;
-- Structured RCA data on investigations
ALTER TABLE investigations
ADD COLUMN IF NOT EXISTS five_why_steps JSONB,
ADD COLUMN IF NOT EXISTS fishbone_categories JSONB;
-- Audit log write helper — SECURITY DEFINER so callers cannot bypass RLS
CREATE OR REPLACE FUNCTION public.write_audit_log(
p_table_name TEXT,
p_record_id UUID,
p_action TEXT,
p_new_value JSONB DEFAULT NULL,
p_old_value JSONB DEFAULT NULL
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
INSERT INTO audit_log (table_name, record_id, action, changed_by, new_value, old_value)
VALUES (p_table_name, p_record_id, p_action, auth.uid(), p_new_value, p_old_value);
END;
$$;
- Step 2: Apply migration to Supabase cloud
npx supabase db push
Expected: Applying migration 20260711000011_phase2_triage_rca.sql... with no errors.
- Step 3: Verify columns exist
npx supabase db diff --schema public 2>/dev/null | grep -E "is_fatality|five_why|write_audit"
Expected: shows no pending diff (columns already applied).
- Step 4: Commit
git add supabase/migrations/20260711000011_phase2_triage_rca.sql
git commit -m "feat: phase2 migration — triage fields, RCA structured data, write_audit_log RPC"
Task 2: DOSH obligation pure function + tests
Files:
- Create:
lib/incidents/dosh.ts - Create:
tests/lib/incidents/dosh.test.ts
Interfaces:
-
Consumes: nothing (pure function, no imports)
-
Produces:
type DoshObligation = { requires_jkkp6: boolean // notify DOSH within 7 days requires_jkkp7: boolean // investigation report within 30 days requires_jkkp8: boolean // occupational disease annual return requires_immediate_notification: boolean // notify within 24h (fatality) reasons: string[] } function computeDoshObligation(incident: { is_fatality: boolean is_serious_bodily_injury: boolean is_dangerous_occurrence: boolean is_occupational_disease: boolean lost_days: number | null }): DoshObligation -
Step 1: Write failing tests
Create tests/lib/incidents/dosh.test.ts:
import { describe, it, expect } from 'vitest'
import { computeDoshObligation } from '@/lib/incidents/dosh'
const base = {
is_fatality: false,
is_serious_bodily_injury: false,
is_dangerous_occurrence: false,
is_occupational_disease: false,
lost_days: null,
}
describe('computeDoshObligation', () => {
it('returns no obligations for minor incident', () => {
const result = computeDoshObligation(base)
expect(result.requires_jkkp6).toBe(false)
expect(result.requires_jkkp7).toBe(false)
expect(result.requires_jkkp8).toBe(false)
expect(result.requires_immediate_notification).toBe(false)
expect(result.reasons).toHaveLength(0)
})
it('fatality triggers jkkp6, jkkp7, immediate notification', () => {
const result = computeDoshObligation({ ...base, is_fatality: true })
expect(result.requires_jkkp6).toBe(true)
expect(result.requires_jkkp7).toBe(true)
expect(result.requires_immediate_notification).toBe(true)
expect(result.requires_jkkp8).toBe(false)
expect(result.reasons).toContain('Fatality — NADOPOD 2004 s.9(1)(a)')
})
it('serious bodily injury triggers jkkp6 + jkkp7, not immediate', () => {
const result = computeDoshObligation({ ...base, is_serious_bodily_injury: true })
expect(result.requires_jkkp6).toBe(true)
expect(result.requires_jkkp7).toBe(true)
expect(result.requires_immediate_notification).toBe(false)
expect(result.reasons).toContain('Serious bodily injury — NADOPOD 2004 s.9(1)(b)')
})
it('dangerous occurrence triggers jkkp6 + jkkp7', () => {
const result = computeDoshObligation({ ...base, is_dangerous_occurrence: true })
expect(result.requires_jkkp6).toBe(true)
expect(result.requires_jkkp7).toBe(true)
expect(result.requires_immediate_notification).toBe(false)
expect(result.reasons).toContain('Dangerous occurrence — NADOPOD 2004 s.9(1)(c)')
})
it('occupational disease triggers jkkp8 only', () => {
const result = computeDoshObligation({ ...base, is_occupational_disease: true })
expect(result.requires_jkkp8).toBe(true)
expect(result.requires_jkkp6).toBe(false)
expect(result.requires_jkkp7).toBe(false)
expect(result.reasons).toContain('Occupational disease — NADOPOD 2004 s.11')
})
it('4+ lost days triggers jkkp6 + jkkp7', () => {
const result = computeDoshObligation({ ...base, lost_days: 4 })
expect(result.requires_jkkp6).toBe(true)
expect(result.requires_jkkp7).toBe(true)
expect(result.reasons).toContain('Lost-time injury ≥4 days — NADOPOD 2004 s.9(1)(d)')
})
it('3 lost days does NOT trigger JKKP reporting', () => {
const result = computeDoshObligation({ ...base, lost_days: 3 })
expect(result.requires_jkkp6).toBe(false)
expect(result.requires_jkkp7).toBe(false)
})
it('multiple flags accumulate reasons', () => {
const result = computeDoshObligation({
...base,
is_fatality: true,
is_occupational_disease: true,
})
expect(result.requires_jkkp6).toBe(true)
expect(result.requires_jkkp8).toBe(true)
expect(result.reasons).toHaveLength(2)
})
})
- Step 2: Run tests to confirm they fail
npx vitest run tests/lib/incidents/dosh.test.ts
Expected: Cannot find module '@/lib/incidents/dosh'
- Step 3: Implement
lib/incidents/dosh.ts
export type DoshObligation = {
requires_jkkp6: boolean
requires_jkkp7: boolean
requires_jkkp8: boolean
requires_immediate_notification: boolean
reasons: string[]
}
export function computeDoshObligation(incident: {
is_fatality: boolean
is_serious_bodily_injury: boolean
is_dangerous_occurrence: boolean
is_occupational_disease: boolean
lost_days: number | null
}): DoshObligation {
const reasons: string[] = []
let requires_jkkp6 = false
let requires_jkkp7 = false
let requires_jkkp8 = false
let requires_immediate_notification = false
if (incident.is_fatality) {
reasons.push('Fatality — NADOPOD 2004 s.9(1)(a)')
requires_jkkp6 = true
requires_jkkp7 = true
requires_immediate_notification = true
}
if (incident.is_serious_bodily_injury) {
reasons.push('Serious bodily injury — NADOPOD 2004 s.9(1)(b)')
requires_jkkp6 = true
requires_jkkp7 = true
}
if (incident.is_dangerous_occurrence) {
reasons.push('Dangerous occurrence — NADOPOD 2004 s.9(1)(c)')
requires_jkkp6 = true
requires_jkkp7 = true
}
if ((incident.lost_days ?? 0) >= 4) {
reasons.push('Lost-time injury ≥4 days — NADOPOD 2004 s.9(1)(d)')
requires_jkkp6 = true
requires_jkkp7 = true
}
if (incident.is_occupational_disease) {
reasons.push('Occupational disease — NADOPOD 2004 s.11')
requires_jkkp8 = true
}
return { requires_jkkp6, requires_jkkp7, requires_jkkp8, requires_immediate_notification, reasons }
}
- Step 4: Run tests to confirm they pass
npx vitest run tests/lib/incidents/dosh.test.ts
Expected: 8 passed
- Step 5: Commit
git add lib/incidents/dosh.ts tests/lib/incidents/dosh.test.ts
git commit -m "feat: DOSH obligation pure function with NADOPOD 2004 rules"
Task 3: Triage panel — component, API route, page
Files:
- Create:
components/incidents/triage-form.tsx - Create:
app/api/incidents/[id]/triage/route.ts - Create:
app/(protected)/hse/incidents/[id]/triage/page.tsx - Modify:
app/(protected)/hse/incidents/[id]/page.tsx— add "Triage incident" button when status =reported
Interfaces:
-
Consumes:
computeDoshObligationfrom@/lib/incidents/dosh -
Produces: PATCH
/api/incidents/[id]/triage— updates triage fields, transitionsreported→triaged -
Produces:
<TriageForm incidentId={string} currentSeverity={number|null} />(client component, calls the API) -
Step 1: Create triage API route
Create app/api/incidents/[id]/triage/route.ts:
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
interface TriageBody {
severity: number
is_fatality: boolean
is_serious_bodily_injury: boolean
is_dangerous_occurrence: boolean
is_occupational_disease: boolean
triage_notes: string | null
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
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 || profile.role !== 'hse')
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body: TriageBody = await request.json()
if (body.severity < 1 || body.severity > 5)
return NextResponse.json({ error: 'severity must be 1–5' }, { status: 422 })
const { data: incident } = await supabase
.from('incidents').select('status').eq('id', id).single()
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (incident.status !== 'reported')
return NextResponse.json({ error: 'Incident is not in reported status' }, { status: 409 })
const { error } = await supabase
.from('incidents')
.update({
severity: body.severity,
is_fatality: body.is_fatality,
is_serious_bodily_injury: body.is_serious_bodily_injury,
is_dangerous_occurrence: body.is_dangerous_occurrence,
is_occupational_disease: body.is_occupational_disease,
triage_notes: body.triage_notes ?? null,
triaged_by: user.id,
triaged_at: new Date().toISOString(),
status: 'triaged',
})
.eq('id', id)
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: id,
p_action: 'triage',
p_new_value: { severity: body.severity, status: 'triaged', triaged_by: user.id },
})
return NextResponse.json({ ok: true })
}
- Step 2: Create triage form component
Create components/incidents/triage-form.tsx:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { computeDoshObligation } from '@/lib/incidents/dosh'
interface Props {
incidentId: string
currentSeverity: number | null
}
const SEVERITY_LABELS: Record<number, string> = {
1: 'Minor',
2: 'Low',
3: 'Moderate',
4: 'Serious',
5: 'Critical / Fatality',
}
export function TriageForm({ incidentId, currentSeverity }: Props) {
const router = useRouter()
const [severity, setSeverity] = useState<number>(currentSeverity ?? 1)
const [isFatality, setIsFatality] = useState(false)
const [isSBI, setIsSBI] = useState(false)
const [isDO, setIsDO] = useState(false)
const [isOD, setIsOD] = useState(false)
const [triageNotes, setTriageNotes] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const dosh = computeDoshObligation({
is_fatality: isFatality,
is_serious_bodily_injury: isSBI,
is_dangerous_occurrence: isDO,
is_occupational_disease: isOD,
lost_days: null,
})
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
const res = await fetch(`/api/incidents/${incidentId}/triage`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
severity,
is_fatality: isFatality,
is_serious_bodily_injury: isSBI,
is_dangerous_occurrence: isDO,
is_occupational_disease: isOD,
triage_notes: triageNotes || null,
}),
})
if (!res.ok) {
const data = await res.json()
setError(data.error ?? 'Triage failed')
setSaving(false)
return
}
router.push(`/hse/incidents/${incidentId}`)
router.refresh()
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Severity — {SEVERITY_LABELS[severity]}
</label>
<input
type="range" min={1} max={5} value={severity}
onChange={e => setSeverity(Number(e.target.value))}
className="w-full accent-blue-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>1 Minor</span><span>3 Moderate</span><span>5 Critical</span>
</div>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-gray-700 mb-2">DOSH Regulatory Checklist (NADOPOD 2004)</legend>
{[
{ id: 'fatality', label: 'Fatality', value: isFatality, set: setIsFatality },
{ id: 'sbi', label: 'Serious bodily injury', value: isSBI, set: setIsSBI },
{ id: 'do', label: 'Dangerous occurrence', value: isDO, set: setIsDO },
{ id: 'od', label: 'Occupational disease', value: isOD, set: setIsOD },
].map(({ id, label, value, set }) => (
<label key={id} className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox" checked={value}
onChange={e => set(e.target.checked)}
className="rounded border-gray-300 text-blue-600"
/>
{label}
</label>
))}
</fieldset>
{dosh.reasons.length > 0 && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 space-y-1">
<p className="text-xs font-semibold text-red-700 uppercase tracking-wide">DOSH Reporting Required</p>
{dosh.reasons.map(r => (
<p key={r} className="text-xs text-red-600">{r}</p>
))}
{dosh.requires_immediate_notification && (
<p className="text-xs font-bold text-red-700 mt-1">⚠ Immediate notification required (within 24h)</p>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Triage notes (optional)</label>
<textarea
value={triageNotes}
onChange={e => setTriageNotes(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Initial assessment, immediate actions taken..."
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={saving}
className="w-full bg-blue-600 text-white rounded-lg py-2 text-sm font-semibold disabled:opacity-50"
>
{saving ? 'Saving…' : 'Complete Triage'}
</button>
</form>
)
}
- Step 3: Create triage page
Create app/(protected)/hse/incidents/[id]/triage/page.tsx:
export const dynamic = 'force-dynamic'
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { TriageForm } from '@/components/incidents/triage-form'
interface Props {
params: Promise<{ id: string }>
}
export default async function TriagePage({ params }: Props) {
const { id } = await params
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/triage`)
const { data: profile } = await supabase
.from('users').select('role').eq('id', data.user.id).single()
if (!profile || profile.role !== 'hse') redirect('/hse/incidents')
const { data: incident } = await supabase
.from('incidents')
.select('id, reference_no, incident_type, status, severity')
.eq('id', id)
.single()
if (!incident) notFound()
if (incident.status !== 'reported') redirect(`/hse/incidents/${id}`)
return (
<main className="max-w-lg mx-auto px-4 py-6">
<Link href={`/hse/incidents/${id}`} className="text-sm text-blue-600 hover:underline mb-4 inline-block">
← Back to incident
</Link>
<h1 className="text-xl font-bold text-gray-900 mb-1">Triage Incident</h1>
<p className="text-sm text-gray-500 mb-6">{incident.reference_no ?? id}</p>
<TriageForm
incidentId={id}
currentSeverity={(incident as { severity: number | null }).severity}
/>
</main>
)
}
- Step 4: Add "Triage" action button to incident detail page
Modify app/(protected)/hse/incidents/[id]/page.tsx — find the section after <IncidentDetail .../> and add a status-gated action block:
{/* After <IncidentDetail incident={...} /> */}
{(incident as { status: string }).status === 'reported' && (
<div className="mt-6">
<Link
href={`/hse/incidents/${id}/triage`}
className="inline-block bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-blue-700"
>
Triage Incident
</Link>
</div>
)}
{(incident as { status: string }).status === 'triaged' && (
<div className="mt-6">
<Link
href={`/hse/incidents/${id}/investigation`}
className="inline-block bg-indigo-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-indigo-700"
>
Start Investigation
</Link>
</div>
)}
{(['investigating', 'capa_pending'] as string[]).includes((incident as { status: string }).status) && (
<div className="mt-6">
<Link
href={`/hse/incidents/${id}/capa/new`}
className="inline-block bg-amber-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-amber-700"
>
Add CAPA Action
</Link>
</div>
)}
- Step 5: Build check
npm run build 2>&1 | tail -20
Expected: ✓ Compiled successfully with no TypeScript or ESLint errors.
- Step 6: Commit
git add components/incidents/triage-form.tsx \
app/api/incidents/[id]/triage/route.ts \
app/\(protected\)/hse/incidents/\[id\]/triage/page.tsx \
app/\(protected\)/hse/incidents/\[id\]/page.tsx
git commit -m "feat: triage panel — severity + DOSH checklist, transitions reported→triaged"
Task 4: Investigation workspace — component, API route, page
Files:
- Create:
components/incidents/investigation-form.tsx - Create:
app/api/incidents/[id]/investigation/route.ts - Create:
app/(protected)/hse/incidents/[id]/investigation/page.tsx
Interfaces:
- Consumes: Task 1 migration (investigations table with
five_why_steps,fishbone_categoriescolumns) - Produces: POST
/api/incidents/[id]/investigation— creates investigation record, transitionstriaged→investigating - Produces: PATCH
/api/incidents/[id]/investigation— updates existing investigation, transitionsinvestigating→capa_pendingwhen completed
Five-Why data shape (stored as JSONB):
type FiveWhyStep = { why: string; answer: string }
// five_why_steps: FiveWhyStep[] (up to 5 entries)
Fishbone data shape:
type FishboneCategory = {
category: 'man' | 'machine' | 'method' | 'material' | 'environment' | 'measurement'
causes: string[]
}
// fishbone_categories: FishboneCategory[]
- Step 1: Create investigation API route
Create app/api/incidents/[id]/investigation/route.ts:
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
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 || profile.role !== 'hse')
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data: incident } = await supabase
.from('incidents').select('status').eq('id', id).single()
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (incident.status !== 'triaged')
return NextResponse.json({ error: 'Incident must be triaged first' }, { status: 409 })
const body = await request.json()
const method: 'five_why' | 'fishbone' | 'other' = body.method ?? 'five_why'
const { data: inv, error } = await supabase
.from('investigations')
.insert({
incident_id: id,
investigator_id: user.id,
method,
findings_text: body.findings_text ?? null,
root_cause_summary: body.root_cause_summary ?? null,
five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null,
fishbone_categories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null,
})
.select('id')
.single()
if (error || !inv) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })
await supabase
.from('incidents')
.update({ status: 'investigating' })
.eq('id', id)
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: id,
p_action: 'investigation_started',
p_new_value: { status: 'investigating', investigation_id: inv.id },
})
return NextResponse.json({ id: inv.id })
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
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 || profile.role !== 'hse')
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await request.json()
const { investigation_id, complete, ...fields } = body
if (!investigation_id) return NextResponse.json({ error: 'investigation_id required' }, { status: 422 })
const updateData: Record<string, unknown> = {
findings_text: fields.findings_text ?? null,
root_cause_summary: fields.root_cause_summary ?? null,
five_why_steps: fields.five_why_steps ?? null,
fishbone_categories: fields.fishbone_categories ?? null,
}
if (complete) updateData.completed_at = new Date().toISOString()
const { error } = await supabase
.from('investigations')
.update(updateData)
.eq('id', investigation_id)
.eq('incident_id', id)
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
if (complete) {
await supabase
.from('incidents')
.update({ status: 'capa_pending' })
.eq('id', id)
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: id,
p_action: 'investigation_completed',
p_new_value: { status: 'capa_pending' },
})
}
return NextResponse.json({ ok: true })
}
- Step 2: Create investigation form component
Create components/incidents/investigation-form.tsx:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
type FiveWhyStep = { why: string; answer: string }
type FishboneCategory = {
category: 'man' | 'machine' | 'method' | 'material' | 'environment' | 'measurement'
causes: string[]
}
const FISHBONE_CATEGORIES: FishboneCategory['category'][] = [
'man', 'machine', 'method', 'material', 'environment', 'measurement',
]
const CATEGORY_LABELS: Record<FishboneCategory['category'], string> = {
man: 'Man (People)',
machine: 'Machine',
method: 'Method',
material: 'Material',
environment: 'Environment',
measurement: 'Measurement',
}
interface Props {
incidentId: string
existingInvestigationId: string | null
}
export function InvestigationForm({ incidentId, existingInvestigationId }: Props) {
const router = useRouter()
const [method, setMethod] = useState<'five_why' | 'fishbone' | 'other'>('five_why')
const [fiveWhy, setFiveWhy] = useState<FiveWhyStep[]>([
{ why: 'Why did the incident happen?', answer: '' },
])
const [fishbone, setFishbone] = useState<FishboneCategory[]>(
FISHBONE_CATEGORIES.map(c => ({ category: c, causes: [''] }))
)
const [findingsText, setFindingsText] = useState('')
const [rootCause, setRootCause] = useState('')
const [complete, setComplete] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
function addWhyStep() {
if (fiveWhy.length >= 5) return
setFiveWhy([...fiveWhy, { why: '', answer: '' }])
}
function updateWhyStep(i: number, field: keyof FiveWhyStep, value: string) {
setFiveWhy(fiveWhy.map((s, idx) => idx === i ? { ...s, [field]: value } : s))
}
function updateFishboneCause(catIdx: number, causeIdx: number, value: string) {
setFishbone(fishbone.map((c, i) =>
i === catIdx ? { ...c, causes: c.causes.map((cause, j) => j === causeIdx ? value : cause) } : c
))
}
function addFishboneCause(catIdx: number) {
setFishbone(fishbone.map((c, i) => i === catIdx ? { ...c, causes: [...c.causes, ''] } : c))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
const payload = {
method,
findings_text: findingsText || null,
root_cause_summary: rootCause || null,
five_why_steps: method === 'five_why' ? fiveWhy.filter(s => s.answer) : null,
fishbone_categories: method === 'fishbone'
? fishbone.map(c => ({ ...c, causes: c.causes.filter(Boolean) })).filter(c => c.causes.length > 0)
: null,
}
let res: Response
if (existingInvestigationId) {
res = await fetch(`/api/incidents/${incidentId}/investigation`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, investigation_id: existingInvestigationId, complete }),
})
} else {
res = await fetch(`/api/incidents/${incidentId}/investigation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
if (!res.ok) {
const data = await res.json()
setError(data.error ?? 'Save failed')
setSaving(false)
return
}
router.push(`/hse/incidents/${incidentId}`)
router.refresh()
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">RCA Method</label>
<div className="flex gap-3">
{(['five_why', 'fishbone', 'other'] as const).map(m => (
<button
key={m} type="button"
onClick={() => setMethod(m)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border ${
method === m
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
}`}
>
{m === 'five_why' ? '5-Why' : m === 'fishbone' ? 'Fishbone' : 'Other'}
</button>
))}
</div>
</div>
{method === 'five_why' && (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">5-Why Analysis</p>
{fiveWhy.map((step, i) => (
<div key={i} className="border border-gray-200 rounded-lg p-3 space-y-2">
<label className="block text-xs text-gray-500">Why #{i + 1}</label>
<input
type="text" value={step.why}
onChange={e => updateWhyStep(i, 'why', e.target.value)}
placeholder="Why did this happen?"
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
<textarea
value={step.answer}
onChange={e => updateWhyStep(i, 'answer', e.target.value)}
rows={2} placeholder="Answer / finding…"
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
))}
{fiveWhy.length < 5 && (
<button type="button" onClick={addWhyStep}
className="text-sm text-blue-600 hover:underline">
+ Add another Why
</button>
)}
</div>
)}
{method === 'fishbone' && (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">Fishbone (Ishikawa) Analysis</p>
{fishbone.map((cat, catIdx) => (
<div key={cat.category} className="border border-gray-200 rounded-lg p-3">
<p className="text-xs font-semibold text-gray-600 mb-2">{CATEGORY_LABELS[cat.category]}</p>
{cat.causes.map((cause, causeIdx) => (
<input
key={causeIdx}
type="text" value={cause}
onChange={e => updateFishboneCause(catIdx, causeIdx, e.target.value)}
placeholder="Contributing cause…"
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm mb-1.5"
/>
))}
<button type="button" onClick={() => addFishboneCause(catIdx)}
className="text-xs text-blue-600 hover:underline">
+ Add cause
</button>
</div>
))}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Findings</label>
<textarea
value={findingsText}
onChange={e => setFindingsText(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Describe the sequence of events and contributing factors…"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Root Cause Summary</label>
<textarea
value={rootCause}
onChange={e => setRootCause(e.target.value)}
rows={2}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="One-sentence root cause statement…"
/>
</div>
{!existingInvestigationId && (
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={complete} onChange={e => setComplete(e.target.checked)}
className="rounded border-gray-300 text-blue-600" />
Mark investigation complete (transitions incident to CAPA Pending)
</label>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={saving}
className="w-full bg-indigo-600 text-white rounded-lg py-2 text-sm font-semibold disabled:opacity-50">
{saving ? 'Saving…' : existingInvestigationId ? 'Update Investigation' : 'Start Investigation'}
</button>
</form>
)
}
- Step 3: Create investigation page
Create app/(protected)/hse/incidents/[id]/investigation/page.tsx:
export const dynamic = 'force-dynamic'
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { InvestigationForm } from '@/components/incidents/investigation-form'
interface Props {
params: Promise<{ id: string }>
}
export default async function InvestigationPage({ params }: Props) {
const { id } = await params
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/investigation`)
const { data: profile } = await supabase
.from('users').select('role').eq('id', data.user.id).single()
if (!profile || profile.role !== 'hse') redirect('/hse/incidents')
const { data: incident } = await supabase
.from('incidents')
.select('id, reference_no, status')
.eq('id', id)
.single()
if (!incident) notFound()
if (!['triaged', 'investigating'].includes((incident as { status: string }).status))
redirect(`/hse/incidents/${id}`)
const { data: existing } = await supabase
.from('investigations')
.select('id')
.eq('incident_id', id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
return (
<main className="max-w-2xl mx-auto px-4 py-6">
<Link href={`/hse/incidents/${id}`} className="text-sm text-blue-600 hover:underline mb-4 inline-block">
← Back to incident
</Link>
<h1 className="text-xl font-bold text-gray-900 mb-1">Investigation Workspace</h1>
<p className="text-sm text-gray-500 mb-6">{(incident as { reference_no: string | null }).reference_no ?? id}</p>
<InvestigationForm
incidentId={id}
existingInvestigationId={(existing as { id: string } | null)?.id ?? null}
/>
</main>
)
}
- Step 4: Build check
npm run build 2>&1 | tail -20
Expected: zero errors.
- Step 5: Commit
git add components/incidents/investigation-form.tsx \
app/api/incidents/\[id\]/investigation/route.ts \
app/\(protected\)/hse/incidents/\[id\]/investigation/page.tsx
git commit -m "feat: investigation workspace — 5-Why/fishbone RCA, transitions triaged→investigating→capa_pending"
Task 5: CAPA board + form + detail
Files:
- Create:
components/capa/capa-board.tsx - Create:
components/capa/capa-form.tsx - Create:
components/capa/capa-detail.tsx - Create:
app/api/capa/route.ts - Create:
app/api/capa/[id]/route.ts - Create:
app/(protected)/hse/capa/page.tsx - Create:
app/(protected)/hse/capa/[id]/page.tsx - Create:
app/(protected)/hse/incidents/[id]/capa/new/page.tsx
Interfaces:
- Consumes:
capa_actionstable from Task 1 migration - Produces:
- GET
/api/capa— returnsCapaAction[]for current user's scope - POST
/api/capa— creates newcapa_action, transitions incidentinvestigating→capa_pendingif needed - GET
/api/capa/[id]— returns singleCapaAction - PATCH
/api/capa/[id]— updates status/fields
- GET
type CapaAction = {
id: string
incident_id: string
root_cause_ref: string | null
description: string
owner_user_id: string
department: string
due_date: string // ISO date string
priority: 'low' | 'med' | 'high'
status: 'open' | 'in_progress' | 'overdue' | 'pending_verification' | 'verified' | 'reopened' | 'closed'
completed_at: string | null
verified_by: string | null
verified_at: string | null
created_at: string
incidents: { reference_no: string | null; incident_type: string } | null
owner: { name: string; email: string } | null
}
- Step 1: Create CAPA list + create API routes
Create app/api/capa/route.ts:
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET() {
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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let query = supabase
.from('capa_actions')
.select(`
id, incident_id, root_cause_ref, description, owner_user_id, department,
due_date, priority, status, completed_at, verified_by, verified_at, created_at,
incidents (reference_no, incident_type),
owner:users!owner_user_id (name, email)
`)
.order('due_date', { ascending: true })
if (profile.role === 'supervisor' || profile.role === 'worker') {
query = query.eq('owner_user_id', user.id)
}
const { data, error } = await query
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
return NextResponse.json(data ?? [])
}
export async function POST(request: NextRequest) {
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 body = await request.json()
const { incident_id, description, owner_user_id, department, due_date, priority, root_cause_ref } = body
if (!incident_id || !description || !owner_user_id || !department || !due_date)
return NextResponse.json({ error: 'Missing required fields' }, { status: 422 })
const { data: capa, error } = await supabase
.from('capa_actions')
.insert({
incident_id,
description,
owner_user_id,
department,
due_date,
priority: priority ?? 'med',
root_cause_ref: root_cause_ref ?? null,
})
.select('id')
.single()
if (error || !capa) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'capa_actions',
p_record_id: capa.id,
p_action: 'created',
p_new_value: { incident_id, description, owner_user_id, department, due_date },
})
return NextResponse.json({ id: capa.id }, { status: 201 })
}
- Step 2: Create CAPA detail/update API route
Create app/api/capa/[id]/route.ts:
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(
_: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
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, error } = await supabase
.from('capa_actions')
.select(`
id, incident_id, root_cause_ref, description, owner_user_id, department,
due_date, priority, status, completed_at, verified_by, verified_at, created_at,
incidents (reference_no, incident_type),
owner:users!owner_user_id (name, email)
`)
.eq('id', id)
.single()
if (error || !data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(data)
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json()
const allowed = ['description', 'due_date', 'priority', 'status', 'department', 'root_cause_ref']
const update: Record<string, unknown> = {}
for (const key of allowed) {
if (key in body) update[key] = body[key]
}
if (body.status === 'pending_verification') {
update.completed_at = new Date().toISOString()
}
const { error } = await supabase
.from('capa_actions')
.update(update)
.eq('id', id)
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'capa_actions',
p_record_id: id,
p_action: 'updated',
p_new_value: update as Record<string, unknown>,
})
return NextResponse.json({ ok: true })
}
- Step 3: Create CAPA form component
Create components/capa/capa-form.tsx:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
interface User {
id: string
name: string
department: string
}
interface Props {
incidentId: string
users: User[]
}
export function CapaForm({ incidentId, users }: Props) {
const router = useRouter()
const [description, setDescription] = useState('')
const [ownerId, setOwnerId] = useState('')
const [department, setDepartment] = useState('')
const [dueDate, setDueDate] = useState('')
const [priority, setPriority] = useState<'low' | 'med' | 'high'>('med')
const [rootCauseRef, setRootCauseRef] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
function handleOwnerChange(id: string) {
setOwnerId(id)
const u = users.find(u => u.id === id)
if (u) setDepartment(u.department)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!ownerId || !description || !dueDate) {
setError('Description, owner, and due date are required')
return
}
setSaving(true)
setError(null)
const res = await fetch('/api/capa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
incident_id: incidentId,
description,
owner_user_id: ownerId,
department,
due_date: dueDate,
priority,
root_cause_ref: rootCauseRef || null,
}),
})
if (!res.ok) {
const data = await res.json()
setError(data.error ?? 'Create failed')
setSaving(false)
return
}
router.push(`/hse/incidents/${incidentId}`)
router.refresh()
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Action Description *</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3} required
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="What corrective action will be taken?"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Root Cause Reference</label>
<input type="text" value={rootCauseRef}
onChange={e => setRootCauseRef(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="e.g. Why #3 — inadequate training" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Assigned To *</label>
<select value={ownerId} onChange={e => handleOwnerChange(e.target.value)} required
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
<option value="">Select owner…</option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name} ({u.department})</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Due Date *</label>
<input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} required
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select value={priority} onChange={e => setPriority(e.target.value as 'low' | 'med' | 'high')}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
<option value="low">Low</option>
<option value="med">Medium</option>
<option value="high">High</option>
</select>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={saving}
className="w-full bg-amber-600 text-white rounded-lg py-2 text-sm font-semibold disabled:opacity-50">
{saving ? 'Creating…' : 'Create CAPA Action'}
</button>
</form>
)
}
- Step 4: Create CAPA board component
Create components/capa/capa-board.tsx:
'use client'
import { useState } from 'react'
import Link from 'next/link'
type CapaStatus = 'open' | 'in_progress' | 'overdue' | 'pending_verification' | 'verified' | 'reopened' | 'closed'
type CapaAction = {
id: string
incident_id: string
description: string
department: string
due_date: string
priority: 'low' | 'med' | 'high'
status: CapaStatus
incidents: { reference_no: string | null } | null
owner: { name: string } | null
}
const STATUS_ORDER: CapaStatus[] = ['open', 'in_progress', 'overdue', 'pending_verification', 'verified', 'closed']
const STATUS_LABELS: Record<CapaStatus, string> = {
open: 'Open',
in_progress: 'In Progress',
overdue: 'Overdue',
pending_verification: 'Pending Verification',
verified: 'Verified',
reopened: 'Reopened',
closed: 'Closed',
}
const STATUS_COLORS: Record<CapaStatus, string> = {
open: 'bg-blue-50 border-blue-200',
in_progress: 'bg-yellow-50 border-yellow-200',
overdue: 'bg-red-50 border-red-200',
pending_verification: 'bg-purple-50 border-purple-200',
verified: 'bg-green-50 border-green-200',
reopened: 'bg-orange-50 border-orange-200',
closed: 'bg-gray-50 border-gray-200',
}
const PRIORITY_BADGE: Record<'low' | 'med' | 'high', string> = {
low: 'bg-gray-100 text-gray-600',
med: 'bg-yellow-100 text-yellow-700',
high: 'bg-red-100 text-red-700',
}
interface Props {
capas: CapaAction[]
}
export function CapaBoard({ capas }: Props) {
const [view, setView] = useState<'board' | 'table'>('board')
const byStatus = STATUS_ORDER.reduce<Record<CapaStatus, CapaAction[]>>((acc, s) => {
acc[s] = capas.filter(c => c.status === s)
return acc
}, {} as Record<CapaStatus, CapaAction[]>)
if (view === 'table') {
return (
<div>
<div className="flex justify-end mb-3">
<button onClick={() => setView('board')} className="text-sm text-blue-600 hover:underline">Board view</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 uppercase border-b">
<th className="pb-2 pr-4">Ref</th>
<th className="pb-2 pr-4">Action</th>
<th className="pb-2 pr-4">Owner</th>
<th className="pb-2 pr-4">Due</th>
<th className="pb-2 pr-4">Priority</th>
<th className="pb-2">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{capas.map(c => (
<tr key={c.id}>
<td className="py-2 pr-4 text-gray-500 text-xs">
{(c.incidents as { reference_no: string | null } | null)?.reference_no ?? '—'}
</td>
<td className="py-2 pr-4">
<Link href={`/hse/capa/${c.id}`} className="text-blue-600 hover:underline line-clamp-2">
{c.description}
</Link>
</td>
<td className="py-2 pr-4 text-gray-600">{(c.owner as { name: string } | null)?.name ?? '—'}</td>
<td className="py-2 pr-4 text-gray-600">{c.due_date}</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_BADGE[c.priority]}`}>
{c.priority}
</span>
</td>
<td className="py-2 text-gray-600 text-xs">{STATUS_LABELS[c.status]}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
return (
<div>
<div className="flex justify-end mb-3">
<button onClick={() => setView('table')} className="text-sm text-blue-600 hover:underline">Table view</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{STATUS_ORDER.filter(s => s !== 'reopened').map(status => (
<div key={status} className={`rounded-xl border p-4 ${STATUS_COLORS[status]}`}>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-600 mb-3">
{STATUS_LABELS[status]} ({byStatus[status].length})
</h3>
<div className="space-y-2">
{byStatus[status].map(c => (
<Link key={c.id} href={`/hse/capa/${c.id}`}
className="block bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow border border-gray-100">
<p className="text-sm text-gray-800 line-clamp-2 mb-1">{c.description}</p>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{(c.owner as { name: string } | null)?.name ?? '—'}</span>
<span className={`px-1.5 py-0.5 rounded ${PRIORITY_BADGE[c.priority]}`}>{c.priority}</span>
</div>
<p className="text-xs text-gray-400 mt-1">Due {c.due_date}</p>
</Link>
))}
{byStatus[status].length === 0 && (
<p className="text-xs text-gray-400 text-center py-3">Empty</p>
)}
</div>
</div>
))}
</div>
</div>
)
}
- Step 5: Create CAPA board page
Create app/(protected)/hse/capa/page.tsx:
export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { CapaBoard } from '@/components/capa/capa-board'
export default async function CapaListPage() {
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect('/login?redirect=/hse/capa')
const { data: capas } = await supabase
.from('capa_actions')
.select(`
id, incident_id, description, department, due_date, priority, status,
incidents (reference_no, incident_type),
owner:users!owner_user_id (name, email)
`)
.order('due_date', { ascending: true })
return (
<main className="max-w-6xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">CAPA Board</h1>
<Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
← Incidents
</Link>
</div>
<CapaBoard capas={(capas ?? []) as Parameters<typeof CapaBoard>[0]['capas']} />
</main>
)
}
- Step 6: Create "new CAPA" page (from incident)
Create app/(protected)/hse/incidents/[id]/capa/new/page.tsx:
export const dynamic = 'force-dynamic'
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { CapaForm } from '@/components/capa/capa-form'
interface Props {
params: Promise<{ id: string }>
}
export default async function NewCapaPage({ params }: Props) {
const { id } = await params
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/capa/new`)
const { data: profile } = await supabase
.from('users').select('role').eq('id', data.user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role)) redirect('/hse/incidents')
const { data: incident } = await supabase
.from('incidents').select('id, reference_no, status').eq('id', id).single()
if (!incident) notFound()
const { data: users } = await supabase
.from('users')
.select('id, name, department')
.not('department', 'is', null)
.order('name')
return (
<main className="max-w-lg mx-auto px-4 py-6">
<Link href={`/hse/incidents/${id}`} className="text-sm text-blue-600 hover:underline mb-4 inline-block">
← Back to incident
</Link>
<h1 className="text-xl font-bold text-gray-900 mb-1">Add CAPA Action</h1>
<p className="text-sm text-gray-500 mb-6">
{(incident as { reference_no: string | null }).reference_no ?? id}
</p>
<CapaForm
incidentId={id}
users={(users ?? []) as Array<{ id: string; name: string; department: string }>}
/>
</main>
)
}
- Step 7: Build check
npm run build 2>&1 | tail -20
Expected: zero errors.
- Step 8: Commit
git add components/capa/ \
app/api/capa/ \
app/\(protected\)/hse/capa/ \
app/\(protected\)/hse/incidents/\[id\]/capa/
git commit -m "feat: CAPA board — kanban/table view, create CAPA from incident, status tracking"
Task 6: Verification flow
Files:
- Create:
components/capa/verify-form.tsx - Create:
app/api/capa/[id]/verify/route.ts - Create:
app/(protected)/hse/capa/[id]/page.tsx
Interfaces:
-
Consumes:
CapaActiontype from Task 5 -
Produces: POST
/api/capa/[id]/verify— HSE marks CAPA verified or reopens with reason -
Produces:
<VerifyForm capaId={string} />(client component) -
Produces: CAPA detail page showing full action + verify form when status =
pending_verification -
Step 1: Create verify API route
Create app/api/capa/[id]/verify/route.ts:
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
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: capa } = await supabase
.from('capa_actions').select('status, incident_id').eq('id', id).single()
if (!capa) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (capa.status !== 'pending_verification')
return NextResponse.json({ error: 'CAPA must be pending_verification' }, { status: 409 })
const body: { verdict: 'verified' | 'reopened'; reopen_reason?: string } = await request.json()
if (body.verdict !== 'verified' && body.verdict !== 'reopened')
return NextResponse.json({ error: 'verdict must be verified or reopened' }, { status: 422 })
const update: Record<string, unknown> = {
status: body.verdict,
verified_by: user.id,
verified_at: new Date().toISOString(),
}
const { error } = await supabase
.from('capa_actions').update(update).eq('id', id)
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'capa_actions',
p_record_id: id,
p_action: body.verdict === 'verified' ? 'verified' : 'reopened',
p_new_value: { ...update, reopen_reason: body.reopen_reason ?? null },
})
// Check if all CAPAs for this incident are verified — if so, transition incident to verification
const { data: openCapas } = await supabase
.from('capa_actions')
.select('id')
.eq('incident_id', capa.incident_id)
.not('status', 'in', '(verified,closed)')
if (!openCapas || openCapas.length === 0) {
await supabase
.from('incidents')
.update({ status: 'verification' })
.eq('id', capa.incident_id)
}
return NextResponse.json({ ok: true })
}
- Step 2: Create verify form component
Create components/capa/verify-form.tsx:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
interface Props {
capaId: string
}
export function VerifyForm({ capaId }: Props) {
const router = useRouter()
const [verdict, setVerdict] = useState<'verified' | 'reopened' | null>(null)
const [reopenReason, setReopenReason] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!verdict) return
if (verdict === 'reopened' && !reopenReason.trim()) {
setError('Reopen reason is required')
return
}
setSaving(true)
setError(null)
const res = await fetch(`/api/capa/${capaId}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verdict, reopen_reason: reopenReason || null }),
})
if (!res.ok) {
const data = await res.json()
setError(data.error ?? 'Verification failed')
setSaving(false)
return
}
router.push('/hse/capa')
router.refresh()
}
return (
<form onSubmit={handleSubmit} className="border border-gray-200 rounded-xl p-5 space-y-4 bg-purple-50">
<h3 className="text-sm font-semibold text-gray-800">HSE Verification</h3>
<div className="flex gap-3">
<button
type="button"
onClick={() => setVerdict('verified')}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
verdict === 'verified'
? 'bg-green-600 text-white border-green-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-green-500'
}`}
>
Verified — Effective
</button>
<button
type="button"
onClick={() => setVerdict('reopened')}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
verdict === 'reopened'
? 'bg-orange-600 text-white border-orange-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-orange-500'
}`}
>
Reopen — Ineffective
</button>
</div>
{verdict === 'reopened' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Why is the action ineffective? *</label>
<textarea
value={reopenReason}
onChange={e => setReopenReason(e.target.value)}
rows={2} required
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Describe why the corrective action did not resolve the root cause…"
/>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
{verdict && (
<button type="submit" disabled={saving}
className="w-full bg-gray-900 text-white rounded-lg py-2 text-sm font-semibold disabled:opacity-50">
{saving ? 'Saving…' : 'Submit Verification'}
</button>
)}
</form>
)
}
- Step 3: Create CAPA detail page
Create app/(protected)/hse/capa/[id]/page.tsx:
export const dynamic = 'force-dynamic'
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { VerifyForm } from '@/components/capa/verify-form'
interface Props {
params: Promise<{ id: string }>
}
const PRIORITY_BADGE: Record<string, string> = {
low: 'bg-gray-100 text-gray-600',
med: 'bg-yellow-100 text-yellow-700',
high: 'bg-red-100 text-red-700',
}
export default async function CapaDetailPage({ params }: Props) {
const { id } = await params
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect(`/login?redirect=/hse/capa/${id}`)
const { data: profile } = await supabase
.from('users').select('role').eq('id', data.user.id).single()
const { data: capa } = await supabase
.from('capa_actions')
.select(`
id, incident_id, root_cause_ref, description, department,
due_date, priority, status, completed_at, verified_by, verified_at, created_at,
incidents (reference_no, incident_type),
owner:users!owner_user_id (name, email)
`)
.eq('id', id)
.single()
if (!capa) notFound()
const isHse = profile && ['hse', 'admin'].includes(profile.role)
const status = (capa as { status: string }).status
return (
<main className="max-w-2xl mx-auto px-4 py-6">
<Link href="/hse/capa" className="text-sm text-blue-600 hover:underline mb-4 inline-block">
← CAPA Board
</Link>
<div className="bg-white rounded-xl shadow-sm p-5 mb-4 space-y-4">
<div className="flex items-start justify-between">
<div>
<p className="text-xs text-gray-500 mb-1">
Incident: {(capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no ?? '—'}
</p>
<p className="text-gray-900 font-medium">{(capa as { description: string }).description}</p>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${PRIORITY_BADGE[(capa as { priority: string }).priority] ?? ''}`}>
{(capa as { priority: string }).priority}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-gray-500">Owner</p>
<p className="text-gray-800">{(capa.owner as unknown as { name: string } | null)?.name ?? '—'}</p>
</div>
<div>
<p className="text-xs text-gray-500">Department</p>
<p className="text-gray-800">{(capa as { department: string }).department}</p>
</div>
<div>
<p className="text-xs text-gray-500">Due Date</p>
<p className="text-gray-800">{(capa as { due_date: string }).due_date}</p>
</div>
<div>
<p className="text-xs text-gray-500">Status</p>
<p className="text-gray-800 capitalize">{status.replace(/_/g, ' ')}</p>
</div>
</div>
{(capa as { root_cause_ref: string | null }).root_cause_ref && (
<div>
<p className="text-xs text-gray-500">Root Cause Reference</p>
<p className="text-sm text-gray-800">{(capa as { root_cause_ref: string }).root_cause_ref}</p>
</div>
)}
{(capa as { completed_at: string | null }).completed_at && (
<p className="text-xs text-gray-500">
Completed: {new Date((capa as { completed_at: string }).completed_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })}
</p>
)}
</div>
{isHse && status === 'pending_verification' && (
<VerifyForm capaId={id} />
)}
</main>
)
}
- Step 4: Build check
npm run build 2>&1 | tail -20
Expected: zero errors.
- Step 5: Commit
git add components/capa/verify-form.tsx \
app/api/capa/\[id\]/verify/route.ts \
app/\(protected\)/hse/capa/\[id\]/page.tsx
git commit -m "feat: verification flow — HSE verify/reopen CAPA, auto-transition incident to verification"
Task 7: CAPA overdue escalation cron
Files:
- Create:
lib/notifications/capa-escalation.ts - Create:
app/api/cron/capa-escalation/route.ts - Create:
tests/lib/notifications/capa-escalation.test.ts
Interfaces:
- Consumes:
sendEscalationEmail(to: string[], subject: string, html: string, text: string): Promise<void>— uses Resend under the hood, same pattern aslib/notifications/email.ts - Produces:
escalateOverdueCapa(supabase: SupabaseClient): Promise<{ notified: number }>— pure business logic, testable without HTTP - Produces: GET
/api/cron/capa-escalation— protected byAuthorization: Bearer <CRON_SECRET>header
Escalation thresholds (DUEDATE = capa.due_date):
- 3 days before due:
warning_3d— send to owner - On due date:
due_today— send to owner + HSE - 3 days overdue:
overdue_3d— send to owner + HSE + supervisor - 7 days overdue:
overdue_7d— send to owner + HSE + supervisor + management
Each threshold fires once (tracked in notifications_log). If notifications_log has a row for (capa_id, channel='email', status='sent') matching the threshold pattern, skip.
- Step 1: Write failing tests
Create tests/lib/notifications/capa-escalation.test.ts:
import { describe, it, expect } from 'vitest'
import { getEscalationThreshold } from '@/lib/notifications/capa-escalation'
describe('getEscalationThreshold', () => {
function daysFromNow(n: number): string {
const d = new Date()
d.setDate(d.getDate() + n)
return d.toISOString().split('T')[0]
}
it('returns warning_3d when due in 3 days', () => {
expect(getEscalationThreshold(daysFromNow(3))).toBe('warning_3d')
})
it('returns due_today when due today', () => {
expect(getEscalationThreshold(daysFromNow(0))).toBe('due_today')
})
it('returns overdue_3d when 3 days past due', () => {
expect(getEscalationThreshold(daysFromNow(-3))).toBe('overdue_3d')
})
it('returns overdue_7d when 7 days past due', () => {
expect(getEscalationThreshold(daysFromNow(-7))).toBe('overdue_7d')
})
it('returns null for 2 days before due (no threshold)', () => {
expect(getEscalationThreshold(daysFromNow(2))).toBeNull()
})
it('returns null for 4 days before due', () => {
expect(getEscalationThreshold(daysFromNow(4))).toBeNull()
})
})
- Step 2: Run tests to confirm they fail
npx vitest run tests/lib/notifications/capa-escalation.test.ts
Expected: Cannot find module '@/lib/notifications/capa-escalation'
- Step 3: Implement
lib/notifications/capa-escalation.ts
import { Resend } from 'resend'
import type { SupabaseClient } from '@supabase/supabase-js'
export type EscalationThreshold = 'warning_3d' | 'due_today' | 'overdue_3d' | 'overdue_7d'
export function getEscalationThreshold(dueDateIso: string): EscalationThreshold | null {
const today = new Date()
today.setHours(0, 0, 0, 0)
const due = new Date(dueDateIso)
due.setHours(0, 0, 0, 0)
const diffDays = Math.round((due.getTime() - today.getTime()) / 86_400_000)
if (diffDays === 3) return 'warning_3d'
if (diffDays === 0) return 'due_today'
if (diffDays === -3) return 'overdue_3d'
if (diffDays === -7) return 'overdue_7d'
return null
}
const THRESHOLD_SUBJECT: Record<EscalationThreshold, string> = {
warning_3d: '[IMS] CAPA action due in 3 days',
due_today: '[IMS] CAPA action due TODAY',
overdue_3d: '[IMS] CAPA action 3 days OVERDUE',
overdue_7d: '[IMS] URGENT: CAPA action 7 days OVERDUE',
}
export async function escalateOverdueCapa(
supabase: SupabaseClient
): Promise<{ notified: number }> {
const { data: capas } = await supabase
.from('capa_actions')
.select(`
id, description, due_date, incident_id,
incidents (reference_no, site_id),
owner:users!owner_user_id (email, name)
`)
.not('status', 'in', '(verified,closed)')
if (!capas || capas.length === 0) return { notified: 0 }
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
let notified = 0
for (const capa of capas) {
const threshold = getEscalationThreshold((capa as { due_date: string }).due_date)
if (!threshold) continue
const { data: alreadySent } = await supabase
.from('notifications_log')
.select('id')
.eq('capa_id', capa.id)
.eq('channel', 'email')
.eq('status', threshold)
.limit(1)
.maybeSingle()
if (alreadySent) continue
const ownerEmail = (capa.owner as unknown as { email: string } | null)?.email
const ownerName = (capa.owner as unknown as { name: string } | null)?.name ?? 'Owner'
if (!ownerEmail) continue
const incidentRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no ?? capa.incident_id
const capaUrl = `${siteUrl}/ims/hse/capa/${capa.id}`
const subject = THRESHOLD_SUBJECT[threshold]
const html = `
<p>Hi ${ownerName},</p>
<p>CAPA action for incident <strong>${incidentRef}</strong> requires attention.</p>
<p><strong>Action:</strong> ${(capa as { description: string }).description}</p>
<p><strong>Due date:</strong> ${(capa as { due_date: string }).due_date}</p>
<p><a href="${capaUrl}">View CAPA</a></p>
`
const text = `CAPA ${incidentRef}: ${(capa as { description: string }).description}\nDue: ${(capa as { due_date: string }).due_date}\n${capaUrl}`
const to = [ownerEmail]
const siteId = (capa.incidents as unknown as { site_id: string } | null)?.site_id
if (siteId && ['due_today', 'overdue_3d', 'overdue_7d'].includes(threshold)) {
const { data: hseUsers } = await supabase
.from('users')
.select('email')
.eq('site_id', siteId)
.in('role', ['hse', 'supervisor', 'management'])
if (hseUsers) to.push(...hseUsers.map((u: { email: string }) => u.email))
}
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
if (error) {
console.error('Escalation email error:', error)
continue
}
await supabase.from('notifications_log').insert({
capa_id: capa.id,
channel: 'email',
recipient: to.join(','),
status: threshold,
})
notified++
}
return { notified }
}
- Step 4: Run tests to confirm they pass
npx vitest run tests/lib/notifications/capa-escalation.test.ts
Expected: 6 passed
- Step 5: Create cron API route
Create app/api/cron/capa-escalation/route.ts:
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
export async function GET(request: NextRequest) {
const auth = request.headers.get('authorization')
const expected = `Bearer ${process.env.CRON_SECRET}`
if (!auth || auth !== expected) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const supabase = await createClient()
const { notified } = await escalateOverdueCapa(supabase)
return NextResponse.json({ ok: true, notified })
}
- Step 6: Add CRON_SECRET to .env.local.example
Open .env.local.example and add at the bottom:
CRON_SECRET=<generate a random secret, e.g. openssl rand -hex 32>
- Step 7: Register VPS cron job
On the VPS (run via SSH or note for manual setup):
# Add to root crontab — runs daily at 08:00 MYT (00:00 UTC)
crontab -e
# Add this line:
0 0 * * * curl -s -H "Authorization: Bearer <CRON_SECRET>" http://localhost:3000/ims/api/cron/capa-escalation >> /var/log/ims-cron.log 2>&1
Document in docs/vps-cron.md:
CAPA escalation: runs daily at 00:00 UTC (08:00 MYT)
Command: curl -s -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/ims/api/cron/capa-escalation
Log: /var/log/ims-cron.log
- Step 8: Build check
npm run build 2>&1 | tail -20
Expected: zero errors.
- Step 9: Commit
git add lib/notifications/capa-escalation.ts \
app/api/cron/capa-escalation/route.ts \
tests/lib/notifications/capa-escalation.test.ts \
.env.local.example \
docs/vps-cron.md
git commit -m "feat: CAPA overdue escalation cron — 4 thresholds, once-per-threshold via notifications_log"
Task 8: JKKP 6/7 PDF generation
Files:
- Create:
lib/pdf/jkkp.ts - Create:
app/api/incidents/[id]/jkkp-pdf/route.ts - Create:
tests/lib/pdf/jkkp.test.ts - Modify:
package.json— addpdf-lib - Modify:
app/(protected)/hse/incidents/[id]/page.tsx— add PDF download buttons for DOSH-reportable incidents
Interfaces:
-
Consumes:
computeDoshObligationfrom@/lib/incidents/dosh -
Produces:
async function buildJkkp6Pdf(incident: JkkpIncident): Promise<Uint8Array> async function buildJkkp7Pdf(incident: JkkpIncident): Promise<Uint8Array> 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 } -
Produces: GET
/api/incidents/[id]/jkkp-pdf?form=jkkp6or?form=jkkp7— streams PDF bytes -
Step 1: Install pdf-lib
npm install pdf-lib
Expected: pdf-lib added to package.json dependencies.
- Step 2: Write failing tests
Create tests/lib/pdf/jkkp.test.ts:
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')
})
})
- Step 3: Run tests to confirm they fail
npx vitest run tests/lib/pdf/jkkp.test.ts
Expected: Cannot find module '@/lib/pdf/jkkp'
- Step 4: Implement
lib/pdf/jkkp.ts
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
}
async function createBaseDoc(title: string): Promise<{ doc: PDFDocument; page: ReturnType<PDFDocument['addPage']>; font: Awaited<ReturnType<PDFDocument['embedFont']>>; boldFont: Awaited<ReturnType<PDFDocument['embedFont']>>; 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: ReturnType<PDFDocument['addPage']>,
label: string, value: string,
font: Awaited<ReturnType<PDFDocument['embedFont']>>,
boldFont: Awaited<ReturnType<PDFDocument['embedFont']>>,
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()
}
- Step 5: Run tests to confirm they pass
npx vitest run tests/lib/pdf/jkkp.test.ts
Expected: 4 passed
- Step 6: Create JKKP PDF API route
Create app/api/incidents/[id]/jkkp-pdf/route.ts:
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(pdfBytes, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${form}-${ref}.pdf"`,
},
})
}
- Step 7: Add PDF download buttons to incident detail page
Modify app/(protected)/hse/incidents/[id]/page.tsx — in the HSE-only action section, add:
{/* After existing action buttons, still inside isHse check */}
{/* DOSH PDF links — only render if fields exist (post-triage) */}
{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>
)}
- Step 8: Build check
npm run build 2>&1 | tail -20
Expected: zero errors.
- Step 9: Run all tests
npx vitest run
Expected: all tests pass (dosh + capa-escalation + jkkp).
- Step 10: Commit
git add lib/pdf/jkkp.ts \
app/api/incidents/\[id\]/jkkp-pdf/route.ts \
tests/lib/pdf/jkkp.test.ts \
app/\(protected\)/hse/incidents/\[id\]/page.tsx \
package.json package-lock.json
git commit -m "feat: JKKP 6/7 PDF generation via pdf-lib, download from incident detail"
Post-Implementation: Deploy to VPS
After all tasks complete and npm run build passes:
# From local machine
rsync -avz --exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='.env.local' \
/Users/yapweeihan/Desktop/Projects/IMS.nosync/ root@64.176.82.100:/ims/
ssh root@64.176.82.100 "cd /ims && npm ci && npm run build && \
cp -r public .next/standalone/ && \
cp -r .next/static .next/standalone/.next/ && \
pm2 restart ims && pm2 status"
Add CRON_SECRET to VPS .env.local, then install cron:
ssh root@64.176.82.100 "crontab -l | { cat; echo '0 0 * * * curl -s -H \"Authorization: Bearer \$(grep CRON_SECRET /ims/.env.local | cut -d= -f2)\" http://localhost:3000/ims/api/cron/capa-escalation >> /var/log/ims-cron.log 2>&1'; } | crontab -"