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:
Candidatetable (for status counts and queue data)Usertable (for role and assignments)- Future:
Settingstable (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
/dashboardpage access requires valid JWT in httpOnly cookie - All
/api/dashboardaccess requires valid JWT - Non-authenticated users redirected to
/login
Role-Based Data Filtering
| Role | Can Access | Sees Data For |
|---|---|---|
| HR | /dashboard | NEW, HR_SCREENED candidates (all, regardless of manager) |
| MANAGER | /dashboard | MANAGER_EVAL_PENDING candidates assigned to them |
| SMO | /dashboard | TO_SMO candidates |
| ADMIN | /dashboard | Operational 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
- Database seeded with test candidates in various statuses
- Test users created: hr@example.com (HR), manager@example.com (MANAGER), smo@example.com (SMO), admin@example.com (ADMIN)
- Dev server running:
npm run dev
Test 1: Authentication
- Navigate to
/dashboardwithout 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/dashboardrequest 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: 0for 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)
- W6: Implement resume upload feature (CTA from HR dashboard)
- W7: Implement candidate intake form (deep-link target from HR dashboard)
- W8: Implement manager scorecard (deep-link target from Manager dashboard)
- W10: Implement SMO decision (deep-link target from SMO dashboard)
- W11: Implement candidate list with filters (deep-link target from all dashboards)
- W17: Add Settings table with SLA config (will replace hardcoded defaults in W5)
- W18: Add advanced analytics dashboard (separate from role dashboards)