diff --git a/app/(protected)/hse/incidents/[id]/investigation/page.tsx b/app/(protected)/hse/incidents/[id]/investigation/page.tsx new file mode 100644 index 0000000..0994108 --- /dev/null +++ b/app/(protected)/hse/incidents/[id]/investigation/page.tsx @@ -0,0 +1,54 @@ +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 ( +
+ + ← Back to incident + +

Investigation Workspace

+

{(incident as { reference_no: string | null }).reference_no ?? id}

+ +
+ ) +} diff --git a/app/api/incidents/[id]/investigation/route.ts b/app/api/incidents/[id]/investigation/route.ts new file mode 100644 index 0000000..363ff75 --- /dev/null +++ b/app/api/incidents/[id]/investigation/route.ts @@ -0,0 +1,112 @@ +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 = { + 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 }) +} diff --git a/components/incidents/investigation-form.tsx b/components/incidents/investigation-form.tsx new file mode 100644 index 0000000..5fda866 --- /dev/null +++ b/components/incidents/investigation-form.tsx @@ -0,0 +1,212 @@ +'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 = { + 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([ + { why: 'Why did the incident happen?', answer: '' }, + ]) + const [fishbone, setFishbone] = useState( + 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(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 ( +
+
+ +
+ {(['five_why', 'fishbone', 'other'] as const).map(m => ( + + ))} +
+
+ + {method === 'five_why' && ( +
+

5-Why Analysis

+ {fiveWhy.map((step, i) => ( +
+ + 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" + /> +