feat: Phase 5 & 6 — usability, compliance hardening, analytics
Phase 5 (usability + compliance): - In-app notification bell/badge: migration 016 adds read state + per-user RLS + create_in_app_notification SECURITY DEFINER RPC; /api/notifications; wired into incident creation, CAPA assign/verify, escalation cron - Incident closure: new POST /api/incidents/[id]/close (requires verification status + all CAPAs verified); migration 017 locks closed incidents at DB level (update/delete triggers) with append-only incident_addenda + UI panel - Server-side pagination on HSE/supervisor incident inboxes (.range, 25/page) - Investigation form: alcohol/urine test result + witness statement refs (existing schema columns, now editable) - Type-specific intake fields: migration 018 adds incidents.type_details JSONB; whitelist validation; environmental/asset/security/fire field groups in report form; EN/MS/ZH labels; offline queue support - JKKP 8 annual register CSV export (/api/reports/jkkp8) + dashboard button + January statutory deadline banner - Admin page: user invite (service-role client), role/site/active management, site + zone CRUD with QR report links — replaces Phase 0 stub - Evidence gallery thumbnails via Supabase render transform with fallback Phase 6 (analytics): - 12-month stacked trend chart (leading/lagging/other) + top root causes (lib/dashboard/trends.ts pure helpers) - AI rising-risk zones: /api/dashboard/ai/risk-flags aggregates 90-day zone stats, claude-opus-4-8 forced tool_use, panel on HSE + management dashboards, suggestion audit-logged Also fixes 9 pre-existing missing /ims basePath prefixes in client fetches and download links. 132 tests passing, tsc clean, next build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type Addendum = {
|
||||
id: string
|
||||
body: string
|
||||
created_at: string
|
||||
author: { name: string } | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
incidentId: string
|
||||
status: string
|
||||
canClose: boolean
|
||||
canAddAddenda: boolean
|
||||
}
|
||||
|
||||
export function ClosurePanel({ incidentId, status, canClose, canAddAddenda }: Props) {
|
||||
const router = useRouter()
|
||||
const [addenda, setAddenda] = useState<Addendum[]>([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isClosed = status === 'closed'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClosed) return
|
||||
fetch(`/ims/api/incidents/${incidentId}/addenda`)
|
||||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((data: Addendum[]) => setAddenda(Array.isArray(data) ? data : []))
|
||||
.catch(() => {})
|
||||
}, [incidentId, isClosed])
|
||||
|
||||
const closeIncident = async () => {
|
||||
if (!confirm('Close this incident? The record will be locked — only addenda can be added afterwards.')) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await fetch(`/ims/api/incidents/${incidentId}/close`, { method: 'POST' })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error ?? 'Failed to close incident')
|
||||
return
|
||||
}
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const addAddendum = async () => {
|
||||
const text = draft.trim()
|
||||
if (!text) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await fetch(`/ims/api/incidents/${incidentId}/addenda`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error ?? 'Failed to add addendum')
|
||||
return
|
||||
}
|
||||
setDraft('')
|
||||
const list = await fetch(`/ims/api/incidents/${incidentId}/addenda`).then(r => r.json()).catch(() => [])
|
||||
setAddenda(Array.isArray(list) ? list : [])
|
||||
}
|
||||
|
||||
if (!isClosed && !canClose) return null
|
||||
|
||||
if (!isClosed) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{status === 'verification' ? (
|
||||
<button
|
||||
onClick={closeIncident}
|
||||
disabled={busy}
|
||||
className="bg-green-700 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-green-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Closing…' : 'Close Incident'}
|
||||
</button>
|
||||
) : null}
|
||||
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-1">Addenda</h2>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
This incident is closed and locked. New information is recorded as addenda.
|
||||
</p>
|
||||
{addenda.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No addenda.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{addenda.map(a => (
|
||||
<li key={a.id} className="border border-gray-100 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-800 whitespace-pre-wrap">{a.body}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{a.author?.name ?? 'Unknown'} · {new Date(a.created_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{canAddAddenda && (
|
||||
<div className="mt-4">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Add an addendum…"
|
||||
className="w-full border border-gray-200 rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={addAddendum}
|
||||
disabled={busy || !draft.trim()}
|
||||
className="mt-2 bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900 disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Saving…' : 'Add Addendum'}
|
||||
</button>
|
||||
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,13 @@ interface Props {
|
||||
function isImage(type: string) { return type.startsWith('image/') }
|
||||
function isVideo(type: string) { return type.startsWith('video/') }
|
||||
|
||||
// Supabase Storage image transform endpoint. Falls back to the original object
|
||||
// URL via onError if the project plan has no image transformation.
|
||||
function thumbnailUrl(url: string, width = 320): string {
|
||||
if (!url.includes('/storage/v1/object/public/')) return url
|
||||
return `${url.replace('/storage/v1/object/public/', '/storage/v1/render/image/public/')}?width=${width}&quality=60`
|
||||
}
|
||||
|
||||
export function EvidenceGallery({ files, stage }: Props) {
|
||||
const filtered = stage ? files.filter(f => f.stage === stage) : files
|
||||
if (filtered.length === 0) return <p className="text-sm text-gray-400">No files for this stage</p>
|
||||
@@ -33,7 +40,16 @@ export function EvidenceGallery({ files, stage }: Props) {
|
||||
className="block rounded-lg overflow-hidden bg-gray-100 aspect-square hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{isImage(file.file_type) ? (
|
||||
<img src={file.file_url} alt="Evidence" className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={thumbnailUrl(file.file_url)}
|
||||
alt="Evidence"
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
if (img.src !== file.file_url) img.src = file.file_url
|
||||
}}
|
||||
/>
|
||||
) : isVideo(file.file_type) ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-3xl">🎥</div>
|
||||
) : (
|
||||
|
||||
@@ -39,6 +39,7 @@ export type Incident = {
|
||||
lost_days: number | null
|
||||
reported_at: string
|
||||
closed_at: string | null
|
||||
type_details?: Record<string, string | boolean> | null
|
||||
sites: { id: string; name: string } | null
|
||||
zones: { id: string; name: string } | null
|
||||
reporter: { id: string; name: string; email: string } | null
|
||||
@@ -94,6 +95,14 @@ export function IncidentDetail({ incident }: Props) {
|
||||
{incident.injury_involved && incident.lost_days != null && (
|
||||
<Field label="Lost days" value={`${incident.lost_days} day(s)`} />
|
||||
)}
|
||||
{incident.type_details &&
|
||||
Object.entries(incident.type_details).map(([key, value]) => (
|
||||
<Field
|
||||
key={key}
|
||||
label={key.replace(/_/g, ' ')}
|
||||
value={typeof value === 'boolean' ? (value ? 'Yes' : 'No') : value}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
||||
)
|
||||
const [findingsText, setFindingsText] = useState('')
|
||||
const [rootCause, setRootCause] = useState('')
|
||||
const [alcoholTest, setAlcoholTest] = useState('')
|
||||
const [witnessRefs, setWitnessRefs] = useState<string[]>([''])
|
||||
const [complete, setComplete] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -61,7 +63,7 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
||||
async function getAiDraft() {
|
||||
setAiDraftLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/incidents/${incidentId}/ai/rca-draft`, { method: 'POST' })
|
||||
const res = await fetch(`/ims/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 }>
|
||||
@@ -92,6 +94,8 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
||||
method,
|
||||
findings_text: findingsText || null,
|
||||
root_cause_summary: rootCause || null,
|
||||
alcohol_test_result: alcoholTest || null,
|
||||
witness_statement_refs: witnessRefs.map(w => w.trim()).filter(Boolean),
|
||||
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)
|
||||
@@ -100,13 +104,13 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
||||
|
||||
let res: Response
|
||||
if (existingInvestigationId) {
|
||||
res = await fetch(`/api/incidents/${incidentId}/investigation`, {
|
||||
res = await fetch(`/ims/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`, {
|
||||
res = await fetch(`/ims/api/incidents/${incidentId}/investigation`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -207,6 +211,45 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Alcohol / Urine Test Result</label>
|
||||
<select
|
||||
value={alcoholTest}
|
||||
onChange={e => setAlcoholTest(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Not applicable / not conducted</option>
|
||||
<option value="negative">Negative</option>
|
||||
<option value="positive">Positive</option>
|
||||
<option value="refused">Refused</option>
|
||||
<option value="pending">Result pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Witness Statements</label>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Reference each statement (witness name, document ref). Upload scans as investigation-stage evidence.
|
||||
</p>
|
||||
{witnessRefs.map((ref, i) => (
|
||||
<input
|
||||
key={i}
|
||||
type="text"
|
||||
value={ref}
|
||||
onChange={e => setWitnessRefs(witnessRefs.map((w, j) => (j === i ? e.target.value : w)))}
|
||||
placeholder="e.g. Ali bin Ahmad — statement dated 12/07/2026"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm mb-1.5"
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWitnessRefs([...witnessRefs, ''])}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
+ Add witness statement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Key Findings</label>
|
||||
<textarea
|
||||
|
||||
@@ -27,6 +27,7 @@ export function OfflineSync() {
|
||||
fd.append('injury_involved', String(report.injury_involved))
|
||||
fd.append('asset_involved', String(report.asset_involved))
|
||||
if (report.medical_status) fd.append('medical_status', report.medical_status)
|
||||
if (report.type_details) fd.append('type_details', JSON.stringify(report.type_details))
|
||||
|
||||
try {
|
||||
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
href: string
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, href }: Props) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const link = (p: number, label: string, disabled: boolean) =>
|
||||
disabled ? (
|
||||
<span className="px-3 py-1.5 text-sm text-gray-300">{label}</span>
|
||||
) : (
|
||||
<Link href={`${href}?page=${p}`} className="px-3 py-1.5 text-sm text-blue-600 hover:underline">
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-between mt-4" aria-label="Pagination">
|
||||
{link(page - 1, '← Previous', page <= 1)}
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
{link(page + 1, 'Next →', page >= totalPages)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
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],
|
||||
@@ -55,6 +56,35 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
medical_status: '' as MedicalStatus | '',
|
||||
asset_involved: false,
|
||||
})
|
||||
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
|
||||
@@ -85,6 +115,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
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,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
setSavedOffline(true)
|
||||
@@ -134,6 +165,9 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
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))
|
||||
}
|
||||
files.forEach(f => fd.append('files', f))
|
||||
|
||||
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
|
||||
@@ -182,7 +216,10 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
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 }))}
|
||||
onChange={e => {
|
||||
setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))
|
||||
setTypeDetails({})
|
||||
}}
|
||||
>
|
||||
<option value="">{t.incidentTypePlaceholder}</option>
|
||||
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
|
||||
@@ -191,6 +228,32 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.descriptionLabel} <span className="text-red-500">*</span>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SimilarIncidentsPanel({ incidentId }: Props) {
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/incidents/${incidentId}/similar`)
|
||||
fetch(`/ims/api/incidents/${incidentId}/similar`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('failed')
|
||||
return r.json()
|
||||
|
||||
@@ -42,7 +42,7 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
|
||||
setAiLoading(true)
|
||||
setAiRationale(null)
|
||||
try {
|
||||
const res = await fetch(`/api/incidents/${incidentId}/ai/triage-suggest`, { method: 'POST' })
|
||||
const res = await fetch(`/ims/api/incidents/${incidentId}/ai/triage-suggest`, { method: 'POST' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json() as {
|
||||
severity: number
|
||||
@@ -69,7 +69,7 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const res = await fetch(`/api/incidents/${incidentId}/triage`, {
|
||||
const res = await fetch(`/ims/api/incidents/${incidentId}/triage`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user