Files
ims/app/(protected)/hse/dashboard/page.tsx
T
adminandClaude Sonnet 4.6 f591c0be18 feat(db): phase 4 group 6 — server component pages to Drizzle
Converts all 18 server component page files from Supabase client queries
to Drizzle ORM using asAdmin. Adds getSession() + redirect to the three
pages (hse/incidents, hse/incidents/[id], hse/dashboard) that lacked it.
Maps snake_case component prop shapes explicitly where required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 17:29:22 +08:00

355 lines
14 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 { asAdmin } from '@/lib/db/with-user'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session'
import { incidents, sites, zones, capaActions, doshReports, investigations } from '@/lib/db/schema'
import { eq, gte, isNotNull } from 'drizzle-orm'
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<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({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>
}) {
const session = await getSession()
if (!session) redirect('/login')
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 [
allIncidents,
recentIncidents,
zoneIncidents,
completedCapas,
doshPendingRows,
yearIncidents,
investigationRows,
] = await Promise.all([
asAdmin(db =>
db.select({
id: incidents.id,
status: incidents.status,
incidentType: incidents.incidentType,
siteName: sites.name,
})
.from(incidents)
.leftJoin(sites, eq(incidents.siteId, sites.id))
),
asAdmin(db =>
db.select({ incidentType: incidents.incidentType })
.from(incidents)
.where(gte(incidents.reportedAt, thirtyDaysAgo))
),
asAdmin(db =>
db.select({ zoneName: zones.name })
.from(incidents)
.leftJoin(zones, eq(incidents.zoneId, zones.id))
.where(gte(incidents.reportedAt, ninetyDaysAgo))
).then(rows => rows.filter(r => r.zoneName !== null)),
asAdmin(db =>
db.select({
dueDate: capaActions.dueDate,
completedAt: capaActions.completedAt,
verifiedAt: capaActions.verifiedAt,
})
.from(capaActions)
.where(isNotNull(capaActions.completedAt))
),
asAdmin(db =>
db.select({ id: doshReports.id })
.from(doshReports)
.where(eq(doshReports.status, 'pending'))
),
asAdmin(db =>
db.select({
reportedAt: incidents.reportedAt,
incidentType: incidents.incidentType,
})
.from(incidents)
.where(gte(incidents.reportedAt, twelveMonthsAgo))
),
asAdmin(db =>
db.select({ rootCauseSummary: investigations.rootCauseSummary })
.from(investigations)
.where(isNotNull(investigations.rootCauseSummary))
),
])
const rows = allIncidents
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.incidentType] = (by_type[r.incidentType] ?? 0) + 1
}
const siteMap: Record<string, number> = {}
for (const r of rows) {
const name = r.siteName ?? '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 leadingCount = recentIncidents.filter(r => ['hazard', 'near_miss'].includes(r.incidentType)).length
const laggingCount = recentIncidents.filter(r => r.incidentType === 'injury').length
const zoneMap: Record<string, number> = {}
for (const r of zoneIncidents) {
const name = r.zoneName ?? '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.dueDate)
const done = c.verifiedAt
? new Date(c.verifiedAt)
: c.completedAt
? new Date(c.completedAt)
: null
return done !== null && done <= due
})
const capaOnTimeRate = capas.length > 0
? Math.round((onTime.length / capas.length) * 100)
: null
const doshPendingCount = doshPendingRows.length
const monthly = bucketIncidentsByMonth(
yearIncidents.map(r => ({ reported_at: r.reportedAt.toISOString(), incident_type: r.incidentType })),
12,
now
)
const monthlyMax = Math.max(1, ...monthly.map(m => m.total))
const rootCauses = topRootCauses(
investigationRows.map(r => ({ root_cause_summary: r.rootCauseSummary }))
)
return (
<main className="max-w-4xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-4">
<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
</a>
<Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
All incidents
</Link>
</div>
</div>
<DashboardTabs activeTab={tab} />
{/* Overview tab */}
{(tab === 'overview') && (
<>
{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>
)}
<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>
<div className="bg-white rounded-xl shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 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-4">
<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>
</>
)}
{/* Trends tab */}
{tab === 'trends' && (
<>
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
<h2 className="text-sm font-semibold text-gray-900 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-48">
{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: '192px' }}>
<div className="w-full bg-blue-400" style={{ height: `${(m.leading / monthlyMax) * 192}px` }} />
<div className="w-full bg-red-400" style={{ height: `${(m.lagging / monthlyMax) * 192}px` }} />
<div className="w-full bg-gray-300" style={{ height: `${(Math.max(0, other) / monthlyMax) * 192}px` }} />
</div>
<span className="text-xs text-gray-400">{m.label}</span>
<span className="text-xs font-semibold text-gray-600">{m.total || ''}</span>
</div>
)
})}
</div>
</div>
{rootCauses.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 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>
)}
</>
)}
{/* Zones & Types tab */}
{tab === 'zones' && (
<>
{by_zone.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
<h2 className="text-sm font-semibold text-gray-900 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(220, 70%, ${Math.max(30, 80 - Math.round((count / zoneMax) * 50))}%)`,
}}
/>
</div>
<span className="text-sm font-semibold text-gray-900 w-6 text-right">{count}</span>
</div>
))}
</div>
</div>
)}
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
<h2 className="text-sm font-semibold text-gray-900 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>
<div className="bg-white rounded-xl shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 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>
</>
)}
{/* AI Insights tab */}
{tab === 'ai' && <RiskFlagsPanel />}
</main>
)
}