diff --git a/app/(protected)/hse/incidents/[id]/page.tsx b/app/(protected)/hse/incidents/[id]/page.tsx new file mode 100644 index 0000000..1895e0c --- /dev/null +++ b/app/(protected)/hse/incidents/[id]/page.tsx @@ -0,0 +1,41 @@ +export const dynamic = 'force-dynamic' + +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/server' +import { IncidentDetail } from '@/components/incidents/incident-detail' + +interface Props { + params: Promise<{ id: string }> +} + +export default async function HseIncidentDetailPage({ params }: Props) { + const { id } = await params + const supabase = await createClient() + + const { data: incident, error } = await supabase + .from('incidents') + .select(` + id, reference_no, incident_type, description, severity, status, + injury_involved, asset_involved, medical_status, lost_days, + reported_at, closed_at, + sites (id, name), + zones (id, name), + reporter:users!reported_by (id, name, email), + evidence_files (id, stage, file_url, file_type, uploaded_at) + `) + .eq('id', id) + .eq('evidence_files.deleted', false) + .single() + + if (error || !incident) notFound() + + return ( +
+ + ← Back to inbox + + +
+ ) +} diff --git a/app/(protected)/supervisor/incidents/[id]/page.tsx b/app/(protected)/supervisor/incidents/[id]/page.tsx new file mode 100644 index 0000000..fc6b0e0 --- /dev/null +++ b/app/(protected)/supervisor/incidents/[id]/page.tsx @@ -0,0 +1,41 @@ +export const dynamic = 'force-dynamic' + +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/server' +import { IncidentDetail } from '@/components/incidents/incident-detail' + +interface Props { + params: Promise<{ id: string }> +} + +export default async function SupervisorIncidentDetailPage({ params }: Props) { + const { id } = await params + const supabase = await createClient() + + const { data: incident, error } = await supabase + .from('incidents') + .select(` + id, reference_no, incident_type, description, severity, status, + injury_involved, asset_involved, medical_status, lost_days, + reported_at, closed_at, + sites (id, name), + zones (id, name), + reporter:users!reported_by (id, name, email), + evidence_files (id, stage, file_url, file_type, uploaded_at) + `) + .eq('id', id) + .eq('evidence_files.deleted', false) + .single() + + if (error || !incident) notFound() + + return ( +
+ + ← Back to inbox + + +
+ ) +} diff --git a/components/incidents/evidence-gallery.tsx b/components/incidents/evidence-gallery.tsx new file mode 100644 index 0000000..4887439 --- /dev/null +++ b/components/incidents/evidence-gallery.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { EvidenceStage } from '@/lib/supabase/storage' + +type EvidenceFile = { + id: string + stage: string + file_url: string + file_type: string + uploaded_at: string +} + +interface Props { + files: EvidenceFile[] + stage?: EvidenceStage +} + +function isImage(type: string) { return type.startsWith('image/') } +function isVideo(type: string) { return type.startsWith('video/') } + +export function EvidenceGallery({ files, stage }: Props) { + const filtered = stage ? files.filter(f => f.stage === stage) : files + if (filtered.length === 0) return

No files for this stage

+ + return ( +
+ {filtered.map(file => ( + + {isImage(file.file_type) ? ( + Evidence + ) : isVideo(file.file_type) ? ( +
🎥
+ ) : ( +
📄
+ )} +
+ ))} +
+ ) +} diff --git a/components/incidents/incident-detail.tsx b/components/incidents/incident-detail.tsx new file mode 100644 index 0000000..f37aa92 --- /dev/null +++ b/components/incidents/incident-detail.tsx @@ -0,0 +1,116 @@ +import { EvidenceGallery } from './evidence-gallery' + +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', +} + +const MEDICAL_LABELS: Record = { + none: 'None', + first_aid: 'First Aid', + medical_treatment: 'Medical Treatment', + lti: 'Lost Time Injury (LTI)', +} + +type Incident = { + id: string + reference_no: string | null + incident_type: string + description: string + status: string + severity: number | null + injury_involved: boolean + asset_involved: boolean + medical_status: string | null + lost_days: number | null + reported_at: string + closed_at: string | null + sites: { id: string; name: string } | null + zones: { id: string; name: string } | null + reporter: { id: string; name: string; email: string } | null + evidence_files: Array<{ + id: string + stage: string + file_url: string + file_type: string + uploaded_at: string + }> +} + +interface Props { + incident: Incident +} + +function Field({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value ?? '—'}
+
+ ) +} + +export function IncidentDetail({ incident }: Props) { + const reportStageFiles = incident.evidence_files.filter(f => f.stage === 'report') + + return ( +
+
+
+
+

+ {incident.reference_no ?? 'Pending reference'} +

+

{TYPE_LABELS[incident.incident_type] ?? incident.incident_type}

+
+ + {incident.status.replace(/_/g, ' ').toUpperCase()} + +
+ +
+ + + + + + {incident.injury_involved && ( + + )} + {incident.injury_involved && incident.lost_days != null && ( + + )} +
+
+ +
+

Description

+

{incident.description}

+
+ +
+

+ Evidence — Report Stage + + {reportStageFiles.length} file{reportStageFiles.length !== 1 ? 's' : ''} + +

+ +
+
+ ) +} diff --git a/tests/components/incidents/incident-detail.test.tsx b/tests/components/incidents/incident-detail.test.tsx new file mode 100644 index 0000000..e46eb3d --- /dev/null +++ b/tests/components/incidents/incident-detail.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { IncidentDetail } from '@/components/incidents/incident-detail' + +const mockIncident = { + id: 'inc-001', + reference_no: 'SCW1-202607-0001', + incident_type: 'near_miss', + description: 'Forklift nearly hit a pedestrian in Dock A aisle near the charging station.', + status: 'reported', + severity: null, + injury_involved: false, + asset_involved: false, + medical_status: null, + lost_days: null, + reported_at: '2026-07-10T09:00:00Z', + closed_at: null, + sites: { id: 'site-001', name: 'SCW1' }, + zones: { id: 'zone-001', name: 'Dock A' }, + reporter: { id: 'user-001', name: 'John Doe', email: 'john@example.com' }, + evidence_files: [ + { + id: 'ev-001', + stage: 'report', + file_url: 'https://example.com/photo.jpg', + file_type: 'image/jpeg', + uploaded_at: '2026-07-10T09:01:00Z', + }, + ], +} + +describe('IncidentDetail', () => { + it('renders reference number', () => { + render() + expect(screen.getByText('SCW1-202607-0001')).toBeTruthy() + }) + + it('renders description', () => { + render() + expect(screen.getByText(/Forklift nearly hit/)).toBeTruthy() + }) + + it('renders reported by', () => { + render() + expect(screen.getByText(/John Doe/)).toBeTruthy() + }) + + it('renders evidence count', () => { + render() + expect(screen.getByText(/1 file/i)).toBeTruthy() + }) +})