# Phase 3 — AI Features & Dashboard Upgrade > **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:** Complete Phase 3 by building functional dashboards for all 5 roles (Management, Supervisor, CAPA Owner, Reporter — HSE already done) and a shared CSV export endpoint. **Architecture:** All dashboards are Next.js server components (`force-dynamic`), scoped to the authenticated user's role and site. Supervisor dashboard filters by `users.site_id`. CAPA Owner and Reporter dashboards filter by `auth.uid()`. CSV export is a single API route with a `role` query param. No new DB tables required. **Tech Stack:** Next.js 15 App Router, Supabase (server client), `@supabase/ssr`, Tailwind CSS, `StatCard` component at `components/dashboard/stat-card.tsx`, Vitest 4. ## Already Complete (do not re-implement) - `lib/claude/client.ts` — Anthropic client singleton - `lib/claude/embed.ts` — Voyage AI embed helper - `app/api/incidents/ai/quality-check/route.ts` — report quality check (wired in `report-form.tsx`) - `app/api/incidents/[id]/ai/triage-suggest/route.ts` — triage severity suggestion (wired in `triage-form.tsx`) - `app/api/incidents/[id]/ai/rca-draft/route.ts` — RCA/CAPA draft (wired in `investigation-form.tsx`) - `app/(protected)/hse/dashboard/page.tsx` — enhanced with leading/lagging, zone heatmap, CAPA on-time rate, DOSH filing status - Tests: `embed.test.ts`, `quality-check.test.ts`, `triage-suggest.test.ts`, `metrics.test.ts` ## Global Constraints - `export const dynamic = 'force-dynamic'` on every `route.ts` and `page.tsx` in this plan. - Auth pattern: `supabase.auth.getUser()` then role/site check. Never trust client-sent user IDs. - Supabase join type casts: `as unknown as { name: string }` for relational fields — never trust inferred join types. - `StatCard` props: `{ label: string; value: number; sub?: string; accent?: 'default' | 'green' | 'yellow' | 'red' }`. - Never commit `.env`, API keys, or secrets. - Test files in `tests/`. --- ## File Map **New files:** - `app/api/dashboard/export/route.ts` — CSV export for HSE and Management roles - `tests/api/incidents/rca-draft.test.ts` — test for existing RCA draft route **Modified files:** - `app/(protected)/management/page.tsx` — replace placeholder with data dashboard - `app/(protected)/supervisor/page.tsx` — replace nav links with site-scoped data dashboard - `app/(protected)/capa-owner/page.tsx` — replace placeholder with user-scoped CAPA list - `app/(protected)/reporter/page.tsx` — replace placeholder with user-scoped incident list --- ### Task 1: Management dashboard Replace the placeholder at `app/(protected)/management/page.tsx` with a data-driven executive dashboard. No sub-components — all rendering inline. **Files:** - Modify: `app/(protected)/management/page.tsx` **Interfaces:** - Consumes: `incidents` (count, severity, type, status, reported_at), `capa_actions` (overdue count) - Produces: visible management dashboard at `/management` - [ ] **Step 1: Replace `app/(protected)/management/page.tsx`** ```typescript export const dynamic = 'force-dynamic' import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { StatCard } from '@/components/dashboard/stat-card' export default async function ManagementPage() { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) redirect('/login') const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() if (!profile || !['management', 'admin'].includes(profile.role)) redirect('/') const now = new Date() const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString() const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString() const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString() const [ { data: allIncidents }, { data: thisMonthIncidents }, { data: lastMonthIncidents }, { data: recentIncidents }, { data: overdueCapas }, ] = await Promise.all([ supabase.from('incidents').select('id, severity, status, incident_type, medical_status, sites (name)'), supabase.from('incidents').select('id').gte('reported_at', thisMonthStart), supabase.from('incidents').select('id').gte('reported_at', lastMonthStart).lt('reported_at', thisMonthStart), supabase.from('incidents').select('incident_type').gte('reported_at', thirtyDaysAgo), supabase.from('capa_actions').select('id').eq('status', 'overdue'), ]) const rows = allIncidents ?? [] const totalThisMonth = thisMonthIncidents?.length ?? 0 const totalLastMonth = lastMonthIncidents?.length ?? 0 const monthDelta = totalThisMonth - totalLastMonth const ltiCount = rows.filter(r => r.medical_status === 'lti').length const overdueCount = overdueCapas?.length ?? 0 // Severity distribution const severityDist: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } for (const r of rows) { if (r.severity && r.severity >= 1 && r.severity <= 5) { severityDist[r.severity] = (severityDist[r.severity] ?? 0) + 1 } } const severityMax = Math.max(...Object.values(severityDist), 1) // Leading vs lagging (last 30 days) const recent = recentIncidents ?? [] const leadingCount = recent.filter(r => ['hazard', 'near_miss'].includes(r.incident_type)).length const laggingCount = recent.filter(r => r.incident_type === 'injury').length // Site comparison const siteMap: Record = {} for (const r of rows) { const name = (r.sites as unknown as { name: string } | null)?.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) const SEVERITY_LABELS: Record = { 1: 'Minor', 2: 'Low', 3: 'Moderate', 4: 'Serious', 5: 'Critical' } const SEVERITY_COLORS: Record = { 1: 'bg-green-400', 2: 'bg-yellow-400', 3: 'bg-orange-400', 4: 'bg-red-500', 5: 'bg-red-700', } return (

Management Dashboard

{/* Summary stats */}
= 0 ? `+${monthDelta} vs last month` : `${monthDelta} vs last month`} accent={monthDelta > 0 ? 'yellow' : 'green'} /> 0 ? 'red' : 'green'} sub="lost-time injuries" /> 0 ? 'red' : 'green'} sub="past due date" />
{/* Leading vs lagging */}

Leading vs Lagging — Last 30 Days

Leading: hazard + near-miss · Lagging: injuries

{leadingCount}

Leading (Hazards + Near Misses)

{laggingCount}

Lagging (Injuries)

{/* Severity distribution */}

Severity Distribution (All Time)

{([5, 4, 3, 2, 1] as const).map(sev => (
{SEVERITY_LABELS[sev]}
0 ? `${(severityDist[sev] / severityMax) * 100}%` : '0%' }} />
{severityDist[sev]}
))}
{/* Site comparison */}

Incidents by Site

{by_site.length === 0 ? (

No data

) : (
{by_site.map(({ name, count }) => (
{name} {count}
))}
)}
) } ``` - [ ] **Step 2: Verify build** ```bash npm run build 2>&1 | tail -20 ``` Expected: `✓ Compiled successfully` with no TS errors. - [ ] **Step 3: Commit** ```bash git add app/\(protected\)/management/page.tsx git commit -m "feat: management dashboard — month delta, LTI, severity distribution, site comparison" ``` --- ### Task 2: Supervisor dashboard Replace the nav-links placeholder at `app/(protected)/supervisor/page.tsx` with a site-scoped data dashboard. Supervisor sees only incidents and CAPAs for their assigned site (`users.site_id`). **Files:** - Modify: `app/(protected)/supervisor/page.tsx` **Interfaces:** - Consumes: `users.site_id` (to scope queries), `incidents` (filtered by site), `capa_actions` (via incident join for site) - Produces: visible supervisor dashboard at `/supervisor` - [ ] **Step 1: Replace `app/(protected)/supervisor/page.tsx`** ```typescript export const dynamic = 'force-dynamic' import Link from 'next/link' import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { StatCard } from '@/components/dashboard/stat-card' const STATUS_LABELS: Record = { reported: 'Reported', triaged: 'Triaged', investigating: 'Investigating', capa_pending: 'CAPA Pending', verification: 'Verification', closed: 'Closed', } const STATUS_COLORS: Record = { reported: 'bg-gray-100 text-gray-700', triaged: 'bg-yellow-100 text-yellow-800', investigating: 'bg-blue-100 text-blue-700', capa_pending: 'bg-orange-100 text-orange-700', verification: 'bg-purple-100 text-purple-700', closed: 'bg-green-100 text-green-700', } 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, site_id, role') .eq('id', user.id) .single() if (!profile || !['supervisor', 'admin'].includes(profile.role)) redirect('/') if (!profile.site_id) { return (

Supervisor Portal

No site assigned — contact your administrator.

) } const { data: siteRow } = await supabase.from('sites').select('name').eq('id', profile.site_id).single() const siteName = (siteRow as unknown as { name: string } | null)?.name ?? 'Your Site' const [ { data: openIncidents }, { data: closedIncidents }, { data: overdueCapas }, { data: allCapas }, ] = await Promise.all([ supabase .from('incidents') .select('id, reference_no, incident_type, status, reported_at') .eq('site_id', profile.site_id) .neq('status', 'closed') .order('reported_at', { ascending: false }) .limit(10), supabase .from('incidents') .select('id') .eq('site_id', profile.site_id) .eq('status', 'closed'), supabase .from('capa_actions') .select('id, description, due_date, users (name)') .eq('status', 'overdue') .in( 'incident_id', (await supabase.from('incidents').select('id').eq('site_id', profile.site_id)).data?.map(r => r.id) ?? [] ), supabase .from('capa_actions') .select('id, status') .in( 'incident_id', (await supabase.from('incidents').select('id').eq('site_id', profile.site_id)).data?.map(r => r.id) ?? [] ), ]) const openCount = openIncidents?.length ?? 0 const closedCount = closedIncidents?.length ?? 0 const overdueCount = overdueCapas?.length ?? 0 const capaRows = allCapas ?? [] const capaOpenCount = capaRows.filter(c => ['open', 'in_progress'].includes(c.status)).length const capaVerifiedCount = capaRows.filter(c => c.status === 'verified').length const TYPE_LABELS: Record = { injury: 'Injury', near_miss: 'Near Miss', hazard: 'Hazard', asset_damage: 'Asset Damage', environmental: 'Environmental', security: 'Security', fire: 'Fire', } return (

Supervisor Portal

{siteName}

All incidents →
{/* Stats */}
0 ? 'yellow' : 'green'} /> 0 ? 'yellow' : 'green'} /> 0 ? 'red' : 'green'} />
{/* Open incidents */}

Open Incidents

{(openIncidents ?? []).length === 0 ? (

No open incidents.

) : (
{(openIncidents ?? []).map(inc => (

{inc.reference_no ?? inc.id.slice(0, 8)}

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

{STATUS_LABELS[inc.status] ?? inc.status} ))}
)}
{/* Overdue CAPAs */} {overdueCount > 0 && (

Overdue CAPAs

{(overdueCapas ?? []).map(capa => (

{capa.description}

Owner: {(capa.users as unknown as { name: string } | null)?.name ?? 'Unassigned'}

Due {new Date(capa.due_date as string).toLocaleDateString('en-MY')}
))}

CAPA on-time rate: {capaRows.length > 0 ? Math.round((capaVerifiedCount / capaRows.length) * 100) : 'N/A'}%

)}
) } ``` - [ ] **Step 2: Verify build** ```bash npm run build 2>&1 | tail -20 ``` Expected: `✓ Compiled successfully` - [ ] **Step 3: Commit** ```bash git add app/\(protected\)/supervisor/page.tsx git commit -m "feat: supervisor dashboard — site-scoped open incidents, overdue CAPAs" ``` --- ### Task 3: CAPA Owner dashboard Replace the placeholder at `app/(protected)/capa-owner/page.tsx` with a user-scoped CAPA list showing the authenticated user's assigned actions. **Files:** - Modify: `app/(protected)/capa-owner/page.tsx` **Interfaces:** - Consumes: `capa_actions.owner_user_id = auth.uid()`, `incidents` (for reference_no) - Produces: visible CAPA owner dashboard at `/capa-owner` - [ ] **Step 1: Replace `app/(protected)/capa-owner/page.tsx`** ```typescript export const dynamic = 'force-dynamic' import Link from 'next/link' import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { StatCard } from '@/components/dashboard/stat-card' const STATUS_LABELS: Record = { open: 'Open', in_progress: 'In Progress', overdue: 'Overdue', pending_verification: 'Pending Verification', verified: 'Verified', reopened: 'Reopened', closed: 'Closed', } const STATUS_COLORS: Record = { open: 'bg-gray-100 text-gray-700', in_progress: 'bg-blue-100 text-blue-700', overdue: 'bg-red-100 text-red-700', pending_verification: 'bg-purple-100 text-purple-700', verified: 'bg-green-100 text-green-700', reopened: 'bg-orange-100 text-orange-700', closed: 'bg-gray-100 text-gray-500', } export default async function CapaOwnerPage() { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) redirect('/login') const { data: profile } = await supabase.from('users').select('name, role').eq('id', user.id).single() if (!profile || !['capa_owner', 'admin', 'hse', 'supervisor'].includes(profile.role)) redirect('/') const { data: capas } = await supabase .from('capa_actions') .select('id, description, due_date, priority, status, incident_id, incidents (reference_no)') .eq('owner_user_id', user.id) .order('due_date', { ascending: true }) const rows = capas ?? [] const openCount = rows.filter(c => ['open', 'in_progress', 'reopened'].includes(c.status)).length const overdueCount = rows.filter(c => c.status === 'overdue').length const doneCount = rows.filter(c => ['verified', 'closed'].includes(c.status)).length const PRIORITY_COLORS: Record = { high: 'text-red-600 font-semibold', med: 'text-orange-500 font-medium', low: 'text-gray-400', } const activeRows = rows.filter(c => !['verified', 'closed'].includes(c.status)) const doneRows = rows.filter(c => ['verified', 'closed'].includes(c.status)) return (

My CAPAs

0 ? 'yellow' : 'green'} /> 0 ? 'red' : 'green'} />
{activeRows.length === 0 ? (

No active CAPAs assigned to you.

) : (
{activeRows.map(capa => { const incRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no const isOverdue = capa.status === 'overdue' return (

{capa.description}

{incRef && ( {incRef} )}
{STATUS_LABELS[capa.status] ?? capa.status}

Due {new Date(capa.due_date as string).toLocaleDateString('en-MY')}

{(capa.priority as string)?.toUpperCase()} priority

) })}
)} {doneRows.length > 0 && (

Completed ({doneCount})

{doneRows.map(capa => (

{capa.description}

{STATUS_LABELS[capa.status] ?? capa.status}
))}
)}
) } ``` - [ ] **Step 2: Verify build** ```bash npm run build 2>&1 | tail -20 ``` Expected: `✓ Compiled successfully` - [ ] **Step 3: Commit** ```bash git add app/\(protected\)/capa-owner/page.tsx git commit -m "feat: CAPA owner dashboard — user-scoped active and completed CAPAs" ``` --- ### Task 4: Reporter dashboard Replace the placeholder at `app/(protected)/reporter/page.tsx` with a user-scoped incident list showing submissions by the authenticated reporter. **Files:** - Modify: `app/(protected)/reporter/page.tsx` **Interfaces:** - Consumes: `incidents.reported_by = auth.uid()` - Produces: visible reporter dashboard at `/reporter` - [ ] **Step 1: Replace `app/(protected)/reporter/page.tsx`** ```typescript export const dynamic = 'force-dynamic' import Link from 'next/link' import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' const STATUS_LABELS: Record = { reported: 'Reported', triaged: 'Triaged', investigating: 'Investigating', capa_pending: 'CAPA Pending', verification: 'Verification', closed: 'Closed', } const STATUS_COLORS: Record = { reported: 'bg-gray-100 text-gray-600', triaged: 'bg-yellow-100 text-yellow-800', investigating: 'bg-blue-100 text-blue-700', capa_pending: 'bg-orange-100 text-orange-700', verification: 'bg-purple-100 text-purple-700', closed: 'bg-green-100 text-green-700', } const TYPE_LABELS: Record = { injury: 'Injury', near_miss: 'Near Miss', hazard: 'Hazard', asset_damage: 'Asset Damage', environmental: 'Environmental', security: 'Security', fire: 'Fire', } export default async function ReporterPage() { 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() const { data: incidents } = await supabase .from('incidents') .select('id, reference_no, incident_type, status, reported_at, severity, sites (name)') .eq('reported_by', user.id) .order('reported_at', { ascending: false }) const rows = incidents ?? [] const openCount = rows.filter(r => r.status !== 'closed').length const closedCount = rows.filter(r => r.status === 'closed').length return (

My Reports

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

+ New Report

{rows.length}

Total Submitted

{openCount}

In Progress

{closedCount}

Closed

{rows.length === 0 ? (

No incidents reported yet.

Submit your first report →
) : (
{rows.map(inc => { const siteName = (inc.sites as unknown as { name: string } | null)?.name return (

{inc.reference_no ?? inc.id.slice(0, 8)}

{TYPE_LABELS[inc.incident_type] ?? inc.incident_type} {siteName ? ` · ${siteName}` : ''} {inc.severity ? ` · Severity ${inc.severity}` : ''}

{new Date(inc.reported_at as string).toLocaleDateString('en-MY', { day: 'numeric', month: 'short', year: 'numeric', })}

{STATUS_LABELS[inc.status] ?? inc.status}
) })}
)}
) } ``` - [ ] **Step 2: Verify build** ```bash npm run build 2>&1 | tail -20 ``` Expected: `✓ Compiled successfully` - [ ] **Step 3: Commit** ```bash git add app/\(protected\)/reporter/page.tsx git commit -m "feat: reporter dashboard — user-scoped incident submissions with status" ``` --- ### Task 5: CSV export endpoint Single API route with a `role` query param. HSE export includes all incidents with key fields. Management export includes the same but adds severity column. Both require the requesting user to be HSE/admin or management/admin respectively. **Files:** - Create: `app/api/dashboard/export/route.ts` **Interfaces:** - Produces: `GET /api/dashboard/export?role=hse` and `GET /api/dashboard/export?role=management` → `Content-Type: text/csv` response - [ ] **Step 1: Create `app/api/dashboard/export/route.ts`** ```typescript export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' function escapeCsv(value: string | number | null | undefined): string { if (value === null || value === undefined) return '' const str = String(value) if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"` } return str } function rowsToCsv(headers: string[], rows: string[][]): string { const lines = [headers.map(escapeCsv).join(',')] for (const row of rows) lines.push(row.map(escapeCsv).join(',')) return lines.join('\r\n') } export async function GET(request: NextRequest) { const supabase = await createClient() const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() if (!profile) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) const role = request.nextUrl.searchParams.get('role') ?? 'hse' const allowedRoles: Record = { hse: ['hse', 'admin'], management: ['management', 'admin'], } if (!allowedRoles[role] || !allowedRoles[role].includes(profile.role)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } const { data: incidents } = await supabase .from('incidents') .select(` reference_no, incident_type, status, severity, reported_at, closed_at, injury_involved, medical_status, lost_days, sites (name), zones (name) `) .order('reported_at', { ascending: false }) const rows = incidents ?? [] const headers = [ 'Reference', 'Type', 'Status', 'Severity', 'Site', 'Zone', 'Reported At', 'Closed At', 'Injury Involved', 'Medical Status', 'Lost Days', ] const csvRows = rows.map(inc => { const siteName = (inc.sites as unknown as { name: string } | null)?.name ?? '' const zoneName = (inc.zones as unknown as { name: string } | null)?.name ?? '' return [ inc.reference_no ?? '', inc.incident_type, inc.status, String(inc.severity ?? ''), siteName, zoneName, inc.reported_at ? new Date(inc.reported_at as string).toISOString().split('T')[0] : '', inc.closed_at ? new Date(inc.closed_at as string).toISOString().split('T')[0] : '', inc.injury_involved ? 'Yes' : 'No', inc.medical_status ?? '', String(inc.lost_days ?? ''), ] }) const csv = rowsToCsv(headers, csvRows) const filename = `incidents-${role}-${new Date().toISOString().split('T')[0]}.csv` return new NextResponse(csv, { status: 200, headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${filename}"`, }, }) } ``` - [ ] **Step 2: Add export link to HSE dashboard** In `app/(protected)/hse/dashboard/page.tsx`, replace the existing header `
` (the one with "View all incidents →") with: ```tsx

Dashboard

Export CSV View all incidents →
``` - [ ] **Step 3: Add export link to Management dashboard** In `app/(protected)/management/page.tsx`, replace `

Management Dashboard

` with: ```tsx

Management Dashboard

Export CSV
``` - [ ] **Step 4: Verify build** ```bash npm run build 2>&1 | tail -20 ``` Expected: `✓ Compiled successfully` - [ ] **Step 5: Commit** ```bash git add app/api/dashboard/export/route.ts app/\(protected\)/hse/dashboard/page.tsx app/\(protected\)/management/page.tsx git commit -m "feat: CSV export endpoint for HSE and management dashboards" ``` --- ### Task 6: RCA draft test + final verification Add the missing test for the RCA draft route, run the full suite, and update the progress ledger. **Files:** - Create: `tests/api/incidents/rca-draft.test.ts` **Interfaces:** - Consumes: `app/api/incidents/[id]/ai/rca-draft/route.ts` (already exists) - [ ] **Step 1: Write RCA draft test** Create `tests/api/incidents/rca-draft.test.ts`: ```typescript import { describe, it, expect, vi } from 'vitest' vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn().mockResolvedValue({ auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }), }, from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), single: vi.fn() .mockResolvedValueOnce({ data: { role: 'hse' } }) .mockResolvedValueOnce({ data: { id: 'inc-1', incident_type: 'injury', description: 'Forklift operator collided with racking in zone B, injuring left arm.', severity: 3, injury_involved: true, medical_status: 'medical_treatment', is_fatality: false, is_serious_bodily_injury: false, triage_notes: null, sites: { name: 'Warehouse A' }, zones: { name: 'Zone B' }, }, }), }), rpc: vi.fn().mockResolvedValue({ error: null }), }), })) vi.mock('@/lib/claude/client', () => ({ anthropic: { messages: { create: vi.fn().mockResolvedValue({ content: [{ type: 'tool_use', name: 'draft_rca', input: { five_why_steps: [ { why: 'Why did the incident happen?', answer: 'Forklift entered zone B without checking for pedestrians.' }, { why: 'Why was there no check?', answer: 'No pedestrian exclusion zone marked at zone B entrance.' }, { why: 'Why was it not marked?', answer: 'Site hazard assessment did not include zone B forklift route.' }, ], root_cause_summary: 'Absence of pedestrian exclusion zone and forklift route hazard assessment in zone B.', capa_suggestions: [ 'Mark pedestrian exclusion zones at all forklift routes in zone B within 7 days.', 'Update site hazard assessment to include forklift routes in all zones.', 'Conduct forklift safety refresher for all operators within 30 days.', ], }, }], }), }, }, })) describe('POST /api/incidents/[id]/ai/rca-draft', () => { it('returns five_why_steps, root_cause_summary, and capa_suggestions', async () => { const { POST } = await import('@/app/api/incidents/[id]/ai/rca-draft/route') const req = new Request('http://localhost/api/incidents/inc-1/ai/rca-draft', { method: 'POST' }) const res = await POST(req as never, { params: Promise.resolve({ id: 'inc-1' }) }) expect(res.status).toBe(200) const body = await res.json() expect(body).toHaveProperty('five_why_steps') expect(Array.isArray(body.five_why_steps)).toBe(true) expect(body.five_why_steps.length).toBeGreaterThanOrEqual(1) expect(body).toHaveProperty('root_cause_summary') expect(typeof body.root_cause_summary).toBe('string') expect(body).toHaveProperty('capa_suggestions') expect(Array.isArray(body.capa_suggestions)).toBe(true) }) }) ``` - [ ] **Step 2: Run test to verify it passes** ```bash npx vitest run tests/api/incidents/rca-draft.test.ts ``` Expected: 1 passed - [ ] **Step 3: Run full test suite** ```bash npx vitest run ``` Expected: all tests pass - [ ] **Step 4: Full build** ```bash npm run build 2>&1 | tail -30 ``` Expected: `✓ Compiled successfully`, zero TS/ESLint errors - [ ] **Step 5: Update graphify graph** ```bash graphify update . ``` Expected: graph updated with new dashboard pages and export route - [ ] **Step 6: Commit** ```bash git add tests/api/incidents/rca-draft.test.ts git commit -m "test: RCA draft route test" ``` - [ ] **Step 7: Update progress ledger** Append to `.superpowers/sdd/progress.md` (create file if missing): ```markdown --- # Phase 3 Progress Ledger Plan: docs/superpowers/plans/2026-07-11-phase-3-ai-dashboard.md Started: 2026-07-11 ## AI Features (complete before this plan) - [x] lib/claude/client.ts - [x] lib/claude/embed.ts - [x] Report quality check (API + report-form.tsx) - [x] Triage AI suggestion (API + triage-form.tsx) - [x] RCA/CAPA drafting assistant (API + investigation-form.tsx) - [x] HSE dashboard enhanced (leading/lagging, zone heatmap, CAPA on-time, DOSH filing) ## Dashboard Upgrades (this plan) - [x] Task 1: Management dashboard - [x] Task 2: Supervisor dashboard (site-scoped) - [x] Task 3: CAPA Owner dashboard (user-scoped) - [x] Task 4: Reporter dashboard (user-scoped) - [x] Task 5: CSV export endpoint - [x] Task 6: RCA draft test + final verification ``` ```bash git add .superpowers/sdd/progress.md git commit -m "chore: mark Phase 3 complete in progress ledger" ``` --- ## Post-Implementation Notes - Supervisor dashboard runs two identical `incidents.select('id').eq('site_id', ...)` queries to build the CAPA `incident_id` filter — acceptable at current scale. If the CAPA count grows large, move to a Supabase RPC that does the join server-side. - The CSV export streams the full incident table — fine for hundreds of rows; add pagination or a date-range filter if exports slow down at thousands. - `similar-incidents-panel.tsx` exists in the repo from a prior session. It is intentionally not wired into the incident detail page in this plan — similar-incident retrieval is Phase 4 (requires pgvector + Voyage AI keys).