Files
ims/components/incidents/investigation-form.tsx
T

249 lines
9.1 KiB
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)
const [aiDraftLoading, setAiDraftLoading] = useState(false)
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 getAiDraft() {
setAiDraftLoading(true)
try {
const res = await fetch(`/api/incidents/${incidentId}/ai/rca-draft`, { method: 'POST' })
if (!res.ok) return
const draft = await res.json() as {
five_why_steps: Array<{ why: string; answer: string }>
root_cause_summary: string
capa_suggestions: string[]
}
if (draft.five_why_steps?.length > 0) {
setMethod('five_why')
setFiveWhy(draft.five_why_steps)
}
if (draft.root_cause_summary) setRootCause(draft.root_cause_summary)
if (draft.capa_suggestions?.length > 0) {
setFindingsText(draft.capa_suggestions.map((s, i) => `${i + 1}. ${s}`).join('\n'))
}
} catch {
// Non-blocking — investigator can fill manually
} finally {
setAiDraftLoading(false)
}
}
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 className="flex justify-end">
<button
type="button"
onClick={getAiDraft}
disabled={aiDraftLoading}
className="bg-purple-50 text-purple-700 border border-purple-300 rounded-lg px-3 py-1.5 text-xs font-semibold disabled:opacity-50 hover:bg-purple-100"
>
{aiDraftLoading ? 'Drafting…' : 'Get AI Draft'}
</button>
</div>
<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>
)
}