- zones: add active column (migration 20260713000005) - sites/zones API: PATCH (toggle active) + DELETE (blocked when incidents reference it) - trucks API: PATCH + DELETE with same pattern - admin page: select active for sites + zones - site-zone-manager + truck-manager: deactivate toggle + delete button per row with busyId - incidents API: reject reports on deactivated zone/site; wrap handler in top-level try-catch so unhandled errors return JSON (not HTML) - report-form: parse JSON separately so HTTP status code surfaces instead of generic "Something went wrong" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { FileUpload } from '@/components/incidents/file-upload'
|
|
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'
|
|
import { useTranslations } from '@/lib/i18n/context'
|
|
|
|
interface Props {
|
|
zoneToken: string | null
|
|
zoneName: string | null
|
|
siteName: string | null
|
|
trucks: Array<{ id: string; truck_no: string; carrier: string | null }>
|
|
initialTruckId?: string | null
|
|
}
|
|
|
|
export function ReportForm({ zoneToken, trucks, initialTruckId }: Props) {
|
|
const router = useRouter()
|
|
const t = useTranslations('ReportForm')
|
|
const itLabels = useTranslations('IncidentType')
|
|
const msLabels = useTranslations('MedicalStatus')
|
|
const tdLabels = useTranslations('TypeDetails')
|
|
|
|
const INCIDENT_TYPE_OPTIONS: [IncidentType, string][] = [
|
|
['injury', itLabels.injury],
|
|
['near_miss', itLabels.near_miss],
|
|
['hazard', itLabels.hazard],
|
|
['asset_damage', itLabels.asset_damage],
|
|
['environmental', itLabels.environmental],
|
|
['security', itLabels.security],
|
|
['fire', itLabels.fire],
|
|
['transport', itLabels.transport],
|
|
]
|
|
|
|
const MEDICAL_STATUS_OPTIONS: [MedicalStatus, string][] = [
|
|
['none', msLabels.none],
|
|
['first_aid', msLabels.first_aid],
|
|
['medical_treatment', msLabels.medical_treatment],
|
|
['lti', msLabels.lti],
|
|
]
|
|
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [files, setFiles] = useState<File[]>([])
|
|
const [isOnline, setIsOnline] = useState(true)
|
|
const [savedOffline, setSavedOffline] = useState(false)
|
|
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: (initialTruckId ? 'transport' : '') as IncidentType | '',
|
|
description: '',
|
|
injury_involved: false,
|
|
medical_status: '' as MedicalStatus | '',
|
|
asset_involved: false,
|
|
truck_id: initialTruckId ?? '',
|
|
})
|
|
const [typeDetails, setTypeDetails] = useState<Record<string, string | boolean>>({})
|
|
|
|
const setDetail = (key: string, value: string | boolean) =>
|
|
setTypeDetails(d => ({ ...d, [key]: value }))
|
|
|
|
const detailText = (key: string, label: string, placeholder = '') => (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
|
<input
|
|
type="text"
|
|
value={(typeDetails[key] as string) ?? ''}
|
|
onChange={e => setDetail(key, e.target.value)}
|
|
placeholder={placeholder}
|
|
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"
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
const detailCheckbox = (key: string, label: string) => (
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
className="w-4 h-4 text-blue-600"
|
|
checked={Boolean(typeDetails[key])}
|
|
onChange={e => setDetail(key, e.target.checked)}
|
|
/>
|
|
<span className="text-sm font-medium text-gray-700">{label}</span>
|
|
</label>
|
|
)
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setIsOnline(navigator.onLine)
|
|
const onOnline = () => setIsOnline(true)
|
|
const onOffline = () => setIsOnline(false)
|
|
window.addEventListener('online', onOnline)
|
|
window.addEventListener('offline', onOffline)
|
|
return () => {
|
|
window.removeEventListener('online', onOnline)
|
|
window.removeEventListener('offline', onOffline)
|
|
}
|
|
}, [])
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
setSubmitting(true)
|
|
|
|
// Offline path — save to IndexedDB
|
|
if (!isOnline) {
|
|
try {
|
|
const { addPendingReport } = await import('@/lib/offline/db')
|
|
await addPendingReport({
|
|
zone_token: zoneToken ?? '',
|
|
incident_type: form.incident_type as IncidentType,
|
|
description: form.description,
|
|
injury_involved: form.injury_involved,
|
|
asset_involved: form.asset_involved,
|
|
medical_status: form.medical_status || undefined,
|
|
type_details: Object.keys(typeDetails).length > 0 ? typeDetails : undefined,
|
|
truck_id: form.truck_id || undefined,
|
|
created_at: new Date().toISOString(),
|
|
})
|
|
setSavedOffline(true)
|
|
} catch (err) {
|
|
console.error('Offline save error:', err)
|
|
setError(t.errorGeneric)
|
|
}
|
|
setSubmitting(false)
|
|
return
|
|
}
|
|
|
|
if (!overrideQuality) {
|
|
try {
|
|
const qcRes = await fetch('/ims/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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if (Object.keys(typeDetails).length > 0) {
|
|
fd.append('type_details', JSON.stringify(typeDetails))
|
|
}
|
|
if (form.truck_id) fd.append('truck_id', form.truck_id)
|
|
files.forEach(f => fd.append('files', f))
|
|
|
|
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
|
|
|
|
// Parse JSON separately so a non-JSON response (HTML error page) surfaces the status code
|
|
let data: Record<string, unknown> = {}
|
|
try {
|
|
data = await res.json()
|
|
} catch {
|
|
console.error('Non-JSON response from /api/incidents', res.status, res.statusText)
|
|
setError(`${t.errorGeneric} (HTTP ${res.status})`)
|
|
return
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const msg = Array.isArray(data.details) ? (data.details as string[]).join('. ') : (data.error as string)
|
|
setError(msg ?? t.errorGeneric)
|
|
return
|
|
}
|
|
|
|
router.push(`/report/success?ref=${data.reference_no}`)
|
|
} catch (err) {
|
|
console.error('Submit error:', err)
|
|
setError(t.errorGeneric)
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
if (savedOffline) {
|
|
return (
|
|
<div className="bg-green-50 border border-green-200 rounded-xl p-5 text-sm text-green-700">
|
|
{t.savedOffline}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
|
|
{!isOnline && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 text-sm text-yellow-700">
|
|
{t.offlineBanner}
|
|
</div>
|
|
)}
|
|
|
|
{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">
|
|
{t.incidentTypeLabel} <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, truck_id: '' }))
|
|
setTypeDetails({})
|
|
}}
|
|
>
|
|
<option value="">{t.incidentTypePlaceholder}</option>
|
|
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{form.incident_type === 'environmental' && (
|
|
<div className="space-y-3 border border-gray-200 rounded-lg p-3">
|
|
{detailText('substance', tdLabels.substance, tdLabels.substancePlaceholder)}
|
|
{detailText('estimated_volume', tdLabels.estimatedVolume, tdLabels.estimatedVolumePlaceholder)}
|
|
{detailCheckbox('containment_deployed', tdLabels.containmentDeployed)}
|
|
</div>
|
|
)}
|
|
{form.incident_type === 'asset_damage' && (
|
|
<div className="space-y-3 border border-gray-200 rounded-lg p-3">
|
|
{detailText('equipment_id', tdLabels.equipmentId, tdLabels.equipmentIdPlaceholder)}
|
|
{detailCheckbox('loto_applied', tdLabels.lotoApplied)}
|
|
</div>
|
|
)}
|
|
{form.incident_type === 'security' && (
|
|
<div className="space-y-3 border border-gray-200 rounded-lg p-3">
|
|
{detailText('persons_involved', tdLabels.personsInvolved, tdLabels.personsInvolvedPlaceholder)}
|
|
{detailCheckbox('police_reported', tdLabels.policeReported)}
|
|
</div>
|
|
)}
|
|
{form.incident_type === 'fire' && (
|
|
<div className="space-y-3 border border-gray-200 rounded-lg p-3">
|
|
{detailCheckbox('alarm_raised', tdLabels.alarmRaised)}
|
|
{detailCheckbox('fire_brigade_called', tdLabels.fireBrigadeCalled)}
|
|
</div>
|
|
)}
|
|
|
|
{form.incident_type === 'transport' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{t.truckLabel} <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.truck_id}
|
|
onChange={e => setForm(f => ({ ...f, truck_id: e.target.value }))}
|
|
>
|
|
<option value="">{t.truckPlaceholder}</option>
|
|
{trucks.map(tk => (
|
|
<option key={tk.id} value={tk.id}>
|
|
{tk.truck_no}{tk.carrier ? ` — ${tk.carrier}` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{t.descriptionLabel} <span className="text-red-500">*</span>
|
|
</label>
|
|
<textarea
|
|
required
|
|
minLength={10}
|
|
rows={4}
|
|
placeholder={t.descriptionPlaceholder}
|
|
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">
|
|
{t.qualityScoreLabel.replace('{score}', String(qualityCheck.score))}
|
|
</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"
|
|
/>
|
|
{t.submitAnyway}
|
|
</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">{t.injuryInvolved}</span>
|
|
</label>
|
|
|
|
{form.injury_involved && (
|
|
<div className="ml-7">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{t.treatmentLevel} <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="">{t.treatmentPlaceholder}</option>
|
|
{MEDICAL_STATUS_OPTIONS.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">{t.assetInvolved}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
{t.filesLabel}
|
|
</label>
|
|
<FileUpload onFilesChange={setFiles} disabled={submitting} />
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={submitting || !form.incident_type || (form.incident_type === 'transport' && !form.truck_id)}
|
|
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 ? t.submitting : t.submitButton}
|
|
</button>
|
|
</form>
|
|
)
|
|
}
|