From 2f2018f27a5ed1a7c88687d5f2d60d90a54333e9 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 07:39:43 +0800 Subject: [PATCH] feat: incident inbox pages with filter/search for HSE and supervisor roles Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01AWxyMibCuGGtSQSqfajDQ7 --- app/(protected)/hse/incidents/page.tsx | 29 ++++ app/(protected)/hse/page.tsx | 33 ++++- app/(protected)/supervisor/incidents/page.tsx | 29 ++++ app/(protected)/supervisor/page.tsx | 28 +++- components/incidents/incident-list.tsx | 132 ++++++++++++++++++ .../incidents/incident-list.test.tsx | 53 +++++++ 6 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 app/(protected)/hse/incidents/page.tsx create mode 100644 app/(protected)/supervisor/incidents/page.tsx create mode 100644 components/incidents/incident-list.tsx create mode 100644 tests/components/incidents/incident-list.test.tsx diff --git a/app/(protected)/hse/incidents/page.tsx b/app/(protected)/hse/incidents/page.tsx new file mode 100644 index 0000000..8867ee4 --- /dev/null +++ b/app/(protected)/hse/incidents/page.tsx @@ -0,0 +1,29 @@ +export const dynamic = 'force-dynamic' + +import { createClient } from '@/lib/supabase/server' +import { IncidentList } from '@/components/incidents/incident-list' + +export default async function HseInboxPage() { + const supabase = await createClient() + + const { data: incidents } = await supabase + .from('incidents') + .select(` + id, reference_no, incident_type, status, severity, reported_at, + sites (name), + zones (name), + reporter:users!reported_by (name) + `) + .order('reported_at', { ascending: false }) + .limit(100) + + return ( +
+
+

Incident Inbox

+ {incidents?.length ?? 0} incidents +
+ +
+ ) +} diff --git a/app/(protected)/hse/page.tsx b/app/(protected)/hse/page.tsx index 88fe2dd..42bf533 100644 --- a/app/(protected)/hse/page.tsx +++ b/app/(protected)/hse/page.tsx @@ -1,9 +1,32 @@ -// app/(protected)/hse/page.tsx -export default function HSEHome() { +export const dynamic = 'force-dynamic' + +import Link from 'next/link' +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' + +export default async function HsePage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: profile } = await supabase.from('users').select('name').eq('id', user.id).single() + return ( -
-

HSE Officer Dashboard

-

Phase 1: Incident inbox + triage coming here.

+
+

HSE Officer Portal

+

Welcome, {profile?.name ?? user.email}

+
+ +
πŸ“‹
+
Incident Inbox
+
View all incidents
+ + +
πŸ“Š
+
Dashboard
+
Stats & overview
+ +
) } diff --git a/app/(protected)/supervisor/incidents/page.tsx b/app/(protected)/supervisor/incidents/page.tsx new file mode 100644 index 0000000..5206b9d --- /dev/null +++ b/app/(protected)/supervisor/incidents/page.tsx @@ -0,0 +1,29 @@ +export const dynamic = 'force-dynamic' + +import { createClient } from '@/lib/supabase/server' +import { IncidentList } from '@/components/incidents/incident-list' + +export default async function SupervisorInboxPage() { + const supabase = await createClient() + + const { data: incidents } = await supabase + .from('incidents') + .select(` + id, reference_no, incident_type, status, severity, reported_at, + sites (name), + zones (name), + reporter:users!reported_by (name) + `) + .order('reported_at', { ascending: false }) + .limit(100) + + return ( +
+
+

Incident Inbox

+ {incidents?.length ?? 0} incidents +
+ +
+ ) +} diff --git a/app/(protected)/supervisor/page.tsx b/app/(protected)/supervisor/page.tsx index a198281..21f0489 100644 --- a/app/(protected)/supervisor/page.tsx +++ b/app/(protected)/supervisor/page.tsx @@ -1,9 +1,27 @@ -// app/(protected)/supervisor/page.tsx -export default function SupervisorHome() { +export const dynamic = 'force-dynamic' + +import Link from 'next/link' +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' + +export default async function SupervisorPage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: profile } = await supabase.from('users').select('name').eq('id', user.id).single() + return ( -
-

Supervisor Dashboard

-

Phase 1: Incident inbox coming here.

+
+

Supervisor Portal

+

Welcome, {profile?.name ?? user.email}

+
+ +
πŸ“‹
+
Incident Inbox
+
View site incidents
+ +
) } diff --git a/components/incidents/incident-list.tsx b/components/incidents/incident-list.tsx new file mode 100644 index 0000000..45071bd --- /dev/null +++ b/components/incidents/incident-list.tsx @@ -0,0 +1,132 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' + +const TYPE_LABELS: Record = { + injury: 'Injury / Medical', + near_miss: 'Near Miss', + hazard: 'Hazard', + asset_damage: 'Asset Damage', + environmental: 'Environmental', + security: 'Security', + fire: 'Fire / Emergency', +} + +const STATUS_COLORS: Record = { + reported: 'bg-yellow-100 text-yellow-800', + triaged: 'bg-blue-100 text-blue-800', + investigating: 'bg-purple-100 text-purple-800', + capa_pending: 'bg-orange-100 text-orange-800', + verification: 'bg-indigo-100 text-indigo-800', + closed: 'bg-green-100 text-green-800', +} + +type Incident = { + id: string + reference_no: string | null + incident_type: string + status: string + severity: number | null + reported_at: string + sites: { name: string } | null + zones: { name: string } | null + reporter: { name: string } | null +} + +interface Props { + incidents: Incident[] + basePath: string +} + +export function IncidentList({ incidents, basePath }: Props) { + const [typeFilter, setTypeFilter] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [search, setSearch] = useState('') + + const filtered = incidents.filter(inc => { + if (typeFilter && inc.incident_type !== typeFilter) return false + if (statusFilter && inc.status !== statusFilter) return false + if (search) { + const q = search.toLowerCase() + return ( + inc.reference_no?.toLowerCase().includes(q) || + inc.incident_type.includes(q) || + (inc.sites?.name ?? '').toLowerCase().includes(q) + ) + } + return true + }) + + return ( +
+
+ setSearch(e.target.value)} + /> + + +
+ + {filtered.length === 0 ? ( +
No incidents found
+ ) : ( +
+ {filtered.map(inc => ( + +
+
+ + {inc.reference_no ?? 'β€”'} + + + {inc.status.replace(/_/g, ' ').toUpperCase()} + +
+
+ {TYPE_LABELS[inc.incident_type] ?? inc.incident_type} + {inc.zones?.name && ` Β· ${inc.zones.name}`} + {inc.sites?.name && ` Β· ${inc.sites.name}`} +
+
+
+
+ {new Date(inc.reported_at).toLocaleDateString('en-MY')} +
+ {inc.severity && ( +
Sev {inc.severity}
+ )} +
+ + ))} +
+ )} +
+ ) +} diff --git a/tests/components/incidents/incident-list.test.tsx b/tests/components/incidents/incident-list.test.tsx new file mode 100644 index 0000000..8979a00 --- /dev/null +++ b/tests/components/incidents/incident-list.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { IncidentList } from '@/components/incidents/incident-list' + +const mockIncidents = [ + { + id: 'inc-001', + reference_no: 'SCW1-202607-0001', + incident_type: 'near_miss', + status: 'reported', + severity: null, + reported_at: '2026-07-10T09:00:00Z', + sites: { name: 'SCW1' }, + zones: { name: 'Dock A' }, + reporter: { name: 'John Doe' }, + }, + { + id: 'inc-002', + reference_no: 'SCW1-202607-0002', + incident_type: 'injury', + status: 'triaged', + severity: 3, + reported_at: '2026-07-10T10:00:00Z', + sites: { name: 'SCW1' }, + zones: { name: 'Loading Bay' }, + reporter: { name: 'Jane Smith' }, + }, +] + +describe('IncidentList', () => { + it('renders all incident rows', () => { + render() + expect(screen.getByText('SCW1-202607-0001')).toBeTruthy() + expect(screen.getByText('SCW1-202607-0002')).toBeTruthy() + }) + + it('shows incident type labels', () => { + render() + expect(screen.getByText('Near Miss')).toBeTruthy() + expect(screen.getByText('Injury / Medical')).toBeTruthy() + }) + + it('shows status badges', () => { + render() + expect(screen.getByText('REPORTED')).toBeTruthy() + expect(screen.getByText('TRIAGED')).toBeTruthy() + }) + + it('renders empty state when no incidents', () => { + render() + expect(screen.getByText(/no incidents/i)).toBeTruthy() + }) +})