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
+86 -1
View File
@@ -3,6 +3,8 @@ export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { StatCard } from '@/components/dashboard/stat-card'
import { bucketIncidentsByMonth, topRootCauses } from '@/lib/dashboard/trends'
import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel'
const TYPE_LABELS: Record<string, string> = {
injury: 'Injury',
@@ -22,6 +24,7 @@ export default async function HseDashboardPage() {
thirtyDaysAgo.setDate(now.getDate() - 30)
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(now.getDate() - 90)
const twelveMonthsAgo = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1))
const [
{ data: incidents },
@@ -29,6 +32,8 @@ export default async function HseDashboardPage() {
{ data: zoneIncidents },
{ data: completedCapas },
{ data: doshPendingRows },
{ data: yearIncidents },
{ data: investigations },
] = await Promise.all([
supabase.from('incidents').select('id, status, incident_type, sites (name)'),
supabase
@@ -48,6 +53,14 @@ export default async function HseDashboardPage() {
.from('dosh_reports')
.select('id')
.eq('status', 'pending'),
supabase
.from('incidents')
.select('reported_at, incident_type')
.gte('reported_at', twelveMonthsAgo.toISOString()),
supabase
.from('investigations')
.select('root_cause_summary')
.not('root_cause_summary', 'is', null),
])
// --- Existing metrics ---
@@ -105,23 +118,47 @@ export default async function HseDashboardPage() {
// --- DOSH pending filings ---
const doshPendingCount = doshPendingRows?.length ?? 0
// --- 12-month trend + top root causes ---
const monthly = bucketIncidentsByMonth(yearIncidents ?? [], 12, now)
const monthlyMax = Math.max(1, ...monthly.map(m => m.total))
const rootCauses = topRootCauses(investigations ?? [])
return (
<main className="max-w-4xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<div className="flex gap-3">
<a
href="/api/dashboard/export?role=hse"
href="/ims/api/dashboard/export?role=hse"
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
>
Export CSV
</a>
<a
href={`/ims/api/reports/jkkp8?year=${now.getFullYear()}`}
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
>
JKKP 8 Register
</a>
<Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
View all incidents
</Link>
</div>
</div>
{/* JKKP 8 statutory deadline reminder — register due to DOSH before 31 January */}
{now.getMonth() === 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
<p className="text-sm text-amber-800">
<strong>JKKP 8 annual register due:</strong> the {now.getFullYear() - 1} register must be
submitted to DOSH before 31 January {now.getFullYear()}.{' '}
<a href={`/ims/api/reports/jkkp8?year=${now.getFullYear() - 1}`} className="underline font-medium">
Download {now.getFullYear() - 1} register
</a>
</p>
</div>
)}
{/* Summary stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
<StatCard label="Total Incidents" value={total} />
@@ -161,6 +198,54 @@ export default async function HseDashboardPage() {
</div>
</div>
<RiskFlagsPanel />
{/* 12-month incident trend */}
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-1">
Incident Trend Last 12 Months
</h2>
<p className="text-xs text-gray-400 mb-4">
Blue: leading (hazard + near miss) · Red: lagging (injury) · Grey: other
</p>
<div className="flex items-end gap-1 h-32">
{monthly.map(m => {
const other = m.total - m.leading - m.lagging
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full flex flex-col-reverse" style={{ height: '100px' }}>
<div className="w-full bg-blue-400" style={{ height: `${(m.leading / monthlyMax) * 100}px` }} />
<div className="w-full bg-red-400" style={{ height: `${(m.lagging / monthlyMax) * 100}px` }} />
<div className="w-full bg-gray-300" style={{ height: `${(Math.max(0, other) / monthlyMax) * 100}px` }} />
</div>
<span className="text-[10px] text-gray-400">{m.label}</span>
<span className="text-[10px] font-semibold text-gray-600">{m.total || ''}</span>
</div>
)
})}
</div>
</div>
{/* Top root causes */}
{rootCauses.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">
Top Root Causes
</h2>
<ol className="space-y-2">
{rootCauses.map((rc, i) => (
<li key={rc.cause} className="flex items-start justify-between gap-3">
<span className="text-sm text-gray-700">
<span className="text-gray-400 mr-2">{i + 1}.</span>
{rc.cause}
</span>
<span className="text-sm font-semibold text-gray-900 shrink-0">{rc.count}×</span>
</li>
))}
</ol>
</div>
)}
{/* Zone heatmap — last 90 days */}
{by_zone.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">