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)
-
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.devuntil a verified domain is set up — Resend allows this for testing.) -
Add
NEXT_PUBLIC_SITE_URLto.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/clientand@/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 falsealready in schema - Incident records lock on closure (
closed_at IS NOT NULL) — no further edits - Every DB mutation writes to
audit_logviaSELECT 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— usesvitest.config.tswith 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): stringEvidenceStage = '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/reportshared-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:
uploadEvidenceFilefrom@/lib/supabase/storage,FileUploadfrom@/components/incidents/file-upload -
Produces:
POST /api/incidentsbody:{ zone_token: string; incident_type: IncidentType; description: string; injury_involved: boolean; medical_status?: MedicalStatus; asset_involved: boolean }+ multipart filesPOST /api/incidentsresponse:{ id: string; reference_no: string }GET /api/incidents/[id]response:IncidentWithEvidenceIncidentType = '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 + callsendNewIncidentEmailafter insert) - Test:
tests/lib/notifications/email.test.ts
Interfaces:
- Consumes:
POST /api/incidentsalready 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
incidentstable 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
incidentstable -
Produces:
GET /api/dashboard/statsresponse:{ 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
- Unauthenticated → redirected to
- 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)