Files
ims/docs/superpowers/plans/2026-07-11-phase-3-ai-dashboard.md
T

39 KiB

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

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<number, number> = { 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<string, number> = {}
  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<number, string> = { 1: 'Minor', 2: 'Low', 3: 'Moderate', 4: 'Serious', 5: 'Critical' }
  const SEVERITY_COLORS: Record<number, string> = {
    1: 'bg-green-400', 2: 'bg-yellow-400', 3: 'bg-orange-400', 4: 'bg-red-500', 5: 'bg-red-700',
  }

  return (
    <main className="max-w-4xl mx-auto px-4 py-6">
      <h1 className="text-2xl font-bold text-gray-900 mb-6">Management Dashboard</h1>

      {/* Summary stats */}
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
        <StatCard
          label="This Month"
          value={totalThisMonth}
          sub={monthDelta >= 0 ? `+${monthDelta} vs last month` : `${monthDelta} vs last month`}
          accent={monthDelta > 0 ? 'yellow' : 'green'}
        />
        <StatCard label="LTI Count" value={ltiCount} accent={ltiCount > 0 ? 'red' : 'green'} sub="lost-time injuries" />
        <StatCard label="CAPA Overdue" value={overdueCount} accent={overdueCount > 0 ? 'red' : 'green'} sub="past due date" />
        <StatCard label="Total (All Time)" value={rows.length} />
      </div>

      {/* Leading vs lagging */}
      <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-1">Leading vs Lagging  Last 30 Days</h2>
        <p className="text-xs text-gray-400 mb-4">Leading: hazard + near-miss · Lagging: injuries</p>
        <div className="flex gap-6">
          <div className="flex-1 text-center bg-blue-50 rounded-lg p-4">
            <p className="text-3xl font-bold text-blue-600">{leadingCount}</p>
            <p className="text-xs text-blue-700 mt-1">Leading (Hazards + Near Misses)</p>
          </div>
          <div className="flex-1 text-center bg-red-50 rounded-lg p-4">
            <p className="text-3xl font-bold text-red-600">{laggingCount}</p>
            <p className="text-xs text-red-700 mt-1">Lagging (Injuries)</p>
          </div>
        </div>
      </div>

      {/* Severity distribution */}
      <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">Severity Distribution (All Time)</h2>
        <div className="space-y-2">
          {([5, 4, 3, 2, 1] as const).map(sev => (
            <div key={sev} className="flex items-center gap-3">
              <span className="text-sm text-gray-600 w-24 shrink-0">{SEVERITY_LABELS[sev]}</span>
              <div className="flex-1 bg-gray-100 rounded-full h-3">
                <div
                  className={`h-3 rounded-full ${SEVERITY_COLORS[sev]}`}
                  style={{ width: severityMax > 0 ? `${(severityDist[sev] / severityMax) * 100}%` : '0%' }}
                />
              </div>
              <span className="text-sm font-semibold text-gray-900 w-6 text-right">{severityDist[sev]}</span>
            </div>
          ))}
        </div>
      </div>

      {/* Site comparison */}
      <div className="bg-white rounded-xl shadow-sm p-5">
        <h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Incidents by Site</h2>
        {by_site.length === 0 ? (
          <p className="text-sm text-gray-400">No data</p>
        ) : (
          <div className="space-y-2">
            {by_site.map(({ name, count }) => (
              <div key={name} className="flex items-center justify-between py-1 border-b border-gray-50 last:border-0">
                <span className="text-sm text-gray-700">{name}</span>
                <span className="text-sm font-semibold text-gray-900">{count}</span>
              </div>
            ))}
          </div>
        )}
      </div>
    </main>
  )
}
  • Step 2: Verify build
npm run build 2>&1 | tail -20

Expected: ✓ Compiled successfully with no TS errors.

  • Step 3: Commit
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

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<string, string> = {
  reported: 'Reported',
  triaged: 'Triaged',
  investigating: 'Investigating',
  capa_pending: 'CAPA Pending',
  verification: 'Verification',
  closed: 'Closed',
}

const STATUS_COLORS: Record<string, string> = {
  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 (
      <main className="max-w-4xl mx-auto px-4 py-6">
        <h1 className="text-2xl font-bold text-gray-900 mb-4">Supervisor Portal</h1>
        <p className="text-sm text-gray-500">No site assigned  contact your administrator.</p>
      </main>
    )
  }

  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<string, string> = {
    injury: 'Injury', near_miss: 'Near Miss', hazard: 'Hazard',
    asset_damage: 'Asset Damage', environmental: 'Environmental', security: 'Security', fire: 'Fire',
  }

  return (
    <main className="max-w-4xl mx-auto px-4 py-6">
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-gray-900">Supervisor Portal</h1>
          <p className="text-sm text-gray-500 mt-1">{siteName}</p>
        </div>
        <Link href="/supervisor/incidents" className="text-sm text-blue-600 hover:underline">
          All incidents 
        </Link>
      </div>

      {/* Stats */}
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
        <StatCard label="Open Incidents" value={openCount} accent={openCount > 0 ? 'yellow' : 'green'} />
        <StatCard label="Closed Incidents" value={closedCount} accent="green" />
        <StatCard label="Open CAPAs" value={capaOpenCount} accent={capaOpenCount > 0 ? 'yellow' : 'green'} />
        <StatCard label="CAPA Overdue" value={overdueCount} accent={overdueCount > 0 ? 'red' : 'green'} />
      </div>

      {/* Open incidents */}
      <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">Open Incidents</h2>
        {(openIncidents ?? []).length === 0 ? (
          <p className="text-sm text-gray-400">No open incidents.</p>
        ) : (
          <div className="space-y-2">
            {(openIncidents ?? []).map(inc => (
              <Link
                key={inc.id}
                href={`/supervisor/incidents/${inc.id}`}
                className="flex items-center justify-between py-2 border-b border-gray-50 last:border-0 hover:bg-gray-50 -mx-2 px-2 rounded"
              >
                <div>
                  <p className="text-sm font-medium text-gray-900">{inc.reference_no ?? inc.id.slice(0, 8)}</p>
                  <p className="text-xs text-gray-500">{TYPE_LABELS[inc.incident_type] ?? inc.incident_type}</p>
                </div>
                <span className={`text-xs rounded-full px-2 py-0.5 font-medium ${STATUS_COLORS[inc.status] ?? 'bg-gray-100 text-gray-700'}`}>
                  {STATUS_LABELS[inc.status] ?? inc.status}
                </span>
              </Link>
            ))}
          </div>
        )}
      </div>

      {/* Overdue CAPAs */}
      {overdueCount > 0 && (
        <div className="bg-white rounded-xl shadow-sm p-5">
          <h2 className="text-sm font-semibold text-red-600 uppercase tracking-wide mb-4">Overdue CAPAs</h2>
          <div className="space-y-2">
            {(overdueCapas ?? []).map(capa => (
              <div key={capa.id} className="flex items-start justify-between py-2 border-b border-gray-50 last:border-0">
                <div className="flex-1 min-w-0 mr-4">
                  <p className="text-sm text-gray-800 line-clamp-2">{capa.description}</p>
                  <p className="text-xs text-gray-500 mt-0.5">
                    Owner: {(capa.users as unknown as { name: string } | null)?.name ?? 'Unassigned'}
                  </p>
                </div>
                <span className="text-xs text-red-600 font-semibold shrink-0">
                  Due {new Date(capa.due_date as string).toLocaleDateString('en-MY')}
                </span>
              </div>
            ))}
          </div>
          <p className="text-xs text-gray-400 mt-3">CAPA on-time rate: {capaRows.length > 0 ? Math.round((capaVerifiedCount / capaRows.length) * 100) : 'N/A'}%</p>
        </div>
      )}
    </main>
  )
}
  • Step 2: Verify build
npm run build 2>&1 | tail -20

Expected: ✓ Compiled successfully

  • Step 3: Commit
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

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<string, string> = {
  open: 'Open',
  in_progress: 'In Progress',
  overdue: 'Overdue',
  pending_verification: 'Pending Verification',
  verified: 'Verified',
  reopened: 'Reopened',
  closed: 'Closed',
}

const STATUS_COLORS: Record<string, string> = {
  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<string, string> = {
    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 (
    <main className="max-w-4xl mx-auto px-4 py-6">
      <h1 className="text-2xl font-bold text-gray-900 mb-6">My CAPAs</h1>

      <div className="grid grid-cols-3 gap-4 mb-6">
        <StatCard label="Active" value={openCount} accent={openCount > 0 ? 'yellow' : 'green'} />
        <StatCard label="Overdue" value={overdueCount} accent={overdueCount > 0 ? 'red' : 'green'} />
        <StatCard label="Completed" value={doneCount} accent="green" />
      </div>

      {activeRows.length === 0 ? (
        <div className="bg-white rounded-xl shadow-sm p-8 text-center">
          <p className="text-gray-400 text-sm">No active CAPAs assigned to you.</p>
        </div>
      ) : (
        <div className="bg-white rounded-xl shadow-sm divide-y divide-gray-50">
          {activeRows.map(capa => {
            const incRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no
            const isOverdue = capa.status === 'overdue'
            return (
              <div key={capa.id} className={`p-4 ${isOverdue ? 'bg-red-50' : ''}`}>
                <div className="flex items-start justify-between gap-4">
                  <div className="flex-1 min-w-0">
                    <p className="text-sm text-gray-800">{capa.description}</p>
                    {incRef && (
                      <Link href={`/hse/incidents/${capa.incident_id}`} className="text-xs text-blue-600 hover:underline mt-0.5 block">
                        {incRef}
                      </Link>
                    )}
                  </div>
                  <div className="shrink-0 text-right">
                    <span className={`text-xs rounded-full px-2 py-0.5 ${STATUS_COLORS[capa.status] ?? 'bg-gray-100 text-gray-600'}`}>
                      {STATUS_LABELS[capa.status] ?? capa.status}
                    </span>
                    <p className={`text-xs mt-1 ${isOverdue ? 'text-red-600 font-semibold' : 'text-gray-400'}`}>
                      Due {new Date(capa.due_date as string).toLocaleDateString('en-MY')}
                    </p>
                    <p className={`text-xs mt-0.5 ${PRIORITY_COLORS[capa.priority as string] ?? ''}`}>
                      {(capa.priority as string)?.toUpperCase()} priority
                    </p>
                  </div>
                </div>
              </div>
            )
          })}
        </div>
      )}

      {doneRows.length > 0 && (
        <div className="mt-6">
          <h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Completed ({doneCount})</h2>
          <div className="bg-white rounded-xl shadow-sm divide-y divide-gray-50 opacity-60">
            {doneRows.map(capa => (
              <div key={capa.id} className="p-4 flex items-center justify-between">
                <p className="text-sm text-gray-500 line-clamp-1">{capa.description}</p>
                <span className={`text-xs rounded-full px-2 py-0.5 ml-4 shrink-0 ${STATUS_COLORS[capa.status] ?? 'bg-gray-100'}`}>
                  {STATUS_LABELS[capa.status] ?? capa.status}
                </span>
              </div>
            ))}
          </div>
        </div>
      )}
    </main>
  )
}
  • Step 2: Verify build
npm run build 2>&1 | tail -20

Expected: ✓ Compiled successfully

  • Step 3: Commit
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

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<string, string> = {
  reported: 'Reported',
  triaged: 'Triaged',
  investigating: 'Investigating',
  capa_pending: 'CAPA Pending',
  verification: 'Verification',
  closed: 'Closed',
}

const STATUS_COLORS: Record<string, string> = {
  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<string, string> = {
  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 (
    <main className="max-w-2xl mx-auto px-4 py-6">
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-gray-900">My Reports</h1>
          <p className="text-sm text-gray-400 mt-0.5">Welcome, {profile?.name ?? user.email}</p>
        </div>
        <Link
          href="/report"
          className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-blue-700"
        >
          + New Report
        </Link>
      </div>

      <div className="flex gap-4 mb-6 text-sm">
        <div className="bg-white rounded-xl shadow-sm p-4 flex-1 text-center">
          <p className="text-2xl font-bold text-gray-900">{rows.length}</p>
          <p className="text-xs text-gray-500 mt-1">Total Submitted</p>
        </div>
        <div className="bg-white rounded-xl shadow-sm p-4 flex-1 text-center">
          <p className="text-2xl font-bold text-yellow-600">{openCount}</p>
          <p className="text-xs text-gray-500 mt-1">In Progress</p>
        </div>
        <div className="bg-white rounded-xl shadow-sm p-4 flex-1 text-center">
          <p className="text-2xl font-bold text-green-600">{closedCount}</p>
          <p className="text-xs text-gray-500 mt-1">Closed</p>
        </div>
      </div>

      {rows.length === 0 ? (
        <div className="bg-white rounded-xl shadow-sm p-8 text-center">
          <p className="text-gray-400 text-sm mb-4">No incidents reported yet.</p>
          <Link href="/report" className="text-blue-600 text-sm hover:underline">Submit your first report </Link>
        </div>
      ) : (
        <div className="bg-white rounded-xl shadow-sm divide-y divide-gray-50">
          {rows.map(inc => {
            const siteName = (inc.sites as unknown as { name: string } | null)?.name
            return (
              <div key={inc.id} className="p-4">
                <div className="flex items-start justify-between gap-3">
                  <div className="flex-1 min-w-0">
                    <p className="text-sm font-semibold text-gray-900">
                      {inc.reference_no ?? inc.id.slice(0, 8)}
                    </p>
                    <p className="text-xs text-gray-500 mt-0.5">
                      {TYPE_LABELS[inc.incident_type] ?? inc.incident_type}
                      {siteName ? ` · ${siteName}` : ''}
                      {inc.severity ? ` · Severity ${inc.severity}` : ''}
                    </p>
                    <p className="text-xs text-gray-400 mt-0.5">
                      {new Date(inc.reported_at as string).toLocaleDateString('en-MY', {
                        day: 'numeric', month: 'short', year: 'numeric',
                      })}
                    </p>
                  </div>
                  <span className={`text-xs rounded-full px-2 py-0.5 shrink-0 ${STATUS_COLORS[inc.status] ?? 'bg-gray-100 text-gray-600'}`}>
                    {STATUS_LABELS[inc.status] ?? inc.status}
                  </span>
                </div>
              </div>
            )
          })}
        </div>
      )}
    </main>
  )
}
  • Step 2: Verify build
npm run build 2>&1 | tail -20

Expected: ✓ Compiled successfully

  • Step 3: Commit
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=managementContent-Type: text/csv response

  • Step 1: Create app/api/dashboard/export/route.ts

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<string, string[]> = {
    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 <div> (the one with "View all incidents →") with:

<div className="flex items-center justify-between mb-6">
  <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
  <div className="flex gap-3">
    <a
      href="/api/dashboard/export?role=hse"
      className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
    >
      Export CSV
    </a>
    <Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
      View all incidents 
    </Link>
  </div>
</div>
  • Step 3: Add export link to Management dashboard

In app/(protected)/management/page.tsx, replace <h1 className="text-2xl font-bold text-gray-900 mb-6">Management Dashboard</h1> with:

<div className="flex items-center justify-between mb-6">
  <h1 className="text-2xl font-bold text-gray-900">Management Dashboard</h1>
  <a
    href="/api/dashboard/export?role=management"
    className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
  >
    Export CSV
  </a>
</div>
  • Step 4: Verify build
npm run build 2>&1 | tail -20

Expected: ✓ Compiled successfully

  • Step 5: Commit
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:

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
npx vitest run tests/api/incidents/rca-draft.test.ts

Expected: 1 passed

  • Step 3: Run full test suite
npx vitest run

Expected: all tests pass

  • Step 4: Full build
npm run build 2>&1 | tail -30

Expected: ✓ Compiled successfully, zero TS/ESLint errors

  • Step 5: Update graphify graph
graphify update .

Expected: graph updated with new dashboard pages and export route

  • Step 6: Commit
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):

---

# 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
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).