diff --git a/docs/superpowers/plans/2026-07-11-phase-3-ai-dashboard.md b/docs/superpowers/plans/2026-07-11-phase-3-ai-dashboard.md new file mode 100644 index 0000000..c16e60c --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-phase-3-ai-dashboard.md @@ -0,0 +1,1043 @@ +# 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).