From b281420810742f32f700c673bd93aceb23c8594c Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 07:40:22 +0800 Subject: [PATCH] feat: HSE dashboard with incident stats by type and site Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01AWxyMibCuGGtSQSqfajDQ7 --- app/(protected)/hse/dashboard/page.tsx | 92 +++++++++++++++++++ app/api/dashboard/stats/route.ts | 36 ++++++++ components/dashboard/stat-card.tsx | 23 +++++ tests/components/dashboard/stat-card.test.tsx | 16 ++++ 4 files changed, 167 insertions(+) create mode 100644 app/(protected)/hse/dashboard/page.tsx create mode 100644 app/api/dashboard/stats/route.ts create mode 100644 components/dashboard/stat-card.tsx create mode 100644 tests/components/dashboard/stat-card.test.tsx diff --git a/app/(protected)/hse/dashboard/page.tsx b/app/(protected)/hse/dashboard/page.tsx new file mode 100644 index 0000000..b5b9fe6 --- /dev/null +++ b/app/(protected)/hse/dashboard/page.tsx @@ -0,0 +1,92 @@ +export const dynamic = 'force-dynamic' + +import Link from 'next/link' +import { createClient } from '@/lib/supabase/server' +import { StatCard } from '@/components/dashboard/stat-card' + +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() { + const supabase = await createClient() + + const { data: incidents } = await supabase + .from('incidents') + .select('id, status, incident_type, sites (name)') + + 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 any)?.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) + + return ( +
+
+

Dashboard

+ + View all incidents → + +
+ +
+ + + +
+ +
+

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

} +
+
+
+ ) +} diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..a24b611 --- /dev/null +++ b/app/api/dashboard/stats/route.ts @@ -0,0 +1,36 @@ +export const dynamic = 'force-dynamic' + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function GET() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { data: incidents, error } = await supabase + .from('incidents') + .select('id, status, incident_type, sites (name)') + + if (error) return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 }) + + 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 any)?.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) + + return NextResponse.json({ total, open, closed, by_type, by_site }) +} diff --git a/components/dashboard/stat-card.tsx b/components/dashboard/stat-card.tsx new file mode 100644 index 0000000..b61b6c1 --- /dev/null +++ b/components/dashboard/stat-card.tsx @@ -0,0 +1,23 @@ +interface Props { + label: string + value: number + sub?: string + accent?: 'default' | 'green' | 'yellow' | 'red' +} + +const ACCENT = { + default: 'border-gray-200', + green: 'border-green-400', + yellow: 'border-yellow-400', + red: 'border-red-400', +} + +export function StatCard({ label, value, sub, accent = 'default' }: Props) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ) +} diff --git a/tests/components/dashboard/stat-card.test.tsx b/tests/components/dashboard/stat-card.test.tsx new file mode 100644 index 0000000..e882d53 --- /dev/null +++ b/tests/components/dashboard/stat-card.test.tsx @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { StatCard } from '@/components/dashboard/stat-card' + +describe('StatCard', () => { + it('renders label and value', () => { + render() + expect(screen.getByText('Total Incidents')).toBeTruthy() + expect(screen.getByText('42')).toBeTruthy() + }) + + it('renders optional sub text', () => { + render() + expect(screen.getByText('awaiting action')).toBeTruthy() + }) +})