Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
249 lines
9.1 KiB
TypeScript
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>
|
|
)
|
|
}
|