Files
ims/components/incidents/report-form.tsx
T
adminandClaude Sonnet 4.6 3bec95aaf8 feat: AI report quality check before submission
Adds POST /api/incidents/ai/quality-check using Claude tool_use to score incident descriptions 1-10. Report form blocks submission when score < 6, shows amber feedback with suggestions, and offers a 'Submit anyway' override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
2026-07-11 16:18:39 +08:00

237 lines
8.1 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { FileUpload } from '@/components/incidents/file-upload'
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'
const INCIDENT_TYPE_LABELS: Record<IncidentType, string> = {
injury: 'Injury / Medical',
near_miss: 'Near Miss',
hazard: 'Hazard / Unsafe Condition',
asset_damage: 'Asset / Equipment Damage',
environmental: 'Environmental Incident',
security: 'Security Incident',
fire: 'Fire / Emergency',
}
const MEDICAL_STATUS_LABELS: Record<MedicalStatus, string> = {
none: 'No treatment needed',
first_aid: 'First aid only',
medical_treatment: 'Medical treatment (non-LTI)',
lti: 'Lost Time Injury (LTI)',
}
interface Props {
zoneToken: string | null
zoneName: string | null
siteName: string | null
}
export function ReportForm({ zoneToken }: Props) {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [files, setFiles] = useState<File[]>([])
const [qualityCheck, setQualityCheck] = useState<{
score: number
passes: boolean
feedback: string
suggestions: string[]
} | null>(null)
const [overrideQuality, setOverrideQuality] = useState(false)
const [form, setForm] = useState({
incident_type: '' as IncidentType | '',
description: '',
injury_involved: false,
medical_status: '' as MedicalStatus | '',
asset_involved: false,
})
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSubmitting(true)
if (!overrideQuality) {
try {
const qcRes = await fetch('/api/incidents/ai/quality-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: form.description,
incident_type: form.incident_type,
}),
})
if (qcRes.ok) {
const qc = await qcRes.json() as {
score: number
passes: boolean
feedback: string
suggestions: string[]
}
setQualityCheck(qc)
if (!qc.passes) {
setSubmitting(false)
return
}
}
} catch {
// Quality check failure is non-blocking — proceed with submission
}
}
try {
const fd = new FormData()
if (zoneToken) fd.append('zone_token', zoneToken)
fd.append('incident_type', form.incident_type)
fd.append('description', form.description)
fd.append('injury_involved', String(form.injury_involved))
fd.append('asset_involved', String(form.asset_involved))
if (form.injury_involved && form.medical_status) {
fd.append('medical_status', form.medical_status)
}
files.forEach(f => fd.append('files', f))
const res = await fetch('/api/incidents', { method: 'POST', body: fd })
const data = await res.json()
if (!res.ok) {
setError(data.details ? data.details.join('. ') : data.error)
return
}
router.push(`/report/success?ref=${data.reference_no}`)
} catch {
setError('Something went wrong. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Incident type <span className="text-red-500">*</span>
</label>
<select
required
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.incident_type}
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
>
<option value="">Select type</option>
{Object.entries(INCIDENT_TYPE_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
What happened? <span className="text-red-500">*</span>
</label>
<textarea
required
minLength={10}
rows={4}
placeholder="Describe what happened, where, and any immediate actions taken…"
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
{qualityCheck && !qualityCheck.passes && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 space-y-2">
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
Report quality {qualityCheck.score}/10
</p>
<p className="text-sm text-amber-800">{qualityCheck.feedback}</p>
{qualityCheck.suggestions.length > 0 && (
<ul className="list-disc list-inside space-y-1">
{qualityCheck.suggestions.map((s, i) => (
<li key={i} className="text-xs text-amber-700">{s}</li>
))}
</ul>
)}
<label className="flex items-center gap-2 text-xs text-amber-700 cursor-pointer mt-1">
<input
type="checkbox"
checked={overrideQuality}
onChange={e => setOverrideQuality(e.target.checked)}
className="rounded border-amber-300 text-amber-600"
/>
Submit anyway
</label>
</div>
)}
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 text-blue-600"
checked={form.injury_involved}
onChange={e => setForm(f => ({ ...f, injury_involved: e.target.checked, medical_status: '' }))}
/>
<span className="text-sm font-medium text-gray-700">Person was injured</span>
</label>
{form.injury_involved && (
<div className="ml-7">
<label className="block text-sm font-medium text-gray-700 mb-1">
Treatment level <span className="text-red-500">*</span>
</label>
<select
required={form.injury_involved}
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.medical_status}
onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
>
<option value="">Select treatment</option>
{Object.entries(MEDICAL_STATUS_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
)}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 text-blue-600"
checked={form.asset_involved}
onChange={e => setForm(f => ({ ...f, asset_involved: e.target.checked }))}
/>
<span className="text-sm font-medium text-gray-700">Equipment / asset was damaged</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Photos / Videos / Documents
</label>
<FileUpload onFilesChange={setFiles} disabled={submitting} />
</div>
<button
type="submit"
disabled={submitting || !form.incident_type}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium text-sm
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? 'Submitting…' : 'Submit Incident Report'}
</button>
</form>
)
}