Files
ims/app/(protected)/hse/dashboard/page.tsx
T
adminandClaude Fable 5 576557181a 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
2026-07-12 10:25:08 +08:00

313 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
near_miss: 'Near Miss',
hazard: 'Hazard',
asset_damage: 'Asset Damage',
environmental: 'Environmental',
security: 'Security',
fire: 'Fire',
}
export default async function HseDashboardPage() {
const supabase = await createClient()
const now = new Date()
const thirtyDaysAgo = new Date(now)
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 },
{ data: recentIncidents },
{ data: zoneIncidents },
{ data: completedCapas },
{ data: doshPendingRows },
{ data: yearIncidents },
{ data: investigations },
] = await Promise.all([
supabase.from('incidents').select('id, status, incident_type, sites (name)'),
supabase
.from('incidents')
.select('incident_type')
.gte('reported_at', thirtyDaysAgo.toISOString()),
supabase
.from('incidents')
.select('zones (name)')
.gte('reported_at', ninetyDaysAgo.toISOString())
.not('zone_id', 'is', null),
supabase
.from('capa_actions')
.select('due_date, completed_at, verified_at')
.not('completed_at', 'is', null),
supabase
.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 ---
const rows = incidents ?? []
const total = rows.length
const closed = rows.filter(r => r.status === 'closed').length
const open = total - closed
const by_type: Record<string, number> = {}
for (const r of rows) {
by_type[r.incident_type] = (by_type[r.incident_type] ?? 0) + 1
}
const siteMap: Record<string, number> = {}
for (const r of rows) {
const name = (r.sites as unknown as { name: string } | null)?.name ?? 'Unknown'
siteMap[name] = (siteMap[name] ?? 0) + 1
}
const by_site = Object.entries(siteMap)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
// --- Leading / lagging (last 30 days) ---
const recent = recentIncidents ?? []
const leadingCount = recent.filter(r => ['hazard', 'near_miss'].includes(r.incident_type)).length
const laggingCount = recent.filter(r => r.incident_type === 'injury').length
// --- Zone heatmap (last 90 days) ---
const zoneMap: Record<string, number> = {}
for (const r of zoneIncidents ?? []) {
const name = (r.zones as unknown as { name: string } | null)?.name ?? 'Unknown'
zoneMap[name] = (zoneMap[name] ?? 0) + 1
}
const by_zone = Object.entries(zoneMap)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
const zoneMax = by_zone[0]?.count ?? 1
// --- CAPA on-time rate ---
const capas = completedCapas ?? []
const onTime = capas.filter(c => {
const due = new Date(c.due_date)
const done = c.verified_at
? new Date(c.verified_at as string)
: c.completed_at
? new Date(c.completed_at as string)
: null
return done !== null && done <= due
})
const capaOnTimeRate = capas.length > 0
? Math.round((onTime.length / capas.length) * 100)
: null
// --- 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="/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} />
<StatCard label="Open" value={open} accent="yellow" sub="awaiting action" />
<StatCard label="Closed" value={closed} accent="green" sub="resolved" />
<StatCard
label="DOSH Pending"
value={doshPendingCount}
accent={doshPendingCount > 0 ? 'yellow' : 'green'}
sub="filings outstanding"
/>
</div>
{/* Leading vs lagging — last 30 days */}
<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">
Leading vs Lagging Last 30 Days
</h2>
<p className="text-xs text-gray-400 mb-4">
Leading: hazard + near-miss reports (predict risk) · Lagging: injuries (past harm)
</p>
<div className="flex gap-6">
<div className="flex-1 text-center bg-blue-50 rounded-lg p-4">
<p className="text-3xl font-bold text-blue-600">{leadingCount}</p>
<p className="text-xs text-blue-700 mt-1">Leading (Hazards + Near Misses)</p>
</div>
<div className="flex-1 text-center bg-red-50 rounded-lg p-4">
<p className="text-3xl font-bold text-red-600">{laggingCount}</p>
<p className="text-xs text-red-700 mt-1">Lagging (Injuries)</p>
</div>
{capaOnTimeRate !== null && (
<div className="flex-1 text-center bg-green-50 rounded-lg p-4">
<p className="text-3xl font-bold text-green-600">{capaOnTimeRate}%</p>
<p className="text-xs text-green-700 mt-1">CAPA On-Time Rate</p>
</div>
)}
</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">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">
Zone Incident Heatmap Last 90 Days
</h2>
<div className="space-y-2">
{by_zone.map(({ name, count }) => (
<div key={name} className="flex items-center gap-3">
<span className="text-sm text-gray-600 w-36 shrink-0 truncate">{name}</span>
<div className="flex-1 bg-gray-100 rounded-full h-3">
<div
className="h-3 rounded-full"
style={{
width: `${(count / zoneMax) * 100}%`,
backgroundColor: `hsl(${Math.round((1 - count / zoneMax) * 120)}, 70%, 50%)`,
}}
/>
</div>
<span className="text-sm font-semibold text-gray-900 w-6 text-right">{count}</span>
</div>
))}
</div>
</div>
)}
{/* Incident type breakdown */}
<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">By Incident Type</h2>
<div className="space-y-2">
{Object.entries(by_type).sort((a, b) => b[1] - a[1]).map(([type, count]) => (
<div key={type} className="flex items-center gap-3">
<span className="text-sm text-gray-600 w-32 shrink-0">{TYPE_LABELS[type] ?? type}</span>
<div className="flex-1 bg-gray-100 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: total > 0 ? `${(count / total) * 100}%` : '0%' }}
/>
</div>
<span className="text-sm font-semibold text-gray-900 w-6 text-right">{count}</span>
</div>
))}
{Object.keys(by_type).length === 0 && (
<p className="text-sm text-gray-400">No incidents yet</p>
)}
</div>
</div>
{/* By site */}
<div className="bg-white rounded-xl shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">By Site</h2>
<div className="space-y-2">
{by_site.map(({ name, count }) => (
<div key={name} className="flex items-center justify-between">
<span className="text-sm text-gray-600">{name}</span>
<span className="text-sm font-semibold text-gray-900">{count}</span>
</div>
))}
{by_site.length === 0 && <p className="text-sm text-gray-400">No data</p>}
</div>
</div>
</main>
)
}