From e55c5284a728ef5dc5aeb0ae1a416f6b2bdde9b8 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 15:12:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20triage=20panel=20=E2=80=94=20severity?= =?UTF-8?q?=20+=20DOSH=20checklist,=20transitions=20reported=E2=86=92triag?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also excludes node_modules.nosync from tsconfig to fix pre-existing TS type check bleed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr --- app/(protected)/hse/incidents/[id]/page.tsx | 33 +++++ .../hse/incidents/[id]/triage/page.tsx | 45 ++++++ app/api/incidents/[id]/triage/route.ts | 65 +++++++++ components/incidents/triage-form.tsx | 134 ++++++++++++++++++ tsconfig.json | 3 +- 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 app/(protected)/hse/incidents/[id]/triage/page.tsx create mode 100644 app/api/incidents/[id]/triage/route.ts create mode 100644 components/incidents/triage-form.tsx diff --git a/app/(protected)/hse/incidents/[id]/page.tsx b/app/(protected)/hse/incidents/[id]/page.tsx index 2f5b04e..2056672 100644 --- a/app/(protected)/hse/incidents/[id]/page.tsx +++ b/app/(protected)/hse/incidents/[id]/page.tsx @@ -30,12 +30,45 @@ export default async function HseIncidentDetailPage({ params }: Props) { if (error || !incident) notFound() + const status = (incident as { status: string }).status + return (
← Back to inbox + + {status === 'reported' && ( +
+ + Triage Incident + +
+ )} + {status === 'triaged' && ( +
+ + Start Investigation + +
+ )} + {(['investigating', 'capa_pending'] as string[]).includes(status) && ( +
+ + Add CAPA Action + +
+ )}
) } diff --git a/app/(protected)/hse/incidents/[id]/triage/page.tsx b/app/(protected)/hse/incidents/[id]/triage/page.tsx new file mode 100644 index 0000000..7721fcd --- /dev/null +++ b/app/(protected)/hse/incidents/[id]/triage/page.tsx @@ -0,0 +1,45 @@ +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 ( +
+ + ← Back to incident + +

Triage Incident

+

{incident.reference_no ?? id}

+ +
+ ) +} diff --git a/app/api/incidents/[id]/triage/route.ts b/app/api/incidents/[id]/triage/route.ts new file mode 100644 index 0000000..3cf98a4 --- /dev/null +++ b/app/api/incidents/[id]/triage/route.ts @@ -0,0 +1,65 @@ +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 }) +} diff --git a/components/incidents/triage-form.tsx b/components/incidents/triage-form.tsx new file mode 100644 index 0000000..cc3cfa1 --- /dev/null +++ b/components/incidents/triage-form.tsx @@ -0,0 +1,134 @@ +'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 = { + 1: 'Minor', + 2: 'Low', + 3: 'Moderate', + 4: 'Serious', + 5: 'Critical / Fatality', +} + +export function TriageForm({ incidentId, currentSeverity }: Props) { + const router = useRouter() + const [severity, setSeverity] = useState(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(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 ( +
+
+ + setSeverity(Number(e.target.value))} + className="w-full accent-blue-600" + /> +
+ 1 Minor3 Moderate5 Critical +
+
+ +
+ DOSH Regulatory Checklist (NADOPOD 2004) + {[ + { 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 }) => ( + + ))} +
+ + {dosh.reasons.length > 0 && ( +
+

DOSH Reporting Required

+ {dosh.reasons.map(r => ( +

{r}

+ ))} + {dosh.requires_immediate_notification && ( +

⚠ Immediate notification required (within 24h)

+ )} +
+ )} + +
+ +