Handover workspace

ERS, Todo, OfferReview, and Docu in one view

Imported from live server docs, code structure, and deployment notes.

Apr 3, 2026, 12:38 PM

OfferReview

W5: Role-Based Dashboard - Implementation Guide

W5 implements a role-aware dashboard that serves as the authenticated home screen for all users. Each role (HR, Hiring Manager, SMO, Admin) sees a customized view with:

docs/W5-DASHBOARD-IMPLEMENTATION.md

Updated Feb 19, 2026, 6:59 AM

Codex 5.3 Refactor Note: Canonical refactor plan: docs/CODEX-5.3-REFACTOR-PLAN.md. This document is retained for historical and implementation context during the refactor.

W5: Role-Based Dashboard - Implementation Guide

A. Summary

W5 implements a role-aware dashboard that serves as the authenticated home screen for all users. Each role (HR, Hiring Manager, SMO, Admin) sees a customized view with:

  • Role-specific KPI cards (key metrics relevant to their stage in workflow)
  • "My Queue" table (candidates requiring action from that role)
  • Primary CTAs (main action for the role: upload resume, view queue, open scorecard, etc.)
  • Secondary links (drill-down to W11 Candidate List, W3 Access Requests, etc.)

The dashboard provides a unified entry point to the hiring workflow, showing immediate actionable items without heavy analytics (those come in W18).

Key principle: Each role sees only candidates relevant to their stage and responsibilities.


B. Routes & Navigation

Page Route

  • URL: /dashboard
  • Access: Authenticated users only (HR, Manager, SMO, Admin)
  • Redirect: Logged-out users go to /login
  • Layout: Uses layout at /(app)/dashboard/layout.tsx (or shared app layout)

Navigation Data Flow

GET /dashboard (page.tsx loads)
  ↓
GET /api/dashboard (returns role-aware payload)
  ↓
Display KPI cards + My Queue
  ↓
User clicks action:
  ├─ HR "Start HR Screening" → /candidates/:id (W7 - candidate intake)
  ├─ HR "Assign Manager" → /candidates/:id (W7 - assign section)
  ├─ Manager "Open Scorecard" → /candidates/:id/scorecard (W8)
  ├─ SMO "Open Decision" → /candidates/:id/decision (W10)
  ├─ Any role "View All Candidates" → /candidates (W11)
  └─ Admin "Access Requests" → /admin/access-requests (W3)

URL Patterns

  • Dashboard Page: /dashboard
  • Drill-down Candidate: /candidates/[code] (W7)
  • Candidate List: /candidates (W11)
  • Scorecard: /candidates/[code]/scorecard (W8)
  • Decision: /candidates/[code]/decision (W10)
  • Access Requests: /admin/access-requests (W3)

C. Data Model Changes

Prisma Schema

No new tables required for W5.

The dashboard uses existing data:

  • Candidate table (for status counts and queue data)
  • User table (for role and assignments)
  • Future: Settings table (W17) may contain SLA values; W5 uses hardcoded defaults

Existing Tables Used

model Candidate {
  id                   String   @id @default(cuid())
  code                 String   @unique
  fullName             String
  email                String   @unique
  phone                String?
  position             String   // "Applying for" field
  status               CandidateStatus // NEW, HR_SCREENED, MANAGER_EVAL_PENDING, TO_SMO, APPROVED, REJECTED, WITHDRAWN
  createdAt            DateTime @default(now())
  updatedAt            DateTime @updatedAt
  
  // Relationships
  hrScreeningId        String?
  managerAssignedId    String?  // Manager ID for MANAGER_EVAL_PENDING
  managerReviewId      String?
  smoDecisionId        String?
  smoReviewId          String?
  
  hrScreening          HrScreening?   @relation(fields: [hrScreeningId], references: [id])
  managerAssigned      User?          @relation("ManagerAssigned", fields: [managerAssignedId], references: [id])
  managerReview        ManagerReview? @relation(fields: [managerReviewId], references: [id])
  smoDecision          SmoDecision?   @relation(fields: [smoDecisionId], references: [id])
}

enum CandidateStatus {
  NEW                    // Just created, needs HR screening
  HR_SCREENED            // HR reviewed, waiting for manager assignment
  MANAGER_EVAL_PENDING   // Assigned to manager, awaiting review
  TO_SMO                 // Manager approved, sent to SMO for decision
  APPROVED               // SMO approved
  REJECTED               // Final rejection
  WITHDRAWN              // Candidate withdrew
}

model User {
  id       String @id @default(cuid())
  email    String @unique
  role     Role   // ADMIN, HR, MANAGER, SMO
  fullName String?
}

enum Role {
  ADMIN
  HR
  MANAGER
  SMO
}

No Migration Required

W5 uses existing schema. No new fields, enums, or tables.

SLA Settings (Future - W17)

// NOT needed for W5, but shown for context when W17 arrives
model Settings {
  id            String @id @default(cuid())
  organization  String @unique
  hrSlaHours    Int    @default(48)    // hours for NEW and HR_SCREENED
  managerSlaHours Int @default(72)     // hours for MANAGER_EVAL_PENDING
  smoSlaHours   Int    @default(48)    // hours for TO_SMO
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt
}

Overdue Defaults (Hardcoded for W5)

// In lib/dashboard/defaults.ts
export const OVERDUE_DEFAULTS = {
  HR: { new: 2, hrScreened: 2 },        // days
  MANAGER: { eval: 3 },                 // days
  SMO: { decision: 2 }                  // days
};

D. UI Components

File Structure

src/app/(app)/dashboard/
├── page.tsx                           (Main dashboard page)
├── layout.tsx                         (Optional: shared dashboard layout)
└── _components/
    ├── HRDashboard.tsx               (HR-specific layout)
    ├── ManagerDashboard.tsx          (Manager-specific layout)
    ├── SMODashboard.tsx              (SMO-specific layout)
    ├── AdminDashboard.tsx            (Admin-specific layout)
    ├── KpiCard.tsx                   (Reusable KPI display)
    ├── MyQueueTable.tsx              (Reusable queue table)
    └── DashboardHeader.tsx           (Reusable header)

1. Main Dashboard Page (/app/(app)/dashboard/page.tsx)

Type: Server Component (or Client if loading toast notifications)

Responsibilities:

  • Check authentication and role
  • Load dashboard data via /api/dashboard
  • Route to role-specific component
  • Handle loading/error states

Structure:

'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import HRDashboard from './_components/HRDashboard';
import ManagerDashboard from './_components/ManagerDashboard';
import SMODashboard from './_components/SMODashboard';
import AdminDashboard from './_components/AdminDashboard';

export default function DashboardPage() {
  const router = useRouter();
  const [role, setRole] = useState<string | null>(null);
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    const loadDashboard = async () => {
      try {
        const res = await fetch('/api/dashboard');
        if (res.status === 401) router.push('/login');
        if (!res.ok) throw new Error('Failed to load dashboard');
        const payload = await res.json();
        setRole(payload.role);
        setData(payload);
      } catch (err) {
        setError(err.message);
      } finally {
        setIsLoading(false);
      }
    };
    loadDashboard();
  }, [router]);

  if (isLoading) return <div className="p-4">Loading...</div>;
  if (error) return <div className="p-4 text-red-600">{error}</div>;

  // Route based on role
  switch (role) {
    case 'HR':
      return <HRDashboard data={data} />;
    case 'MANAGER':
      return <ManagerDashboard data={data} />;
    case 'SMO':
      return <SMODashboard data={data} />;
    case 'ADMIN':
      return <AdminDashboard data={data} />;
    default:
      return <div className="p-4">Unknown role</div>;
  }
}

2. HR Dashboard Component (HRDashboard.tsx)

Props:

interface HRDashboardProps {
  data: {
    role: 'HR';
    newCount: number;
    hrScreenedCount: number;
    totalActive: number;
    myQueue: Array<{
      id: string;
      code: string;
      fullName: string;
      position: string;
      status: 'NEW' | 'HR_SCREENED';
      createdAt: string;
      ageInStatus: number;
    }>;
    overdueCount: number;
  };
}

Layout:

┌─ Dashboard Header ──────────────────────┐
├─ KPI Cards (3 cards in row)             │
│  [NEW (red)]  [HR_SCREENED]  [Total]   │
├─ Primary CTA: Upload Resume (button)    │
├─ Secondary CTA: View Candidates         │
├─ "My Queue" table                       │
│  Cols: Name|Applying|Status|Age|Action  │
│  Rows: NEW + HR_SCREENED candidates     │
│  Actions:                               │
│    - NEW → "Start HR Screening"         │
│    - HR_SCREENED → "Assign Manager"     │
└─────────────────────────────────────────┘

Return JSX:

<div className="min-h-screen bg-gray-50">
  <DashboardHeader title="Dashboard" />
  
  <div className="max-w-7xl mx-auto px-4 py-8">
    {/* KPI Cards */}
    <div className="grid grid-cols-3 gap-4 mb-8">
      <KpiCard
        label="Needs HR Screening"
        value={data.newCount}
        color="red"
        icon="hourglass"
      />
      <KpiCard
        label="Needs Manager Assignment"
        value={data.hrScreenedCount}
        color="yellow"
        icon="users"
      />
      <KpiCard
        label="Total Active in Pipeline"
        value={data.totalActive}
        color="blue"
        icon="chart"
      />
    </div>

    {/* CTAs */}
    <div className="flex gap-4 mb-8">
      <button className="px-4 py-2 bg-blue-600 text-white rounded">
        Upload Resume
      </button>
      <button className="px-4 py-2 bg-gray-200 rounded">
        View Candidates
      </button>
    </div>

    {/* My Queue */}
    <MyQueueTable
      title="My Queue (Needs Your Action)"
      candidates={data.myQueue}
      columns={['name', 'position', 'status', 'age', 'action']}
      actions={{
        NEW: { label: 'Start HR Screening', href: '/candidates/:code' },
        HR_SCREENED: { label: 'Assign Manager', href: '/candidates/:code?tab=assign' }
      }}
    />
  </div>
</div>

3. Manager Dashboard Component (ManagerDashboard.tsx)

Props:

interface ManagerDashboardProps {
  data: {
    role: 'MANAGER';
    pendingCount: number;
    overdueCount: number;
    myQueue: Array<{
      id: string;
      code: string;
      fullName: string;
      position: string;
      assignedDate: string;
      ageInStatus: number;
    }>;
  };
}

Layout:

┌─ Dashboard Header ──────────────────────┐
├─ KPI Cards (2 cards)                    │
│  [MANAGER_EVAL_PENDING]  [Overdue]      │
├─ Primary CTA: View My Queue (link)      │
├─ "My Queue" table                       │
│  Cols: Name|Applying|Assigned|Age|Act   │
│  Rows: MANAGER_EVAL_PENDING for me      │
│  Action: "Open Scorecard"               │
└─────────────────────────────────────────┘

4. SMO Dashboard Component (SMODashboard.tsx)

Props:

interface SMODashboardProps {
  data: {
    role: 'SMO';
    toSmoCount: number;
    overdueCount: number;
    myQueue: Array<{
      id: string;
      code: string;
      fullName: string;
      position: string;
      createdAt: string;
      ageInStatus: number;
    }>;
  };
}

Layout:

┌─ Dashboard Header ──────────────────────┐
├─ KPI Cards (2 cards)                    │
│  [Pending Decisions]  [Overdue]         │
├─ "My Queue" table                       │
│  Cols: Name|Applying|Age|Action         │
│  Rows: TO_SMO candidates                │
│  Action: "Open Decision"                │
└─────────────────────────────────────────┘

5. Admin Dashboard Component (AdminDashboard.tsx)

Props:

interface AdminDashboardProps {
  data: {
    role: 'ADMIN';
    pendingAccessRequests: number;
    failedOutbox: number; // Optional if outbox exists
  };
}

Layout:

┌─ Dashboard Header ──────────────────────┐
├─ KPI Cards                              │
│  [Pending Access Requests]  [if outbox] │
├─ Primary CTA: Go to Access Requests     │
├─ Secondary CTAs:                        │
│  - Users (W4)                           │
│  - Audit (W14)                          │
└─────────────────────────────────────────┘

6. Reusable KPI Card Component (KpiCard.tsx)

interface KpiCardProps {
  label: string;
  value: number;
  color?: 'red' | 'yellow' | 'blue' | 'green';
  icon?: string;
}

// Returns: A card with large number, label, color-coded background

7. Reusable My Queue Table Component (MyQueueTable.tsx)

interface MyQueueTableProps {
  title: string;
  candidates: Array<{
    id: string;
    code: string;
    fullName: string;
    position: string;
    status?: string;
    ageInStatus: number;
    [key: string]: any;
  }>;
  columns: string[]; // ['name', 'position', 'status', 'age', 'action']
  actions: {
    [status: string]: {
      label: string;
      href: string; // '/candidates/:code' or '/candidates/:code/scorecard'
    };
  };
}

// Returns: Table with rows clickable, action button in last column

8. Dashboard Header Component (DashboardHeader.tsx)

interface DashboardHeaderProps {
  title: string;
}

// Returns: Simple header with title + greeting

E. API Logic

API Endpoint: GET /api/dashboard

File: /src/app/api/dashboard/route.ts

Authentication: Required (checks JWT in httpOnly cookie)

RBAC: Returns role-specific data

Request:

GET /api/dashboard
Cookie: auth_token=...

Response Structure (varies by role):

HR Response:

{
  "role": "HR",
  "newCount": 5,
  "hrScreenedCount": 3,
  "totalActive": 12,
  "overdueCount": 2,
  "myQueue": [
    {
      "id": "cand-001",
      "code": "CAND-001",
      "fullName": "Alice Johnson",
      "position": "Software Engineer",
      "status": "NEW",
      "createdAt": "2025-01-20T10:00:00Z",
      "ageInStatus": 1
    },
    {
      "id": "cand-002",
      "code": "CAND-002",
      "fullName": "Bob Smith",
      "position": "Product Manager",
      "status": "HR_SCREENED",
      "createdAt": "2025-01-19T14:30:00Z",
      "ageInStatus": 3
    }
  ]
}

Manager Response:

{
  "role": "MANAGER",
  "pendingCount": 4,
  "overdueCount": 1,
  "myQueue": [
    {
      "id": "cand-001",
      "code": "CAND-001",
      "fullName": "Carol White",
      "position": "Data Scientist",
      "assignedDate": "2025-01-18T09:00:00Z",
      "ageInStatus": 2
    }
  ]
}

SMO Response:

{
  "role": "SMO",
  "toSmoCount": 2,
  "overdueCount": 0,
  "myQueue": [
    {
      "id": "cand-003",
      "code": "CAND-003",
      "fullName": "Diana Lee",
      "position": "UX Designer",
      "createdAt": "2025-01-21T11:00:00Z",
      "ageInStatus": 0
    }
  ]
}

Admin Response:

{
  "role": "ADMIN",
  "pendingAccessRequests": 3,
  "failedOutbox": 0
}

Implementation Logic

import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth/rbac';
import { prisma } from '@/lib/prisma';
import { calculateOverdue } from '@/lib/dashboard/overdue';

export async function GET(request: NextRequest) {
  try {
    // 1. Verify authentication
    const authPayload = await requireAuth();
    if (!authPayload) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const { userId, role } = authPayload;

    // 2. Build role-specific response
    if (role === 'HR') {
      return getHRDashboard(userId);
    } else if (role === 'MANAGER') {
      return getManagerDashboard(userId);
    } else if (role === 'SMO') {
      return getSMODashboard(userId);
    } else if (role === 'ADMIN') {
      return getAdminDashboard();
    }

    return NextResponse.json({ error: 'Unknown role' }, { status: 400 });
  } catch (error) {
    console.error('Dashboard error:', error);
    return NextResponse.json(
      { error: 'Failed to load dashboard' },
      { status: 500 }
    );
  }
}

async function getHRDashboard(userId: string) {
  const [newCount, hrScreenedCount, totalActive, myQueue] = await Promise.all([
    prisma.candidate.count({ where: { status: 'NEW' } }),
    prisma.candidate.count({
      where: { status: 'HR_SCREENED', managerAssignedId: null }
    }),
    prisma.candidate.count({
      where: {
        status: { in: ['NEW', 'HR_SCREENED', 'MANAGER_EVAL_PENDING'] }
      }
    }),
    prisma.candidate.findMany({
      where: {
        status: { in: ['NEW', 'HR_SCREENED'] }
      },
      select: {
        id: true,
        code: true,
        fullName: true,
        position: true,
        status: true,
        createdAt: true
      },
      take: 20,
      orderBy: { createdAt: 'asc' }
    })
  ]);

  // Calculate age in status
  const now = new Date();
  const queueWithAge = myQueue.map((c) => ({
    ...c,
    ageInStatus: Math.floor(
      (now.getTime() - c.createdAt.getTime()) / (1000 * 60 * 60 * 24)
    )
  }));

  // Calculate overdue
  const overdueCount = queueWithAge.filter(
    (c) =>
      (c.status === 'NEW' && c.ageInStatus > 2) ||
      (c.status === 'HR_SCREENED' && c.ageInStatus > 2)
  ).length;

  return NextResponse.json({
    role: 'HR',
    newCount,
    hrScreenedCount,
    totalActive,
    overdueCount,
    myQueue: queueWithAge
  });
}

async function getManagerDashboard(userId: string) {
  const [pendingCount, myQueue] = await Promise.all([
    prisma.candidate.count({
      where: {
        status: 'MANAGER_EVAL_PENDING',
        managerAssignedId: userId
      }
    }),
    prisma.candidate.findMany({
      where: {
        status: 'MANAGER_EVAL_PENDING',
        managerAssignedId: userId
      },
      select: {
        id: true,
        code: true,
        fullName: true,
        position: true,
        updatedAt: true
      },
      take: 20,
      orderBy: { updatedAt: 'asc' }
    })
  ]);

  const now = new Date();
  const queueWithAge = myQueue.map((c) => ({
    ...c,
    assignedDate: c.updatedAt,
    ageInStatus: Math.floor(
      (now.getTime() - c.updatedAt.getTime()) / (1000 * 60 * 60 * 24)
    )
  }));

  const overdueCount = queueWithAge.filter((c) => c.ageInStatus > 3).length;

  return NextResponse.json({
    role: 'MANAGER',
    pendingCount,
    overdueCount,
    myQueue: queueWithAge
  });
}

async function getSMODashboard(userId: string) {
  const toSmoCount = await prisma.candidate.count({
    where: { status: 'TO_SMO' }
  });

  const myQueue = await prisma.candidate.findMany({
    where: { status: 'TO_SMO' },
    select: {
      id: true,
      code: true,
      fullName: true,
      position: true,
      createdAt: true
    },
    take: 20,
    orderBy: { createdAt: 'asc' }
  });

  const now = new Date();
  const queueWithAge = myQueue.map((c) => ({
    ...c,
    ageInStatus: Math.floor(
      (now.getTime() - c.createdAt.getTime()) / (1000 * 60 * 60 * 24)
    )
  }));

  const overdueCount = queueWithAge.filter((c) => c.ageInStatus > 2).length;

  return NextResponse.json({
    role: 'SMO',
    toSmoCount,
    overdueCount,
    myQueue: queueWithAge
  });
}

async function getAdminDashboard() {
  const pendingAccessRequests = await prisma.accessRequest.count({
    where: { status: 'PENDING' }
  });

  return NextResponse.json({
    role: 'ADMIN',
    pendingAccessRequests,
    failedOutbox: 0 // Placeholder if outbox not implemented
  });
}

Overdue Calculation

File: /lib/dashboard/overdue.ts

export const OVERDUE_DEFAULTS = {
  HR: {
    new: 2,           // days
    hrScreened: 2
  },
  MANAGER: {
    eval: 3           // days
  },
  SMO: {
    decision: 2       // days
  }
};

export function isOverdue(
  status: string,
  createdOrAssignedDate: Date,
  role: 'HR' | 'MANAGER' | 'SMO'
): boolean {
  const now = new Date();
  const diffMs = now.getTime() - createdOrAssignedDate.getTime();
  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));

  if (role === 'HR') {
    if (status === 'NEW') return diffDays > OVERDUE_DEFAULTS.HR.new;
    if (status === 'HR_SCREENED') return diffDays > OVERDUE_DEFAULTS.HR.hrScreened;
  }

  if (role === 'MANAGER' && status === 'MANAGER_EVAL_PENDING') {
    return diffDays > OVERDUE_DEFAULTS.MANAGER.eval;
  }

  if (role === 'SMO' && status === 'TO_SMO') {
    return diffDays > OVERDUE_DEFAULTS.SMO.decision;
  }

  return false;
}

F. RBAC Checks

Authentication Requirement

  • All /dashboard page access requires valid JWT in httpOnly cookie
  • All /api/dashboard access requires valid JWT
  • Non-authenticated users redirected to /login

Role-Based Data Filtering

RoleCan AccessSees Data For
HR/dashboardNEW, HR_SCREENED candidates (all, regardless of manager)
MANAGER/dashboardMANAGER_EVAL_PENDING candidates assigned to them
SMO/dashboardTO_SMO candidates
ADMIN/dashboardOperational counts (access requests, etc.)

Guardrails

  • HR cannot see manager-specific data or SMO decisions
  • Manager cannot see other managers' queues or HR/SMO data
  • SMO cannot see manager data or HR decisions
  • Admin sees operational data, not candidate content

Implementation

// In lib/auth/rbac.ts
export async function requireAuth() {
  const token = getAuthCookie();
  if (!token) return null;
  const payload = verifyToken(token);
  if (!payload) return null;
  return payload; // { userId, email, role }
}

export async function requireRole(...roles: string[]) {
  const payload = await requireAuth();
  if (!payload || !roles.includes(payload.role)) {
    throw new Error('Forbidden');
  }
  return payload;
}

G. Audit Events

No New Audit Events for W5

W5 is a view-only page. No events are logged.

Rationale: Viewing a dashboard is not an actionable event that requires compliance tracking. Only when a user takes action (e.g., starts HR screening in W7, submits a scorecard in W8) are events logged.


H. Test Checklist

Prerequisites

Test 1: Authentication

  • Navigate to /dashboard without login → Redirects to /login
  • Login as hr@example.com → Redirected to /dashboard
  • JWT cookie present in DevTools

Test 2: HR Dashboard

  • Login as HR user
  • Dashboard loads with HR-specific layout
  • KPI cards show correct counts:
    • "Needs HR Screening" = count of NEW candidates
    • "Needs Manager Assignment" = count of HR_SCREENED without manager
    • "Total Active in Pipeline" = NEW + HR_SCREENED + MANAGER_EVAL_PENDING
  • "My Queue" table shows NEW and HR_SCREENED candidates
  • "Upload Resume" button visible
  • "View Candidates" link visible
  • "Start HR Screening" action links to /candidates/:code
  • "Assign Manager" action visible for HR_SCREENED rows

Test 3: Manager Dashboard

  • Login as Manager user
  • Dashboard shows Manager-specific layout
  • KPI cards show:
    • "Pending Reviews" = MANAGER_EVAL_PENDING assigned to me
    • "Overdue" = count >3 days in MANAGER_EVAL_PENDING
  • "My Queue" table shows only candidates assigned to me
  • "Open Scorecard" action links to /candidates/:code/scorecard

Test 4: SMO Dashboard

  • Login as SMO user
  • Dashboard shows SMO-specific layout
  • KPI cards show:
    • "Pending Decisions" = TO_SMO count
    • "Overdue" = count >2 days in TO_SMO
  • "My Queue" table shows TO_SMO candidates
  • "Open Decision" action links to /candidates/:code/decision

Test 5: Admin Dashboard

  • Login as Admin user
  • Dashboard shows Admin-specific layout
  • KPI card shows "Pending Access Requests"
  • "Go to Access Requests" link goes to /admin/access-requests (W3)
  • Secondary CTAs visible: Users (W4), Audit (W14)

Test 6: Data Consistency

  • KPI counts match Prisma query results directly (run manual DB query to verify)
  • Age in status calculated correctly (now - createdAt or now - assignedDate)
  • Overdue flags show correct candidates based on hardcoded defaults

Test 7: Page Load Performance

  • Dashboard loads in <1 second
  • API response time <500ms
  • No console errors

Test 8: Drill-Down Navigation

  • HR clicks "Start HR Screening" → Opens W7 candidate intake for that candidate
  • Manager clicks "Open Scorecard" → Opens W8 scorecard for that candidate
  • SMO clicks "Open Decision" → Opens W10 decision for that candidate
  • "View Candidates" links to W11 candidate list with appropriate filters

Test 9: Empty Queue

  • If HR has no NEW/HR_SCREENED candidates → "My Queue" shows empty state message
  • If Manager has no MANAGER_EVAL_PENDING → Empty message
  • If SMO has no TO_SMO → Empty message

Test 10: Role Isolation

  • HR cannot see manager-assigned counts in their queue
  • Manager cannot see other managers' data
  • SMO cannot see HR/Manager data
  • Admin sees only operational counts, not candidate content

Database Verification Queries

-- Check NEW candidates count
SELECT COUNT(*) FROM "Candidate" WHERE status = 'NEW';

-- Check HR_SCREENED unassigned
SELECT COUNT(*) FROM "Candidate" 
WHERE status = 'HR_SCREENED' AND "managerAssignedId" IS NULL;

-- Check MANAGER_EVAL_PENDING for a specific manager
SELECT COUNT(*) FROM "Candidate" 
WHERE status = 'MANAGER_EVAL_PENDING' AND "managerAssignedId" = 'manager-id-here';

-- Check TO_SMO candidates
SELECT COUNT(*) FROM "Candidate" WHERE status = 'TO_SMO';

-- Check overdue (>2 days NEW)
SELECT code, "fullName", "createdAt", 
  EXTRACT(DAY FROM (NOW() - "createdAt")) as days_old
FROM "Candidate" 
WHERE status = 'NEW' AND (NOW() - "createdAt") > INTERVAL '2 days'
ORDER BY "createdAt" ASC;

Manual Browser Testing

  • Open DevTools → Network tab
  • Navigate to /dashboard
  • Verify GET /api/dashboard request succeeds (200 OK)
  • Check payload structure matches expected role response
  • Verify no 401/403 errors
  • Check Console for no JS errors

Responsive Design (if applicable)

  • Desktop (1920px): 3 columns of KPI cards
  • Tablet (768px): 2 columns of KPI cards (may reflow)
  • Mobile (375px): 1 column of KPI cards, table scrolls horizontally

Acceptance Criteria

  • Each role sees different dashboard
  • KPI counts match DB queries
  • "My Queue" shows correct candidates for role
  • Actions deep-link to correct pages (W7, W8, W10, W3)
  • No extra features beyond W5 scope
  • All tests in checklist pass
  • No audit events logged (view-only page)
  • No new DB tables or migrations
  • API response time <500ms
  • Overdue logic uses hardcoded defaults

Summary of Files to Create/Modify

Create

  • /src/app/(app)/dashboard/page.tsx (Main page, 120 lines)
  • /src/app/(app)/dashboard/_components/HRDashboard.tsx (100 lines)
  • /src/app/(app)/dashboard/_components/ManagerDashboard.tsx (80 lines)
  • /src/app/(app)/dashboard/_components/SMODashboard.tsx (80 lines)
  • /src/app/(app)/dashboard/_components/AdminDashboard.tsx (60 lines)
  • /src/app/(app)/dashboard/_components/KpiCard.tsx (40 lines)
  • /src/app/(app)/dashboard/_components/MyQueueTable.tsx (120 lines)
  • /src/app/(app)/dashboard/_components/DashboardHeader.tsx (30 lines)
  • /src/app/api/dashboard/route.ts (250 lines)
  • /src/lib/dashboard/overdue.ts (30 lines)

Modify

  • None (no schema changes)

Total Code

  • ~900 lines of new code
  • 0 database migrations
  • 0 breaking changes

Integration Notes

  • Uses existing auth: RBAC middleware from W1
  • Uses existing candidates: No new fields, just queries
  • No outbox integration yet: Hardcoded failedOutbox: 0 for Admin
  • No email notifications: W5 is display only
  • Links to W3, W4, W7, W8, W10, W11: All routes assumed to exist (will be built in those wireframes)

Next Steps (Post-W5)

  1. W6: Implement resume upload feature (CTA from HR dashboard)
  2. W7: Implement candidate intake form (deep-link target from HR dashboard)
  3. W8: Implement manager scorecard (deep-link target from Manager dashboard)
  4. W10: Implement SMO decision (deep-link target from SMO dashboard)
  5. W11: Implement candidate list with filters (deep-link target from all dashboards)
  6. W17: Add Settings table with SLA config (will replace hardcoded defaults in W5)
  7. W18: Add advanced analytics dashboard (separate from role dashboards)