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:
2026-07-12 10:25:08 +08:00
co-authored by Claude Fable 5
parent 98c38c3716
commit 576557181a
51 changed files with 2394 additions and 38 deletions
+64 -1
View File
@@ -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>