Files
ims/docs/superpowers/plans/2026-07-10-phase-1-core-reporting.md

76 KiB

IMS Phase 1 — MVP Core Reporting Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the incident reporting MVP — QR-triggered mobile form, photo/video upload, email notifications, incident inbox, detail page, and basic dashboard — replacing WhatsApp/Excel reporting.

Architecture: Reporter scans zone QR → /report?zone=<token> → authenticated form submits to /api/incidents (server action) which writes to Supabase DB + uploads files to Supabase Storage + fires Resend email. Supervisor/HSE view incidents in inbox and detail pages. Dashboard shows aggregate counts from DB.

Tech Stack: Next.js 15 App Router, TypeScript, Supabase (Postgres + Storage + RLS), Resend (email), Vitest + React Testing Library

Prerequisites (do before Task 1)

  1. Sign up at resend.com → create API key → add to .env.local:

    RESEND_API_KEY=re_xxxxxxxxxxxx
    RESEND_FROM_EMAIL=onboarding@resend.dev
    

    (Use onboarding@resend.dev until a verified domain is set up — Resend allows this for testing.)

  2. Add NEXT_PUBLIC_SITE_URL to .env.local:

    NEXT_PUBLIC_SITE_URL=http://localhost:3000
    

Global Constraints

  • Next.js 15 App Router; all pages use export const dynamic = 'force-dynamic' if they call Supabase
  • @supabase/ssr: createBrowserClient (client) + createServerClient + await cookies() (server) — import from @/lib/supabase/client and @/lib/supabase/server
  • All Claude API and Resend calls: server-side only (/app/api/... routes), never in client components
  • RLS enforced at DB level on every table — never use service-role key in app code
  • Evidence files: NEVER hard-delete; deleted BOOLEAN DEFAULT false already in schema
  • Incident records lock on closure (closed_at IS NOT NULL) — no further edits
  • Every DB mutation writes to audit_log via SELECT public.write_audit_log(...) (SECURITY DEFINER function from migration 009)
  • Incident reference format: SITE-YYYYMM-#### — auto-generated by Postgres trigger, never set manually
  • File upload max: photos/docs 10MB, videos 200MB
  • Accepted MIME types: image/jpeg, image/png, image/heic, video/mp4, video/quicktime, application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document
  • Test runner: npx vitest run — uses vitest.config.ts with jsdom environment
  • Tailwind CSS only for styling — no shadcn/ui, no component libraries
  • Commits: feat:, fix:, test: prefixes; one commit per task minimum

Task 1: Supabase Storage + FileUpload component

Files:

  • Create: supabase/migrations/20260710000010_storage.sql
  • Create: lib/supabase/storage.ts
  • Create: components/incidents/file-upload.tsx
  • Test: tests/lib/supabase/storage.test.ts

Interfaces:

  • Produces:

    • uploadEvidenceFile(supabase, file: File, incidentId: string, stage: EvidenceStage): Promise<{ path: string; publicUrl: string; hash: string }>
    • getEvidenceUrl(supabase, path: string): string
    • EvidenceStage = 'report' | 'response' | 'investigation' | 'capa' | 'verification'
    • <FileUpload stage={EvidenceStage} onFilesChange={(files: File[]) => void} /> (client component)
  • Step 1: Write the storage migration

Create supabase/migrations/20260710000010_storage.sql:

-- Create evidence storage bucket (private)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'evidence',
  'evidence',
  false,
  209715200, -- 200MB
  ARRAY[
    'image/jpeg', 'image/png', 'image/heic', 'image/webp',
    'video/mp4', 'video/quicktime',
    'application/pdf',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
  ]
)
ON CONFLICT (id) DO NOTHING;

-- RLS on storage.objects for the evidence bucket
CREATE POLICY "evidence_upload_authenticated"
  ON storage.objects FOR INSERT
  TO authenticated
  WITH CHECK (bucket_id = 'evidence');

CREATE POLICY "evidence_read_uploader"
  ON storage.objects FOR SELECT
  TO authenticated
  USING (
    bucket_id = 'evidence'
    AND auth.uid()::text = (storage.foldername(name))[1]
  );

CREATE POLICY "evidence_read_elevated"
  ON storage.objects FOR SELECT
  TO authenticated
  USING (
    bucket_id = 'evidence'
    AND public.auth_user_role() IN ('hse', 'admin', 'management', 'supervisor')
  );
  • Step 2: Apply the storage migration via Supabase API
curl -s -X POST \
  "https://api.supabase.com/v1/projects/nkcfjbgappslicotwopl/database/query" \
  -H "Authorization: Bearer sbp_09aac663c6ed68a78559ff40694b82ce8c02d959" \
  -H "Content-Type: application/json" \
  -d "{\"query\": $(cat supabase/migrations/20260710000010_storage.sql | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')}"

Expected: [] (empty array = success)

  • Step 3: Write failing tests for storage helpers

Create tests/lib/supabase/storage.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { uploadEvidenceFile, getEvidenceUrl } from '@/lib/supabase/storage'

const mockUpload = vi.fn()
const mockGetPublicUrl = vi.fn()
const mockSupabase = {
  storage: {
    from: vi.fn(() => ({
      upload: mockUpload,
      getPublicUrl: mockGetPublicUrl,
    })),
  },
  auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-123' } } }) },
} as any

beforeEach(() => {
  vi.clearAllMocks()
  mockUpload.mockResolvedValue({ data: { path: 'user-123/incident-abc/report/photo.jpg' }, error: null })
  mockGetPublicUrl.mockReturnValue({ data: { publicUrl: 'https://example.com/photo.jpg' } })
})

describe('uploadEvidenceFile', () => {
  it('uploads to path user-id/incident-id/stage/filename', async () => {
    const file = new File(['content'], 'photo.jpg', { type: 'image/jpeg' })
    const result = await uploadEvidenceFile(mockSupabase, file, 'incident-abc', 'report')
    expect(mockSupabase.storage.from).toHaveBeenCalledWith('evidence')
    expect(mockUpload).toHaveBeenCalledWith(
      expect.stringContaining('user-123/incident-abc/report/'),
      file,
      expect.objectContaining({ contentType: 'image/jpeg', upsert: false })
    )
    expect(result.path).toContain('user-123/incident-abc/report/')
    expect(result.hash).toBeTruthy()
  })

  it('throws on upload error', async () => {
    mockUpload.mockResolvedValue({ data: null, error: { message: 'Bucket not found' } })
    const file = new File(['x'], 'f.jpg', { type: 'image/jpeg' })
    await expect(uploadEvidenceFile(mockSupabase, file, 'inc', 'report')).rejects.toThrow('Bucket not found')
  })
})

describe('getEvidenceUrl', () => {
  it('returns signed public URL', () => {
    const url = getEvidenceUrl(mockSupabase, 'user-123/incident-abc/report/photo.jpg')
    expect(mockSupabase.storage.from).toHaveBeenCalledWith('evidence')
    expect(url).toBe('https://example.com/photo.jpg')
  })
})
  • Step 4: Run tests — expect FAIL
npx vitest run tests/lib/supabase/storage.test.ts

Expected: FAIL — Cannot find module '@/lib/supabase/storage'

  • Step 5: Implement storage helpers

Create lib/supabase/storage.ts:

import type { SupabaseClient } from '@supabase/supabase-js'

export type EvidenceStage = 'report' | 'response' | 'investigation' | 'capa' | 'verification'

export async function uploadEvidenceFile(
  supabase: SupabaseClient,
  file: File,
  incidentId: string,
  stage: EvidenceStage,
): Promise<{ path: string; publicUrl: string; hash: string }> {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const ext = file.name.split('.').pop() ?? 'bin'
  const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
  const path = `${user.id}/${incidentId}/${stage}/${filename}`

  const hash = await computeHash(file)

  const { data, error } = await supabase.storage
    .from('evidence')
    .upload(path, file, { contentType: file.type, upsert: false })

  if (error) throw new Error(error.message)

  const { data: urlData } = supabase.storage.from('evidence').getPublicUrl(data.path)

  return { path: data.path, publicUrl: urlData.publicUrl, hash }
}

export function getEvidenceUrl(supabase: SupabaseClient, path: string): string {
  const { data } = supabase.storage.from('evidence').getPublicUrl(path)
  return data.publicUrl
}

async function computeHash(file: File): Promise<string> {
  const buffer = await file.arrayBuffer()
  const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
}
  • Step 6: Run tests — expect PASS
npx vitest run tests/lib/supabase/storage.test.ts

Expected: PASS — 3 tests pass

  • Step 7: Implement FileUpload component

Create components/incidents/file-upload.tsx:

'use client'

import { useRef, useState } from 'react'
import type { EvidenceStage } from '@/lib/supabase/storage'

const ACCEPTED = [
  'image/jpeg', 'image/png', 'image/heic', 'image/webp',
  'video/mp4', 'video/quicktime',
  'application/pdf',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
].join(',')

const MAX_SIZE = {
  'video/mp4': 200 * 1024 * 1024,
  'video/quicktime': 200 * 1024 * 1024,
  default: 10 * 1024 * 1024,
}

interface Props {
  stage: EvidenceStage
  onFilesChange: (files: File[]) => void
  disabled?: boolean
}

export function FileUpload({ stage, onFilesChange, disabled = false }: Props) {
  const inputRef = useRef<HTMLInputElement>(null)
  const [files, setFiles] = useState<File[]>([])
  const [errors, setErrors] = useState<string[]>([])
  const [dragging, setDragging] = useState(false)

  function validate(incoming: File[]): { valid: File[]; errs: string[] } {
    const valid: File[] = []
    const errs: string[] = []
    for (const f of incoming) {
      const limit = MAX_SIZE[f.type as keyof typeof MAX_SIZE] ?? MAX_SIZE.default
      if (f.size > limit) {
        errs.push(`${f.name}: exceeds ${limit / 1024 / 1024}MB limit`)
      } else {
        valid.push(f)
      }
    }
    return { valid, errs }
  }

  function addFiles(incoming: File[]) {
    const { valid, errs } = validate(incoming)
    const next = [...files, ...valid]
    setFiles(next)
    setErrors(errs)
    onFilesChange(next)
  }

  function remove(index: number) {
    const next = files.filter((_, i) => i !== index)
    setFiles(next)
    onFilesChange(next)
  }

  return (
    <div className="space-y-2">
      <div
        className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
          ${dragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}
          ${disabled ? 'opacity-50 pointer-events-none' : ''}`}
        onClick={() => inputRef.current?.click()}
        onDragOver={e => { e.preventDefault(); setDragging(true) }}
        onDragLeave={() => setDragging(false)}
        onDrop={e => {
          e.preventDefault()
          setDragging(false)
          addFiles(Array.from(e.dataTransfer.files))
        }}
      >
        <p className="text-sm text-gray-600">
          Tap to add photos / videos / documents
        </p>
        <p className="text-xs text-gray-400 mt-1">
          Photos/docs up to 10MB · Videos up to 200MB
        </p>
      </div>

      <input
        ref={inputRef}
        type="file"
        multiple
        accept={ACCEPTED}
        className="hidden"
        onChange={e => addFiles(Array.from(e.target.files ?? []))}
        disabled={disabled}
      />

      {errors.map((err, i) => (
        <p key={i} className="text-sm text-red-600">{err}</p>
      ))}

      {files.length > 0 && (
        <ul className="space-y-1">
          {files.map((f, i) => (
            <li key={i} className="flex items-center justify-between bg-gray-50 rounded px-3 py-2 text-sm">
              <span className="truncate max-w-xs">{f.name}</span>
              <button
                type="button"
                onClick={() => remove(i)}
                className="ml-2 text-red-500 hover:text-red-700 text-xs"
                disabled={disabled}
              >
                Remove
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
  • Step 8: Commit
git add supabase/migrations/20260710000010_storage.sql lib/supabase/storage.ts components/incidents/file-upload.tsx tests/lib/supabase/storage.test.ts
git commit -m "feat: Supabase Storage setup, upload helpers, FileUpload component"

Task 2: Incident report form + API route + middleware update

Files:

  • Modify: middleware.ts (add /report shared-route exception + redirect preservation)
  • Create: lib/incidents/validate.ts
  • Create: app/api/incidents/route.ts
  • Create: app/api/incidents/[id]/route.ts
  • Create: app/report/page.tsx
  • Create: components/incidents/report-form.tsx
  • Test: tests/lib/incidents/validate.test.ts
  • Test: tests/api/incidents.test.ts

Interfaces:

  • Consumes: uploadEvidenceFile from @/lib/supabase/storage, FileUpload from @/components/incidents/file-upload

  • Produces:

    • POST /api/incidents body: { zone_token: string; incident_type: IncidentType; description: string; injury_involved: boolean; medical_status?: MedicalStatus; asset_involved: boolean } + multipart files
    • POST /api/incidents response: { id: string; reference_no: string }
    • GET /api/incidents/[id] response: IncidentWithEvidence
    • IncidentType = 'injury' | 'near_miss' | 'hazard' | 'asset_damage' | 'environmental' | 'security' | 'fire'
    • MedicalStatus = 'none' | 'first_aid' | 'medical_treatment' | 'lti'
  • Step 1: Write failing validation tests

Create tests/lib/incidents/validate.test.ts:

import { describe, it, expect } from 'vitest'
import { validateIncidentInput, type IncidentInput } from '@/lib/incidents/validate'

const valid: IncidentInput = {
  zone_token: 'scw1-dock-a-qr-2026',
  incident_type: 'near_miss',
  description: 'Forklift nearly hit a pedestrian in Dock A aisle.',
  injury_involved: false,
  asset_involved: false,
}

describe('validateIncidentInput', () => {
  it('accepts valid input', () => {
    expect(validateIncidentInput(valid)).toEqual({ ok: true, errors: [] })
  })

  it('rejects missing zone_token', () => {
    const result = validateIncidentInput({ ...valid, zone_token: '' })
    expect(result.ok).toBe(false)
    expect(result.errors).toContain('zone_token is required')
  })

  it('rejects description under 10 chars', () => {
    const result = validateIncidentInput({ ...valid, description: 'Short' })
    expect(result.ok).toBe(false)
    expect(result.errors).toContain('description must be at least 10 characters')
  })

  it('requires medical_status when injury_involved is true', () => {
    const result = validateIncidentInput({ ...valid, injury_involved: true })
    expect(result.ok).toBe(false)
    expect(result.errors).toContain('medical_status is required when injury is involved')
  })

  it('accepts injury with medical_status', () => {
    const result = validateIncidentInput({ ...valid, injury_involved: true, medical_status: 'first_aid' })
    expect(result.ok).toBe(true)
  })

  it('rejects invalid incident_type', () => {
    const result = validateIncidentInput({ ...valid, incident_type: 'explosion' as any })
    expect(result.ok).toBe(false)
    expect(result.errors).toContain('incident_type is invalid')
  })
})
  • Step 2: Run tests — expect FAIL
npx vitest run tests/lib/incidents/validate.test.ts

Expected: FAIL — Cannot find module '@/lib/incidents/validate'

  • Step 3: Implement validation

Create lib/incidents/validate.ts:

export const INCIDENT_TYPES = ['injury', 'near_miss', 'hazard', 'asset_damage', 'environmental', 'security', 'fire'] as const
export const MEDICAL_STATUSES = ['none', 'first_aid', 'medical_treatment', 'lti'] as const

export type IncidentType = typeof INCIDENT_TYPES[number]
export type MedicalStatus = typeof MEDICAL_STATUSES[number]

export interface IncidentInput {
  zone_token: string
  incident_type: IncidentType
  description: string
  injury_involved: boolean
  medical_status?: MedicalStatus
  asset_involved: boolean
}

export function validateIncidentInput(input: IncidentInput): { ok: boolean; errors: string[] } {
  const errors: string[] = []

  if (!input.zone_token?.trim()) errors.push('zone_token is required')
  if (input.description.trim().length < 10) errors.push('description must be at least 10 characters')
  if (!INCIDENT_TYPES.includes(input.incident_type)) errors.push('incident_type is invalid')
  if (input.injury_involved && !input.medical_status) errors.push('medical_status is required when injury is involved')
  if (input.medical_status && !MEDICAL_STATUSES.includes(input.medical_status)) errors.push('medical_status is invalid')

  return { ok: errors.length === 0, errors }
}
  • Step 4: Run validation tests — expect PASS
npx vitest run tests/lib/incidents/validate.test.ts

Expected: PASS — 6 tests pass

  • Step 5: Implement the incidents API route

Create app/api/incidents/route.ts:

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { validateIncidentInput } from '@/lib/incidents/validate'
import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'

export async function POST(request: Request) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  let body: Record<string, unknown>
  let files: File[] = []

  const contentType = request.headers.get('content-type') ?? ''
  if (contentType.includes('multipart/form-data')) {
    const form = await request.formData()
    body = Object.fromEntries(
      [...form.entries()].filter(([, v]) => typeof v === 'string')
    ) as Record<string, unknown>
    files = form.getAll('files').filter((v): v is File => v instanceof File)
  } else {
    body = await request.json()
  }

  const input = {
    zone_token: body.zone_token as string,
    incident_type: body.incident_type as any,
    description: body.description as string,
    injury_involved: body.injury_involved === 'true' || body.injury_involved === true,
    asset_involved: body.asset_involved === 'true' || body.asset_involved === true,
    medical_status: body.medical_status as any || undefined,
  }

  const validation = validateIncidentInput(input)
  if (!validation.ok) {
    return NextResponse.json({ error: 'Validation failed', details: validation.errors }, { status: 422 })
  }

  // Look up zone by token → get site_id + zone_id
  const { data: zone, error: zoneError } = await supabase
    .from('zones')
    .select('id, site_id')
    .eq('qr_code_token', input.zone_token)
    .single()

  if (zoneError || !zone) {
    return NextResponse.json({ error: 'Zone not found' }, { status: 404 })
  }

  // Insert incident
  const { data: incident, error: incidentError } = await supabase
    .from('incidents')
    .insert({
      incident_type: input.incident_type,
      site_id: zone.site_id,
      zone_id: zone.id,
      reported_by: user.id,
      description: input.description.trim(),
      injury_involved: input.injury_involved,
      asset_involved: input.asset_involved,
      medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null,
    })
    .select('id, reference_no')
    .single()

  if (incidentError || !incident) {
    console.error('incident insert error:', incidentError)
    return NextResponse.json({ error: 'Failed to create incident' }, { status: 500 })
  }

  // Upload evidence files
  const evidenceRows: Array<{
    incident_id: string
    stage: EvidenceStage
    file_url: string
    file_type: string
    file_hash: string
    uploaded_by: string
  }> = []

  for (const file of files) {
    try {
      const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incident.id, 'report')
      evidenceRows.push({
        incident_id: incident.id,
        stage: 'report',
        file_url: publicUrl,
        file_type: file.type,
        file_hash: hash,
        uploaded_by: user.id,
      })
    } catch (err) {
      console.error('file upload error:', err)
    }
  }

  if (evidenceRows.length > 0) {
    await supabase.from('evidence_files').insert(evidenceRows)
  }

  // Audit log
  await supabase.rpc('write_audit_log', {
    p_table_name: 'incidents',
    p_record_id: incident.id,
    p_action: 'INSERT',
    p_new_value: { incident_type: input.incident_type, reported_by: user.id },
  })

  return NextResponse.json({ id: incident.id, reference_no: incident.reference_no }, { status: 201 })
}

Create app/api/incidents/[id]/route.ts:

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export const dynamic = 'force-dynamic'

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  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) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  return NextResponse.json(incident)
}
  • Step 6: Update middleware — add /report exception + redirect preservation

In middleware.ts, make these two changes:

// BEFORE (line 33):
const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth')

// AFTER:
const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth')
const isSharedRoute = pathname.startsWith('/report') // accessible to all logged-in users
// BEFORE (line 36):
if (!user && !isPublicRoute) return NextResponse.redirect(new URL('/login', request.url))

// AFTER:
if (!user && !isPublicRoute) {
  const redirectUrl = new URL('/login', request.url)
  redirectUrl.searchParams.set('redirect', pathname + request.nextUrl.search)
  return NextResponse.redirect(redirectUrl)
}
// BEFORE (line 55):
if (user && !isPublicRoute && pathname !== '/') {

// AFTER:
if (user && !isPublicRoute && !isSharedRoute && pathname !== '/') {

Full updated middleware.ts:

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import { ROLE_HOME, isValidRole, type UserRole } from '@/lib/auth/roles'

export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll() },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options),
          )
        },
      },
    },
  )

  const { data: { user } } = await supabase.auth.getUser()
  const { pathname } = request.nextUrl
  const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth')
  const isSharedRoute = pathname.startsWith('/report')

  if (!user && !isPublicRoute) {
    const redirectUrl = new URL('/login', request.url)
    redirectUrl.searchParams.set('redirect', pathname + request.nextUrl.search)
    return NextResponse.redirect(redirectUrl)
  }

  if (user && (pathname === '/' || pathname === '/login')) {
    const redirect = request.nextUrl.searchParams.get('redirect')
    if (redirect && redirect.startsWith('/')) {
      return NextResponse.redirect(new URL(redirect, request.url))
    }
    const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
    const role = profile?.role
    if (isValidRole(role)) return NextResponse.redirect(new URL(ROLE_HOME[role as UserRole], request.url))
  }

  if (user && !isPublicRoute && !isSharedRoute && pathname !== '/') {
    const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
    const role = profile?.role
    if (isValidRole(role)) {
      const allowedPrefix = ROLE_HOME[role as UserRole]
      if (!pathname.startsWith(allowedPrefix)) {
        return NextResponse.redirect(new URL(allowedPrefix, request.url))
      }
    }
  }

  return supabaseResponse
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}
  • Step 7: Create the report page (server component)

Create app/report/page.tsx:

export const dynamic = 'force-dynamic'

import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { ReportForm } from '@/components/incidents/report-form'

interface Props {
  searchParams: Promise<{ zone?: string }>
}

export default async function ReportPage({ searchParams }: Props) {
  const { zone } = await searchParams
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) redirect(`/login?redirect=/report${zone ? `?zone=${zone}` : ''}`)

  let zoneData: { id: string; name: string; site_id: string; sites: { name: string } } | null = null

  if (zone) {
    const { data } = await supabase
      .from('zones')
      .select('id, name, site_id, sites (name)')
      .eq('qr_code_token', zone)
      .single()
    zoneData = data as typeof zoneData
  }

  return (
    <main className="min-h-screen bg-gray-50 py-6 px-4 max-w-lg mx-auto">
      <div className="mb-6">
        <h1 className="text-2xl font-bold text-gray-900">Report an Incident</h1>
        {zoneData ? (
          <p className="text-sm text-gray-600 mt-1">
            {(zoneData.sites as any)?.name ?? 'Unknown Site'}  {zoneData.name}
          </p>
        ) : (
          <p className="text-sm text-amber-600 mt-1">No zone detected  zone will not be recorded</p>
        )}
      </div>
      <ReportForm zoneToken={zone ?? null} zoneName={zoneData?.name ?? null} siteName={(zoneData?.sites as any)?.name ?? null} />
    </main>
  )
}
  • Step 8: Create the report form component (client)

Create components/incidents/report-form.tsx:

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { FileUpload } from '@/components/incidents/file-upload'
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'

const INCIDENT_TYPE_LABELS: Record<IncidentType, string> = {
  injury: 'Injury / Medical',
  near_miss: 'Near Miss',
  hazard: 'Hazard / Unsafe Condition',
  asset_damage: 'Asset / Equipment Damage',
  environmental: 'Environmental Incident',
  security: 'Security Incident',
  fire: 'Fire / Emergency',
}

const MEDICAL_STATUS_LABELS: Record<MedicalStatus, string> = {
  none: 'No treatment needed',
  first_aid: 'First aid only',
  medical_treatment: 'Medical treatment (non-LTI)',
  lti: 'Lost Time Injury (LTI)',
}

interface Props {
  zoneToken: string | null
  zoneName: string | null
  siteName: string | null
}

export function ReportForm({ zoneToken }: Props) {
  const router = useRouter()
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [files, setFiles] = useState<File[]>([])

  const [form, setForm] = useState({
    incident_type: '' as IncidentType | '',
    description: '',
    injury_involved: false,
    medical_status: '' as MedicalStatus | '',
    asset_involved: false,
  })

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setError(null)
    setSubmitting(true)

    try {
      const fd = new FormData()
      if (zoneToken) fd.append('zone_token', zoneToken)
      fd.append('incident_type', form.incident_type)
      fd.append('description', form.description)
      fd.append('injury_involved', String(form.injury_involved))
      fd.append('asset_involved', String(form.asset_involved))
      if (form.injury_involved && form.medical_status) {
        fd.append('medical_status', form.medical_status)
      }
      files.forEach(f => fd.append('files', f))

      const res = await fetch('/api/incidents', { method: 'POST', body: fd })
      const data = await res.json()

      if (!res.ok) {
        setError(data.details ? data.details.join('. ') : data.error)
        return
      }

      router.push(`/report/success?ref=${data.reference_no}`)
    } catch {
      setError('Something went wrong. Please try again.')
    } finally {
      setSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
      {error && (
        <div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
          {error}
        </div>
      )}

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">
          Incident type <span className="text-red-500">*</span>
        </label>
        <select
          required
          className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          value={form.incident_type}
          onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
        >
          <option value="">Select type</option>
          {Object.entries(INCIDENT_TYPE_LABELS).map(([v, l]) => (
            <option key={v} value={v}>{l}</option>
          ))}
        </select>
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">
          What happened? <span className="text-red-500">*</span>
        </label>
        <textarea
          required
          minLength={10}
          rows={4}
          placeholder="Describe what happened, where, and any immediate actions taken…"
          className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
          value={form.description}
          onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
        />
      </div>

      <div className="space-y-3">
        <label className="flex items-center gap-3 cursor-pointer">
          <input
            type="checkbox"
            className="w-4 h-4 text-blue-600"
            checked={form.injury_involved}
            onChange={e => setForm(f => ({ ...f, injury_involved: e.target.checked, medical_status: '' }))}
          />
          <span className="text-sm font-medium text-gray-700">Person was injured</span>
        </label>

        {form.injury_involved && (
          <div className="ml-7">
            <label className="block text-sm font-medium text-gray-700 mb-1">
              Treatment level <span className="text-red-500">*</span>
            </label>
            <select
              required={form.injury_involved}
              className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
              value={form.medical_status}
              onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
            >
              <option value="">Select treatment</option>
              {Object.entries(MEDICAL_STATUS_LABELS).map(([v, l]) => (
                <option key={v} value={v}>{l}</option>
              ))}
            </select>
          </div>
        )}

        <label className="flex items-center gap-3 cursor-pointer">
          <input
            type="checkbox"
            className="w-4 h-4 text-blue-600"
            checked={form.asset_involved}
            onChange={e => setForm(f => ({ ...f, asset_involved: e.target.checked }))}
          />
          <span className="text-sm font-medium text-gray-700">Equipment / asset was damaged</span>
        </label>
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Photos / Videos / Documents
        </label>
        <FileUpload stage="report" onFilesChange={setFiles} disabled={submitting} />
      </div>

      <button
        type="submit"
        disabled={submitting || !form.incident_type}
        className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium text-sm
          hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        {submitting ? 'Submitting…' : 'Submit Incident Report'}
      </button>
    </form>
  )
}
  • Step 9: Create success page

Create app/report/success/page.tsx:

export const dynamic = 'force-dynamic'

import Link from 'next/link'

interface Props {
  searchParams: Promise<{ ref?: string }>
}

export default async function ReportSuccessPage({ searchParams }: Props) {
  const { ref } = await searchParams
  return (
    <main className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
      <div className="bg-white rounded-xl shadow-sm p-8 max-w-sm w-full text-center">
        <div className="text-4xl mb-4"></div>
        <h1 className="text-xl font-bold text-gray-900 mb-2">Report submitted</h1>
        {ref && (
          <p className="text-sm text-gray-600 mb-1">
            Reference: <span className="font-mono font-semibold">{ref}</span>
          </p>
        )}
        <p className="text-sm text-gray-500 mb-6">
          The supervisor and HSE officer have been notified.
        </p>
        <Link
          href="/report"
          className="text-sm text-blue-600 hover:underline"
        >
          Submit another report
        </Link>
      </div>
    </main>
  )
}
  • Step 10: Write API route tests

Create tests/api/incidents.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { validateIncidentInput } from '@/lib/incidents/validate'

// Test validation logic used by the API route (API routes are tested via integration)
describe('incident report validation', () => {
  it('rejects empty zone_token', () => {
    const result = validateIncidentInput({
      zone_token: '',
      incident_type: 'near_miss',
      description: 'Forklift nearly hit a worker',
      injury_involved: false,
      asset_involved: false,
    })
    expect(result.ok).toBe(false)
    expect(result.errors).toContain('zone_token is required')
  })

  it('requires medical_status for injury', () => {
    const result = validateIncidentInput({
      zone_token: 'scw1-dock-a-qr-2026',
      incident_type: 'injury',
      description: 'Worker slipped on wet floor near cold storage entrance',
      injury_involved: true,
      asset_involved: false,
    })
    expect(result.ok).toBe(false)
    expect(result.errors).toContain('medical_status is required when injury is involved')
  })
})
  • Step 11: Run all tests
npx vitest run tests/lib/incidents/validate.test.ts tests/api/incidents.test.ts

Expected: PASS — 8 tests pass

  • Step 12: Commit
git add middleware.ts lib/incidents/ app/api/incidents/ app/report/ components/incidents/report-form.tsx tests/
git commit -m "feat: incident report form, API route, middleware shared-route + redirect"

Task 3: Email notifications via Resend

Files:

  • Create: lib/notifications/email.ts
  • Create: lib/notifications/templates/new-incident.ts
  • Modify: app/api/incidents/route.ts (import + call sendNewIncidentEmail after insert)
  • Test: tests/lib/notifications/email.test.ts

Interfaces:

  • Consumes: POST /api/incidents already creates incident — this task hooks in after
  • Produces: sendNewIncidentEmail(incidentId: string, siteId: string, reference_no: string, incidentType: string): Promise<void>

Prerequisite: RESEND_API_KEY and RESEND_FROM_EMAIL must be in .env.local before testing manually. For automated tests, Resend is mocked.

  • Step 1: Install Resend
npm install resend

Expected: added 1 package

  • Step 2: Write failing email tests

Create tests/lib/notifications/email.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockSend = vi.fn()
vi.mock('resend', () => ({
  Resend: vi.fn().mockImplementation(() => ({
    emails: { send: mockSend },
  })),
}))

const mockSupabase = {
  from: vi.fn(),
}
vi.mock('@/lib/supabase/server', () => ({
  createClient: vi.fn().mockResolvedValue(mockSupabase),
}))

import { sendNewIncidentEmail } from '@/lib/notifications/email'

beforeEach(() => {
  vi.clearAllMocks()
  mockSend.mockResolvedValue({ data: { id: 'email-123' }, error: null })

  // Mock users query: returns supervisor + hse users
  mockSupabase.from.mockReturnValue({
    select: vi.fn().mockReturnThis(),
    in: vi.fn().mockReturnThis(),
    eq: vi.fn().mockReturnValue({
      data: [
        { email: 'supervisor@setiacorp.com', name: 'Ahmad', role: 'supervisor' },
        { email: 'hse@setiacorp.com', name: 'Priya', role: 'hse' },
      ],
      error: null,
    }),
  })
})

describe('sendNewIncidentEmail', () => {
  it('sends email to supervisor and hse users', async () => {
    await sendNewIncidentEmail('inc-001', 'site-001', 'SCW1-202607-0001', 'near_miss')
    expect(mockSend).toHaveBeenCalledTimes(1)
    const call = mockSend.mock.calls[0][0]
    expect(call.to).toEqual(expect.arrayContaining(['supervisor@setiacorp.com', 'hse@setiacorp.com']))
    expect(call.subject).toContain('SCW1-202607-0001')
  })

  it('does not send if no recipients', async () => {
    mockSupabase.from.mockReturnValue({
      select: vi.fn().mockReturnThis(),
      in: vi.fn().mockReturnThis(),
      eq: vi.fn().mockReturnValue({ data: [], error: null }),
    })
    await sendNewIncidentEmail('inc-001', 'site-001', 'SCW1-202607-0001', 'near_miss')
    expect(mockSend).not.toHaveBeenCalled()
  })
})
  • Step 3: Run tests — expect FAIL
npx vitest run tests/lib/notifications/email.test.ts

Expected: FAIL — Cannot find module '@/lib/notifications/email'

  • Step 4: Implement email template

Create lib/notifications/templates/new-incident.ts:

export function newIncidentTemplate(params: {
  reference_no: string
  incident_type: string
  site_name: string
  reporter_name: string
  reported_at: string
  site_url: string
  incident_id: string
}): { subject: string; html: string; text: string } {
  const typeLabel = params.incident_type.replace(/_/g, ' ')
  const subject = `[IMS] New incident ${params.reference_no}${typeLabel} at ${params.site_name}`
  const link = `${params.site_url}/hse/incidents/${params.incident_id}`

  const text = `
New incident report received.

Reference: ${params.reference_no}
Type: ${typeLabel}
Site: ${params.site_name}
Reported by: ${params.reporter_name}
Time: ${params.reported_at}

View incident: ${link}
  `.trim()

  const html = `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
  <h2 style="color:#1e293b">New Incident Report</h2>
  <table style="border-collapse:collapse;width:100%">
    <tr><td style="padding:6px 0;color:#64748b">Reference</td><td style="padding:6px 0;font-weight:600">${params.reference_no}</td></tr>
    <tr><td style="padding:6px 0;color:#64748b">Type</td><td style="padding:6px 0">${typeLabel}</td></tr>
    <tr><td style="padding:6px 0;color:#64748b">Site</td><td style="padding:6px 0">${params.site_name}</td></tr>
    <tr><td style="padding:6px 0;color:#64748b">Reported by</td><td style="padding:6px 0">${params.reporter_name}</td></tr>
    <tr><td style="padding:6px 0;color:#64748b">Time</td><td style="padding:6px 0">${params.reported_at}</td></tr>
  </table>
  <p style="margin-top:20px">
    <a href="${link}" style="background:#2563eb;color:#fff;padding:10px 18px;text-decoration:none;border-radius:6px;display:inline-block">
      View Incident
    </a>
  </p>
</div>
  `.trim()

  return { subject, html, text }
}
  • Step 5: Implement email sender

Create lib/notifications/email.ts:

import { Resend } from 'resend'
import { createClient } from '@/lib/supabase/server'
import { newIncidentTemplate } from '@/lib/notifications/templates/new-incident'

export async function sendNewIncidentEmail(
  incidentId: string,
  siteId: string,
  reference_no: string,
  incidentType: string,
): Promise<void> {
  const supabase = await createClient()

  // Get supervisor + HSE recipients for this site
  const { data: recipients } = await supabase
    .from('users')
    .select('email, name, role')
    .in('role', ['supervisor', 'hse'])
    .eq('site_id', siteId)

  if (!recipients || recipients.length === 0) return

  const to = recipients.map(r => r.email).filter(Boolean)
  if (to.length === 0) return

  // Get site name + reporter name from the incident
  const { data: incident } = await supabase
    .from('incidents')
    .select('reported_at, sites (name), reporter:users!reported_by (name)')
    .eq('id', incidentId)
    .single()

  const siteName = (incident?.sites as any)?.name ?? 'Unknown Site'
  const reporterName = (incident?.reporter as any)?.name ?? 'Unknown'
  const reportedAt = incident?.reported_at
    ? new Date(incident.reported_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })
    : '-'
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'

  const { subject, html, text } = newIncidentTemplate({
    reference_no,
    incident_type: incidentType,
    site_name: siteName,
    reporter_name: reporterName,
    reported_at: reportedAt,
    site_url: siteUrl,
    incident_id: incidentId,
  })

  const resend = new Resend(process.env.RESEND_API_KEY)
  const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'

  const { error } = await resend.emails.send({ from, to, subject, html, text })
  if (error) console.error('Resend error:', error)
}
  • Step 6: Run email tests — expect PASS
npx vitest run tests/lib/notifications/email.test.ts

Expected: PASS — 2 tests pass

  • Step 7: Wire email into the incidents API route

In app/api/incidents/route.ts, add after the audit log call:

// After: await supabase.rpc('write_audit_log', ...)

// Fire email notification (non-blocking — don't await, don't fail request on email error)
import { sendNewIncidentEmail } from '@/lib/notifications/email'

sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
  .catch(err => console.error('email notification failed:', err))

Add the import at the top of app/api/incidents/route.ts:

import { sendNewIncidentEmail } from '@/lib/notifications/email'

And the non-blocking call before return NextResponse.json(...):

sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
  .catch(err => console.error('email notification failed:', err))

return NextResponse.json({ id: incident.id, reference_no: incident.reference_no }, { status: 201 })
  • Step 8: Run all tests
npx vitest run

Expected: All existing tests pass

  • Step 9: Commit
git add lib/notifications/ app/api/incidents/route.ts tests/lib/notifications/
git commit -m "feat: email notifications via Resend on new incident"

Task 4: Incident inbox (supervisor + HSE)

Files:

  • Create: components/incidents/incident-list.tsx
  • Create: app/(protected)/hse/incidents/page.tsx
  • Create: app/(protected)/supervisor/incidents/page.tsx
  • Modify: app/(protected)/hse/page.tsx (add nav link to inbox)
  • Modify: app/(protected)/supervisor/page.tsx (add nav link to inbox)
  • Test: tests/components/incidents/incident-list.test.tsx

Interfaces:

  • Consumes: DB incidents table via server component (no intermediate API — server components query Supabase directly)

  • Produces: <IncidentList incidents={Incident[]} /> (client component for filtering)

  • Step 1: Write failing component tests

Create tests/components/incidents/incident-list.test.tsx:

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(<IncidentList incidents={mockIncidents as any} />)
    expect(screen.getByText('SCW1-202607-0001')).toBeTruthy()
    expect(screen.getByText('SCW1-202607-0002')).toBeTruthy()
  })

  it('shows incident type labels', () => {
    render(<IncidentList incidents={mockIncidents as any} />)
    expect(screen.getByText('Near Miss')).toBeTruthy()
    expect(screen.getByText('Injury / Medical')).toBeTruthy()
  })

  it('shows status badges', () => {
    render(<IncidentList incidents={mockIncidents as any} />)
    expect(screen.getByText('REPORTED')).toBeTruthy()
    expect(screen.getByText('TRIAGED')).toBeTruthy()
  })

  it('renders empty state when no incidents', () => {
    render(<IncidentList incidents={[]} />)
    expect(screen.getByText(/no incidents/i)).toBeTruthy()
  })
})
  • Step 2: Run tests — expect FAIL
npx vitest run tests/components/incidents/incident-list.test.tsx

Expected: FAIL — Cannot find module '@/components/incidents/incident-list'

  • Step 3: Implement IncidentList component

Create components/incidents/incident-list.tsx:

'use client'

import { useState } from 'react'
import Link from 'next/link'

const TYPE_LABELS: Record<string, string> = {
  injury: 'Injury / Medical',
  near_miss: 'Near Miss',
  hazard: 'Hazard',
  asset_damage: 'Asset Damage',
  environmental: 'Environmental',
  security: 'Security',
  fire: 'Fire / Emergency',
}

const STATUS_COLORS: Record<string, string> = {
  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 // '/hse' or '/supervisor'
}

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 (
    <div className="space-y-4">
      {/* Filters */}
      <div className="flex flex-wrap gap-2">
        <input
          type="search"
          placeholder="Search ref, type, site…"
          className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-blue-500"
          value={search}
          onChange={e => setSearch(e.target.value)}
        />
        <select
          className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          value={typeFilter}
          onChange={e => setTypeFilter(e.target.value)}
        >
          <option value="">All types</option>
          {Object.entries(TYPE_LABELS).map(([v, l]) => (
            <option key={v} value={v}>{l}</option>
          ))}
        </select>
        <select
          className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          value={statusFilter}
          onChange={e => setStatusFilter(e.target.value)}
        >
          <option value="">All statuses</option>
          {Object.keys(STATUS_COLORS).map(s => (
            <option key={s} value={s}>{s.replace(/_/g, ' ').toUpperCase()}</option>
          ))}
        </select>
      </div>

      {filtered.length === 0 ? (
        <div className="text-center py-12 text-gray-500 text-sm">No incidents found</div>
      ) : (
        <div className="divide-y divide-gray-100 bg-white rounded-xl shadow-sm overflow-hidden">
          {filtered.map(inc => (
            <Link
              key={inc.id}
              href={`${basePath}/incidents/${inc.id}`}
              className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
            >
              <div className="min-w-0 flex-1">
                <div className="flex items-center gap-2 mb-0.5">
                  <span className="font-mono text-sm font-semibold text-gray-900">
                    {inc.reference_no ?? '—'}
                  </span>
                  <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[inc.status] ?? 'bg-gray-100 text-gray-700'}`}>
                    {inc.status.replace(/_/g, ' ').toUpperCase()}
                  </span>
                </div>
                <div className="text-sm text-gray-600">
                  {TYPE_LABELS[inc.incident_type] ?? inc.incident_type}
                  {inc.zones?.name && ` · ${inc.zones.name}`}
                  {inc.sites?.name && ` · ${inc.sites.name}`}
                </div>
              </div>
              <div className="ml-4 text-right">
                <div className="text-xs text-gray-400">
                  {new Date(inc.reported_at).toLocaleDateString('en-MY')}
                </div>
                {inc.severity && (
                  <div className="text-xs text-gray-500 mt-0.5">Sev {inc.severity}</div>
                )}
              </div>
            </Link>
          ))}
        </div>
      )}
    </div>
  )
}
  • Step 4: Run component tests — expect PASS
npx vitest run tests/components/incidents/incident-list.test.tsx

Expected: PASS — 4 tests pass

  • Step 5: Create HSE incidents inbox page

Create app/(protected)/hse/incidents/page.tsx:

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 (
    <main className="max-w-4xl mx-auto px-4 py-6">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-gray-900">Incident Inbox</h1>
        <span className="text-sm text-gray-500">{incidents?.length ?? 0} incidents</span>
      </div>
      <IncidentList incidents={(incidents ?? []) as any} basePath="/hse" />
    </main>
  )
}
  • Step 6: Create Supervisor incidents inbox page

Create app/(protected)/supervisor/incidents/page.tsx:

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 (
    <main className="max-w-4xl mx-auto px-4 py-6">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-gray-900">Incident Inbox</h1>
        <span className="text-sm text-gray-500">{incidents?.length ?? 0} incidents</span>
      </div>
      <IncidentList incidents={(incidents ?? []) as any} basePath="/supervisor" />
    </main>
  )
}
  • Step 7: Update HSE and Supervisor home pages with nav links

Replace app/(protected)/hse/page.tsx:

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 (
    <main className="max-w-4xl mx-auto px-4 py-6">
      <h1 className="text-2xl font-bold text-gray-900 mb-1">HSE Officer Portal</h1>
      <p className="text-gray-500 text-sm mb-8">Welcome, {profile?.name ?? user.email}</p>
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
        <Link href="/hse/incidents" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow border border-gray-100">
          <div className="text-2xl mb-2">📋</div>
          <div className="font-semibold text-gray-900">Incident Inbox</div>
          <div className="text-xs text-gray-500 mt-1">View all incidents</div>
        </Link>
        <Link href="/hse/dashboard" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow border border-gray-100">
          <div className="text-2xl mb-2">📊</div>
          <div className="font-semibold text-gray-900">Dashboard</div>
          <div className="text-xs text-gray-500 mt-1">Stats & overview</div>
        </Link>
      </div>
    </main>
  )
}

Replace app/(protected)/supervisor/page.tsx:

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 (
    <main className="max-w-4xl mx-auto px-4 py-6">
      <h1 className="text-2xl font-bold text-gray-900 mb-1">Supervisor Portal</h1>
      <p className="text-gray-500 text-sm mb-8">Welcome, {profile?.name ?? user.email}</p>
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
        <Link href="/supervisor/incidents" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow border border-gray-100">
          <div className="text-2xl mb-2">📋</div>
          <div className="font-semibold text-gray-900">Incident Inbox</div>
          <div className="text-xs text-gray-500 mt-1">View site incidents</div>
        </Link>
      </div>
    </main>
  )
}
  • Step 8: Run all tests
npx vitest run

Expected: All tests pass

  • Step 9: Commit
git add components/incidents/incident-list.tsx app/\(protected\)/hse/ app/\(protected\)/supervisor/ tests/components/
git commit -m "feat: incident inbox pages for HSE and supervisor roles"

Task 5: Incident detail page

Files:

  • Create: components/incidents/incident-detail.tsx
  • Create: components/incidents/evidence-gallery.tsx
  • Create: app/(protected)/hse/incidents/[id]/page.tsx
  • Create: app/(protected)/supervisor/incidents/[id]/page.tsx
  • Test: tests/components/incidents/incident-detail.test.tsx

Interfaces:

  • Consumes: GET /api/incidents/[id] (from Task 2)

  • Produces: <IncidentDetail incident={IncidentWithEvidence} />, <EvidenceGallery files={EvidenceFile[]} stage={EvidenceStage} />

  • Step 1: Write failing tests

Create tests/components/incidents/incident-detail.test.tsx:

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(<IncidentDetail incident={mockIncident as any} />)
    expect(screen.getByText('SCW1-202607-0001')).toBeTruthy()
  })

  it('renders description', () => {
    render(<IncidentDetail incident={mockIncident as any} />)
    expect(screen.getByText(/Forklift nearly hit/)).toBeTruthy()
  })

  it('renders reported by', () => {
    render(<IncidentDetail incident={mockIncident as any} />)
    expect(screen.getByText(/John Doe/)).toBeTruthy()
  })

  it('renders evidence count', () => {
    render(<IncidentDetail incident={mockIncident as any} />)
    expect(screen.getByText(/1 file/i)).toBeTruthy()
  })
})
  • Step 2: Run tests — expect FAIL
npx vitest run tests/components/incidents/incident-detail.test.tsx

Expected: FAIL — Cannot find module '@/components/incidents/incident-detail'

  • Step 3: Implement EvidenceGallery

Create components/incidents/evidence-gallery.tsx:

'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 <p className="text-sm text-gray-400">No files for this stage</p>

  return (
    <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
      {filtered.map(file => (
        <a
          key={file.id}
          href={file.file_url}
          target="_blank"
          rel="noopener noreferrer"
          className="block rounded-lg overflow-hidden bg-gray-100 aspect-square hover:opacity-90 transition-opacity"
        >
          {isImage(file.file_type) ? (
            <img
              src={file.file_url}
              alt="Evidence"
              className="w-full h-full object-cover"
            />
          ) : isVideo(file.file_type) ? (
            <div className="w-full h-full flex items-center justify-center text-3xl">🎥</div>
          ) : (
            <div className="w-full h-full flex items-center justify-center text-3xl">📄</div>
          )}
        </a>
      ))}
    </div>
  )
}
  • Step 4: Implement IncidentDetail

Create components/incidents/incident-detail.tsx:

import { EvidenceGallery } from './evidence-gallery'

const TYPE_LABELS: Record<string, string> = {
  injury: 'Injury / Medical',
  near_miss: 'Near Miss',
  hazard: 'Hazard',
  asset_damage: 'Asset Damage',
  environmental: 'Environmental',
  security: 'Security',
  fire: 'Fire / Emergency',
}

const STATUS_COLORS: Record<string, string> = {
  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<string, string> = {
  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 (
    <div>
      <dt className="text-xs text-gray-500 uppercase tracking-wide">{label}</dt>
      <dd className="text-sm text-gray-900 mt-0.5">{value ?? '—'}</dd>
    </div>
  )
}

export function IncidentDetail({ incident }: Props) {
  const reportStageFiles = incident.evidence_files.filter(f => f.stage === 'report')

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="bg-white rounded-xl shadow-sm p-5">
        <div className="flex items-start justify-between mb-4">
          <div>
            <h1 className="text-xl font-bold text-gray-900 font-mono">
              {incident.reference_no ?? 'Pending reference'}
            </h1>
            <p className="text-sm text-gray-600 mt-0.5">{TYPE_LABELS[incident.incident_type] ?? incident.incident_type}</p>
          </div>
          <span className={`text-xs px-2.5 py-1 rounded-full font-medium ${STATUS_COLORS[incident.status] ?? 'bg-gray-100'}`}>
            {incident.status.replace(/_/g, ' ').toUpperCase()}
          </span>
        </div>

        <dl className="grid grid-cols-2 gap-4 sm:grid-cols-3">
          <Field label="Site" value={incident.sites?.name} />
          <Field label="Zone" value={incident.zones?.name} />
          <Field label="Reported by" value={incident.reporter?.name} />
          <Field label="Reported at" value={new Date(incident.reported_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })} />
          <Field label="Severity" value={incident.severity ? `Level ${incident.severity}` : 'Not yet assigned'} />
          {incident.injury_involved && (
            <Field label="Medical status" value={MEDICAL_LABELS[incident.medical_status ?? 'none']} />
          )}
          {incident.injury_involved && incident.lost_days != null && (
            <Field label="Lost days" value={`${incident.lost_days} day(s)`} />
          )}
        </dl>
      </div>

      {/* Description */}
      <div className="bg-white rounded-xl shadow-sm p-5">
        <h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">Description</h2>
        <p className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">{incident.description}</p>
      </div>

      {/* Evidence */}
      <div className="bg-white rounded-xl shadow-sm p-5">
        <h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">
          Evidence  Report Stage
          <span className="ml-2 text-gray-400 font-normal normal-case">
            {reportStageFiles.length} file{reportStageFiles.length !== 1 ? 's' : ''}
          </span>
        </h2>
        <EvidenceGallery files={incident.evidence_files} stage="report" />
      </div>
    </div>
  )
}
  • Step 5: Run tests — expect PASS
npx vitest run tests/components/incidents/incident-detail.test.tsx

Expected: PASS — 4 tests pass

  • Step 6: Create HSE incident detail page

Create app/(protected)/hse/incidents/[id]/page.tsx:

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 (
    <main className="max-w-3xl mx-auto px-4 py-6">
      <Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline mb-4 inline-block">
         Back to inbox
      </Link>
      <IncidentDetail incident={incident as any} />
    </main>
  )
}
  • Step 7: Create Supervisor incident detail page

Create app/(protected)/supervisor/incidents/[id]/page.tsx:

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 (
    <main className="max-w-3xl mx-auto px-4 py-6">
      <Link href="/supervisor/incidents" className="text-sm text-blue-600 hover:underline mb-4 inline-block">
         Back to inbox
      </Link>
      <IncidentDetail incident={incident as any} />
    </main>
  )
}
  • Step 8: Run all tests
npx vitest run

Expected: All tests pass

  • Step 9: Commit
git add components/incidents/incident-detail.tsx components/incidents/evidence-gallery.tsx \
  app/\(protected\)/hse/incidents/ app/\(protected\)/supervisor/incidents/ \
  tests/components/incidents/incident-detail.test.tsx
git commit -m "feat: incident detail page with evidence gallery"

Task 6: Basic dashboard

Files:

  • Create: app/api/dashboard/stats/route.ts
  • Create: components/dashboard/stat-card.tsx
  • Create: app/(protected)/hse/dashboard/page.tsx
  • Test: tests/components/dashboard/stat-card.test.tsx

Interfaces:

  • Consumes: DB incidents table

  • Produces:

    • GET /api/dashboard/stats response: { total: number; open: number; closed: number; by_type: Record<string, number>; by_site: Array<{ name: string; count: number }> }
    • <StatCard label={string} value={number} sub?: string />
  • Step 1: Write failing tests

Create tests/components/dashboard/stat-card.test.tsx:

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(<StatCard label="Total Incidents" value={42} />)
    expect(screen.getByText('Total Incidents')).toBeTruthy()
    expect(screen.getByText('42')).toBeTruthy()
  })

  it('renders optional sub text', () => {
    render(<StatCard label="Open" value={12} sub="awaiting action" />)
    expect(screen.getByText('awaiting action')).toBeTruthy()
  })
})
  • Step 2: Run tests — expect FAIL
npx vitest run tests/components/dashboard/stat-card.test.tsx

Expected: FAIL — Cannot find module '@/components/dashboard/stat-card'

  • Step 3: Implement StatCard

Create components/dashboard/stat-card.tsx:

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 (
    <div className={`bg-white rounded-xl shadow-sm p-5 border-t-4 ${ACCENT[accent]}`}>
      <p className="text-xs text-gray-500 uppercase tracking-wide font-medium">{label}</p>
      <p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
      {sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
    </div>
  )
}
  • Step 4: Run StatCard tests — expect PASS
npx vitest run tests/components/dashboard/stat-card.test.tsx

Expected: PASS — 2 tests pass

  • Step 5: Implement dashboard stats API route

Create app/api/dashboard/stats/route.ts:

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<string, number> = {}
  for (const r of rows) {
    by_type[r.incident_type] = (by_type[r.incident_type] ?? 0) + 1
  }

  const siteMap: Record<string, number> = {}
  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 })
}
  • Step 6: Create HSE dashboard page

Create app/(protected)/hse/dashboard/page.tsx:

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<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() {
  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<string, number> = {}
  for (const r of rows) {
    by_type[r.incident_type] = (by_type[r.incident_type] ?? 0) + 1
  }

  const siteMap: Record<string, number> = {}
  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 (
    <main className="max-w-4xl mx-auto px-4 py-6">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
        <Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
          View all incidents 
        </Link>
      </div>

      {/* KPI cards */}
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 mb-8">
        <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" />
      </div>

      {/* By type */}
      <div className="bg-white rounded-xl shadow-sm p-5 mb-4">
        <h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide 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>

      {/* By site */}
      <div className="bg-white rounded-xl shadow-sm p-5">
        <h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide 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>
    </main>
  )
}
  • Step 7: Run all tests
npx vitest run

Expected: All tests pass

  • Step 8: Commit
git add components/dashboard/ app/api/dashboard/ app/\(protected\)/hse/dashboard/ \
  tests/components/dashboard/
git commit -m "feat: basic dashboard with incident counts by type and site"

Final verification

  • Start dev server: npm run dev
  • Navigate to http://localhost:3000/ims/report?zone=scw1-dock-a-qr-2026
    • Unauthenticated → redirected to /login?redirect=...
    • Log in → redirected back to report form
    • Zone pre-shown: "SCW1 — Dock A"
    • Submit near miss → lands on success page with reference number
  • Log in as admin@ims-test.com / navigate to /hse/incidents — new incident appears in list
  • Click incident → detail page shows description + evidence
  • Navigate to /hse/dashboard — stat cards show 1 total, 1 open
  • Check email inbox of supervisor — Resend email received (if Resend API key set up)