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' import { DashboardTabs } from '@/components/dashboard/dashboard-tabs' const TYPE_LABELS: Record = { injury: 'Injury', near_miss: 'Near Miss', hazard: 'Hazard', asset_damage: 'Asset Damage', environmental: 'Environmental', security: 'Security', fire: 'Fire', } export default async function HseDashboardPage({ searchParams, }: { searchParams: Promise<{ tab?: string }> }) { const supabase = await createClient() const params = await searchParams const tab = params.tab ?? 'overview' 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), ]) const rows = incidents ?? [] const total = rows.length const closed = rows.filter(r => r.status === 'closed').length const open = total - closed const by_type: Record = {} for (const r of rows) { by_type[r.incident_type] = (by_type[r.incident_type] ?? 0) + 1 } const siteMap: Record = {} 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) 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 const zoneMap: Record = {} 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 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 const doshPendingCount = doshPendingRows?.length ?? 0 const monthly = bucketIncidentsByMonth(yearIncidents ?? [], 12, now) const monthlyMax = Math.max(1, ...monthly.map(m => m.total)) const rootCauses = topRootCauses(investigations ?? []) return (

Dashboard

Export CSV JKKP 8 All incidents →
{/* Overview tab */} {(tab === 'overview') && ( <> {now.getMonth() === 0 && (

JKKP 8 annual register due: the {now.getFullYear() - 1} register must be submitted to DOSH before 31 January {now.getFullYear()}.{' '} Download {now.getFullYear() - 1} register

)}
0 ? 'yellow' : 'green'} sub="filings outstanding" />

Leading vs Lagging — Last 30 Days

Leading: hazard + near-miss reports (predict risk) · Lagging: injuries (past harm)

{leadingCount}

Leading (Hazards + Near Misses)

{laggingCount}

Lagging (Injuries)

{capaOnTimeRate !== null && (

{capaOnTimeRate}%

CAPA On-Time Rate

)}
)} {/* Trends tab */} {tab === 'trends' && ( <>

Incident Trend — Last 12 Months

Blue: leading (hazard + near miss) · Red: lagging (injury) · Grey: other

{monthly.map(m => { const other = m.total - m.leading - m.lagging return (
{m.label} {m.total || ''}
) })}
{rootCauses.length > 0 && (

Top Root Causes

    {rootCauses.map((rc, i) => (
  1. {i + 1}. {rc.cause} {rc.count}×
  2. ))}
)} )} {/* Zones & Types tab */} {tab === 'zones' && ( <> {by_zone.length > 0 && (

Zone Incident Heatmap — Last 90 Days

{by_zone.map(({ name, count }) => (
{name}
{count}
))}
)}

By Incident Type

{Object.entries(by_type).sort((a, b) => b[1] - a[1]).map(([type, count]) => (
{TYPE_LABELS[type] ?? type}
0 ? `${(count / total) * 100}%` : '0%' }} />
{count}
))} {Object.keys(by_type).length === 0 && (

No incidents yet

)}

By Site

{by_site.map(({ name, count }) => (
{name} {count}
))} {by_site.length === 0 &&

No data

}
)} {/* AI Insights tab */} {tab === 'ai' && }
) }