- 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
2629 lines
90 KiB
Markdown
2629 lines
90 KiB
Markdown
# 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.example` as 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 `deleted` flag
|
||
- 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 bare `as any` (ESLint blocks it) and never `as DirectType` without `unknown` intermediary (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 internal `href` values are relative (no `/ims` prefix in code)
|
||
- `npm run build` must 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 function `computeDoshObligation`
|
||
- `lib/pdf/jkkp.ts` — pdf-lib JKKP 6/7 PDF builder
|
||
- `lib/notifications/capa-escalation.ts` — overdue CAPA escalation email logic
|
||
|
||
### New files — components
|
||
- `components/incidents/triage-form.tsx` — severity slider + DOSH checklist + notes
|
||
- `components/incidents/investigation-form.tsx` — method picker + 5-Why / fishbone RCA form
|
||
- `components/capa/capa-board.tsx` — Kanban + table toggle, groups by status
|
||
- `components/capa/capa-form.tsx` — create CAPA form
|
||
- `components/capa/capa-detail.tsx` — single CAPA view + verify/reopen actions
|
||
- `components/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→triaged
|
||
- `app/api/incidents/[id]/investigation/route.ts` — POST: create investigation; PATCH: update
|
||
- `app/api/capa/route.ts` — GET: list CAPAs; POST: create
|
||
- `app/api/capa/[id]/route.ts` — GET: single CAPA; PATCH: update status/fields
|
||
- `app/api/capa/[id]/verify/route.ts` — POST: HSE verification
|
||
- `app/api/incidents/[id]/jkkp-pdf/route.ts` — GET: generate + stream JKKP 6 or 7 PDF
|
||
- `app/api/cron/capa-escalation/route.ts` — GET: protected by `CRON_SECRET` header
|
||
|
||
### New files — pages
|
||
- `app/(protected)/hse/incidents/[id]/triage/page.tsx`
|
||
- `app/(protected)/hse/incidents/[id]/investigation/page.tsx`
|
||
- `app/(protected)/hse/incidents/[id]/capa/new/page.tsx`
|
||
- `app/(protected)/hse/capa/page.tsx`
|
||
- `app/(protected)/hse/capa/[id]/page.tsx`
|
||
|
||
### New files — tests
|
||
- `tests/lib/incidents/dosh.test.ts`
|
||
- `tests/lib/pdf/jkkp.test.ts`
|
||
- `tests/lib/notifications/capa-escalation.test.ts`
|
||
- `tests/api/triage.test.ts`
|
||
- `tests/api/capa.test.ts`
|
||
|
||
### Modified files
|
||
- `app/(protected)/hse/incidents/[id]/page.tsx` — add "Triage" / "Investigate" / "Add CAPA" action buttons based on current status
|
||
- `components/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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
npx supabase db push
|
||
```
|
||
|
||
Expected: `Applying migration 20260711000011_phase2_triage_rca.sql...` with no errors.
|
||
|
||
- [ ] **Step 3: Verify columns exist**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/incidents/dosh.test.ts
|
||
```
|
||
|
||
Expected: `Cannot find module '@/lib/incidents/dosh'`
|
||
|
||
- [ ] **Step 3: Implement `lib/incidents/dosh.ts`**
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/incidents/dosh.test.ts
|
||
```
|
||
|
||
Expected: `8 passed`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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: `computeDoshObligation` from `@/lib/incidents/dosh`
|
||
- Produces: PATCH `/api/incidents/[id]/triage` — updates triage fields, transitions `reported` → `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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
'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`:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
{/* 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**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: `✓ Compiled successfully` with no TypeScript or ESLint errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
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_categories` columns)
|
||
- Produces: POST `/api/incidents/[id]/investigation` — creates investigation record, transitions `triaged` → `investigating`
|
||
- Produces: PATCH `/api/incidents/[id]/investigation` — updates existing investigation, transitions `investigating` → `capa_pending` when completed
|
||
|
||
Five-Why data shape (stored as JSONB):
|
||
```typescript
|
||
type FiveWhyStep = { why: string; answer: string }
|
||
// five_why_steps: FiveWhyStep[] (up to 5 entries)
|
||
```
|
||
|
||
Fishbone data shape:
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
'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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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_actions` table from Task 1 migration
|
||
- Produces:
|
||
- GET `/api/capa` — returns `CapaAction[]` for current user's scope
|
||
- POST `/api/capa` — creates new `capa_action`, transitions incident `investigating` → `capa_pending` if needed
|
||
- GET `/api/capa/[id]` — returns single `CapaAction`
|
||
- PATCH `/api/capa/[id]` — updates status/fields
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
'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`:
|
||
|
||
```typescript
|
||
'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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
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: `CapaAction` type 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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
'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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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 as `lib/notifications/email.ts`
|
||
- Produces: `escalateOverdueCapa(supabase: SupabaseClient): Promise<{ notified: number }>` — pure business logic, testable without HTTP
|
||
- Produces: GET `/api/cron/capa-escalation` — protected by `Authorization: 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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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`**
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```typescript
|
||
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):
|
||
|
||
```bash
|
||
# 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**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
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` — add `pdf-lib`
|
||
- Modify: `app/(protected)/hse/incidents/[id]/page.tsx` — add PDF download buttons for DOSH-reportable incidents
|
||
|
||
**Interfaces:**
|
||
- Consumes: `computeDoshObligation` from `@/lib/incidents/dosh`
|
||
- Produces:
|
||
```typescript
|
||
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=jkkp6` or `?form=jkkp7` — streams PDF bytes
|
||
|
||
- [ ] **Step 1: Install pdf-lib**
|
||
|
||
```bash
|
||
npm install pdf-lib
|
||
```
|
||
|
||
Expected: `pdf-lib` added to `package.json` dependencies.
|
||
|
||
- [ ] **Step 2: Write failing tests**
|
||
|
||
Create `tests/lib/pdf/jkkp.test.ts`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/pdf/jkkp.test.ts
|
||
```
|
||
|
||
Expected: `Cannot find module '@/lib/pdf/jkkp'`
|
||
|
||
- [ ] **Step 4: Implement `lib/pdf/jkkp.ts`**
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
{/* 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**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 9: Run all tests**
|
||
|
||
```bash
|
||
npx vitest run
|
||
```
|
||
|
||
Expected: all tests pass (dosh + capa-escalation + jkkp).
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
# 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:
|
||
```bash
|
||
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 -"
|
||
```
|