# 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=` → 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'` - ` void} />` (client component) - [ ] **Step 1: Write the storage migration** Create `supabase/migrations/20260710000010_storage.sql`: ```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** ```bash 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`: ```typescript 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** ```bash 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`: ```typescript 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 { 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** ```bash 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`: ```typescript '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(null) const [files, setFiles] = useState([]) const [errors, setErrors] = useState([]) 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 (
inputRef.current?.click()} onDragOver={e => { e.preventDefault(); setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={e => { e.preventDefault() setDragging(false) addFiles(Array.from(e.dataTransfer.files)) }} >

Tap to add photos / videos / documents

Photos/docs up to 10MB · Videos up to 200MB

addFiles(Array.from(e.target.files ?? []))} disabled={disabled} /> {errors.map((err, i) => (

{err}

))} {files.length > 0 && (
    {files.map((f, i) => (
  • {f.name}
  • ))}
)}
) } ``` - [ ] **Step 8: Commit** ```bash 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`: ```typescript 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** ```bash 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`: ```typescript 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** ```bash 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`: ```typescript 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 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 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`: ```typescript 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: ```typescript // 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 ``` ```typescript // 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) } ``` ```typescript // BEFORE (line 55): if (user && !isPublicRoute && pathname !== '/') { // AFTER: if (user && !isPublicRoute && !isSharedRoute && pathname !== '/') { ``` Full updated `middleware.ts`: ```typescript 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`: ```typescript 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 (

Report an Incident

{zoneData ? (

{(zoneData.sites as any)?.name ?? 'Unknown Site'} — {zoneData.name}

) : (

No zone detected — zone will not be recorded

)}
) } ``` - [ ] **Step 8: Create the report form component (client)** Create `components/incidents/report-form.tsx`: ```typescript '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 = { 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 = { 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(null) const [files, setFiles] = useState([]) 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 (
{error && (
{error}
)}