Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

Phase 08: Dashboard System

Widget-based customizable dashboards with drag-and-drop layout

Phase 08: Dashboard System

Goal: Build a flexible, widget-based dashboard system supporting system-defined dashboards, user-created custom dashboards, drag-and-drop layout customization, and dashboard sharing.

AttributeValue
Steps123-138
Estimated Time8-12 hours
DependenciesPhase 07 complete (Tags & Custom Fields)
Completion GateUsers can create dashboards, add widgets, drag-and-drop layout, layouts persist.

Step Timing Estimates

StepTaskEst. Time
123Add DashboardType enum10 min
124Add ShareType, SharePermission enums10 min
125Add Dashboard model (no relations)20 min
126Add DashboardShare model15 min
127Add DashboardLayout model + ALL relations20 min
128Run migration15 min
129Create widget registry types30 min
130Create DashboardService45 min
131Create DashboardController40 min
132Install react-grid-layout15 min
133Create DashboardContainer component45 min
134Create GridLayout wrapper40 min
135Create WidgetGallery component35 min
136Create HeadcountWidget30 min
137Create layout persistence35 min
138Create dashboard page30 min

Phase Context (READ FIRST)

What This Phase Accomplishes

  • Dashboard types: SYSTEM (admin-only), PERSONAL, SHARED
  • Widget registry with centralized definitions
  • Drag-and-drop layout using react-grid-layout
  • Dashboard sharing with VIEW/EDIT permissions
  • Saved layouts per user per dashboard
  • Auto-save to localStorage + API sync
  • Responsive breakpoints (xl, lg, md, sm, xs)
  • HeadcountWidget as first working widget

What This Phase Does NOT Include

  • All widget implementations - only HeadcountWidget in this phase
  • AI-powered widgets - Phase 09
  • Dashboard export/import - future enhancement
  • Dashboard templates - future enhancement
  • Widget-to-widget communication - future enhancement

Bluewoo Anti-Pattern Reminder

This phase intentionally has NO:

  • Complex widget state management - simple local state only
  • Real-time collaboration - single user editing only
  • Widget marketplace - internal registry only
  • Dashboard versioning - direct updates only

If the AI suggests adding any of these, REJECT and continue with the spec.

Known Limitations (MVP)

Dashboard Sharing & Widgets

Share Permission Enforcement:

  • EDIT permission is stored in the database but not enforced in this MVP
  • All shared users effectively have VIEW-only access
  • Only the dashboard owner can make edits
  • Full permission enforcement planned for future enhancement

Share Type Support:

  • Only USER and COMPANY share types are currently enforced
  • TEAM and DEPARTMENT shares can be created but don't grant access yet
  • Requires user team/department lookup from JWT or database

Blocked Widgets:

  • Time-off Widget (DB-10): Cannot be implemented until /api/v1/timeoff/stats/usage endpoint exists (Phase 05 enhancement)
  • Skills Widget (DB-11): Needs tag aggregation endpoint to display employee skill distribution

Mock Data:

  • HeadcountWidget: Uses mock data until headcount API endpoint is created (future analytics phase)

Future Enhancements: Full permission enforcement, team/department-aware sharing, all widget APIs.


Step 123: Add DashboardType Enum

Input

  • Phase 07 complete
  • Prisma schema at packages/database/prisma/schema.prisma

Constraints

  • DO NOT modify existing models
  • ONLY add DashboardType enum
  • Enums must be created BEFORE models that reference them

Task

Add to packages/database/prisma/schema.prisma:

// ==========================================
// DASHBOARD SYSTEM ENUMS & MODELS
// ==========================================

enum DashboardType {
  SYSTEM    // Pre-defined, admin-editable only
  PERSONAL  // User-created, private
  SHARED    // User-created, shared with others
}

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 5 "enum DashboardType"
# Should show SYSTEM, PERSONAL, SHARED

Common Errors

ErrorCauseFix
Enum already existsDuplicate definitionRemove duplicate

Rollback

# Remove DashboardType enum from schema.prisma

Lock

packages/database/prisma/schema.prisma (DashboardType enum)

Checkpoint

  • DashboardType enum added
  • prisma format succeeds

Step 124: Add ShareType and SharePermission Enums

Input

  • Step 123 complete
  • DashboardType enum exists

Constraints

  • DO NOT modify existing models
  • Add both enums in this step

Task

Add to packages/database/prisma/schema.prisma:

enum ShareType {
  USER        // Shared with specific user
  TEAM        // Shared with team members
  DEPARTMENT  // Shared with department
  COMPANY     // Shared company-wide
}

enum SharePermission {
  VIEW  // Can view only
  EDIT  // Can customize their copy
}

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 6 "enum ShareType"
# Should show USER, TEAM, DEPARTMENT, COMPANY

cat prisma/schema.prisma | grep -A 4 "enum SharePermission"
# Should show VIEW, EDIT

Common Errors

ErrorCauseFix
Enum already existsDuplicate definitionRemove duplicate

Rollback

# Remove ShareType and SharePermission enums from schema.prisma

Lock

packages/database/prisma/schema.prisma (ShareType, SharePermission enums)

Checkpoint

  • ShareType enum added with 4 values
  • SharePermission enum added with 2 values
  • prisma format succeeds

Step 125: Add Dashboard Model

Input

  • Step 124 complete
  • DashboardType enum exists

Constraints

  • DO NOT add relations yet (DashboardShare, DashboardLayout don't exist)
  • Relations will be added in Step 127

Task

Add to packages/database/prisma/schema.prisma:

model Dashboard {
  id          String        @id @default(cuid())
  tenantId    String
  name        String
  description String?
  type        DashboardType @default(PERSONAL)
  ownerId     String?       // null for SYSTEM dashboards
  config      Json          // { widgets: WidgetInstance[], layout: LayoutItem[], version: string }
  isDefault   Boolean       @default(false)
  createdAt   DateTime      @default(now())
  updatedAt   DateTime      @updatedAt
  deletedAt   DateTime?

  // Relations added in Step 127

  @@index([tenantId])
  @@index([ownerId])
  @@index([type])
  @@map("dashboards")
}

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 20 "model Dashboard"
# Should show all fields

Common Errors

ErrorCauseFix
Unknown type "DashboardType"Enum not definedComplete Step 123 first
Json type not supportedOld Prisma versionRun npm update @prisma/client prisma

Rollback

# Remove Dashboard model from schema.prisma

Lock

packages/database/prisma/schema.prisma (Dashboard model - no relations)

Checkpoint

  • Dashboard model added with all fields
  • config is Json type
  • prisma format succeeds

Step 126: Add DashboardShare Model

Input

  • Step 125 complete
  • Dashboard model exists (without relations)

Constraints

  • DO NOT add relation to Dashboard yet (added in Step 127)

Task

Add to packages/database/prisma/schema.prisma:

model DashboardShare {
  id          String          @id @default(cuid())
  dashboardId String
  shareType   ShareType
  targetId    String?         // userId, teamId, deptId, or null for COMPANY
  permission  SharePermission @default(VIEW)
  sharedBy    String
  sharedAt    DateTime        @default(now())
  expiresAt   DateTime?

  // Relations added in Step 127

  @@index([dashboardId])
  @@index([targetId])
  @@map("dashboard_shares")
}

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 15 "model DashboardShare"
# Should show all fields

Common Errors

ErrorCauseFix
Unknown type "ShareType"Enum not definedComplete Step 124 first

Rollback

# Remove DashboardShare model from schema.prisma

Lock

packages/database/prisma/schema.prisma (DashboardShare model - no relations)

Checkpoint

  • DashboardShare model added
  • Uses ShareType and SharePermission enums
  • prisma format succeeds

Step 127: Add DashboardLayout Model + ALL Relations

Input

  • Step 126 complete
  • Dashboard and DashboardShare models exist

Constraints

  • Add DashboardLayout model
  • Add ALL relations to Dashboard, DashboardShare, DashboardLayout
  • Add relations to User model (add fields to existing User model)

Task

1. Add DashboardLayout model to packages/database/prisma/schema.prisma:

model DashboardLayout {
  id          String    @id @default(cuid())
  dashboardId String
  userId      String
  name        String
  description String?
  layout      Json      // LayoutItem[]
  isDefault   Boolean   @default(false)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  // Relations
  dashboard   Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
  user        User      @relation("UserDashboardLayouts", fields: [userId], references: [id])

  @@index([dashboardId, userId])
  @@map("dashboard_layouts")
}

2. Update Dashboard model - add relations:

model Dashboard {
  id          String        @id @default(cuid())
  tenantId    String
  name        String
  description String?
  type        DashboardType @default(PERSONAL)
  ownerId     String?
  config      Json
  isDefault   Boolean       @default(false)
  createdAt   DateTime      @default(now())
  updatedAt   DateTime      @updatedAt
  deletedAt   DateTime?

  // Relations
  tenant      Tenant            @relation(fields: [tenantId], references: [id])
  owner       User?             @relation("UserDashboards", fields: [ownerId], references: [id])
  shares      DashboardShare[]
  layouts     DashboardLayout[]

  @@index([tenantId])
  @@index([ownerId])
  @@index([type])
  @@map("dashboards")
}

3. Update DashboardShare model - add relation:

model DashboardShare {
  id          String          @id @default(cuid())
  dashboardId String
  shareType   ShareType
  targetId    String?
  permission  SharePermission @default(VIEW)
  sharedBy    String
  sharedAt    DateTime        @default(now())
  expiresAt   DateTime?

  // Relations
  dashboard   Dashboard       @relation(fields: [dashboardId], references: [id], onDelete: Cascade)

  @@index([dashboardId])
  @@index([targetId])
  @@map("dashboard_shares")
}

4. Update User model - add dashboard relations:

Find the existing User model and add these fields:

// Add to User model
dashboards        Dashboard[]        @relation("UserDashboards")
dashboardLayouts  DashboardLayout[]  @relation("UserDashboardLayouts")

5. Update Tenant model - add dashboard relation:

Find the existing Tenant model and add:

// Add to Tenant model
dashboards        Dashboard[]

Gate

cd packages/database
npx prisma format
# Should complete without errors

npx prisma validate
# Should complete without errors - all relations valid

Common Errors

ErrorCauseFix
Unknown model "Dashboard"Model not definedComplete Step 125 first
Relation not foundMissing reverse relationAdd relation to both sides
Ambiguous relationMultiple relations to same modelUse named relations (e.g., "UserDashboards")

Rollback

# Remove DashboardLayout model
# Remove relations from Dashboard, DashboardShare, User, Tenant

Lock

packages/database/prisma/schema.prisma (Dashboard relations, DashboardShare relations, DashboardLayout model)

Checkpoint

  • DashboardLayout model added with relations
  • Dashboard model has tenant, owner, shares, layouts relations
  • DashboardShare model has dashboard relation
  • User model has dashboards and dashboardLayouts relations
  • Tenant model has dashboards relation
  • prisma format and validate succeed

Step 128: Run Migration

Input

  • Step 127 complete
  • All dashboard models and relations defined

Constraints

  • Run prisma db push (development mode)
  • Verify all tables created

Task

cd packages/database
npx prisma db push
npx prisma generate

Gate

npx prisma studio
# Should show: dashboards, dashboard_shares, dashboard_layouts tables
# Each table should have correct columns

# Or via CLI:
npx prisma db execute --stdin <<< "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'dashboard%';"

Common Errors

ErrorCauseFix
Foreign key constraint failedMissing related tableRun migration in correct order
Column type mismatchSchema changed incompatiblyReset database or manual migration

Rollback

# Drop tables (CAUTION: data loss)
npx prisma db execute --stdin <<< "DROP TABLE IF EXISTS dashboard_layouts, dashboard_shares, dashboards CASCADE;"

Lock

packages/database/prisma/schema.prisma (all dashboard models - schema locked)

Checkpoint

  • Migration completed successfully
  • dashboards table exists
  • dashboard_shares table exists
  • dashboard_layouts table exists
  • prisma generate completed

Step 129: Create Widget Registry Types

Input

  • Step 128 complete
  • Database tables exist

Prerequisites

1. Create directories:

mkdir -p apps/api/src/dashboards apps/api/src/dashboards/dto
mkdir -p apps/web/features/dashboard/widgets
mkdir -p apps/web/features/dashboard/components

2. Add @/features/* path alias to apps/web/tsconfig.json:

Add to the paths section:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"],
      "@/features/*": ["./features/*"]
    }
  }
}

Important: This path alias is required for imports like @/features/dashboard/components.

Constraints

  • Create shared types for widgets
  • Widget registry is frontend-only (widgets are React components)
  • Backend only stores widget configuration as JSON

Task

1. Create apps/web/features/dashboard/widgets/types.ts:

import { ComponentType } from 'react';

export type WidgetCategory =
  | 'metrics'       // Single values, KPIs
  | 'charts'        // Visualizations
  | 'lists'         // Data lists
  | 'ai'            // AI-powered widgets
  | 'quick-actions'; // Action shortcuts

export interface WidgetDefinition {
  // Identity
  id: string;
  title: string;
  description: string;
  category: WidgetCategory;
  icon: string; // Lucide icon name

  // Component
  component: ComponentType<WidgetProps>;

  // Size constraints (in grid units, 12-column grid)
  defaultSize: { width: number; height: number };
  minSize: { width: number; height: number };
  maxSize?: { width: number; height: number };

  // Permissions
  permissions: {
    requiredRoles?: string[];       // e.g., ['SYSTEM_ADMIN', 'HR_ADMIN']
    tenantAdminOnly?: boolean;
  };

  // Configuration
  configurable: boolean;
  configSchema?: ConfigField[];
  defaultConfig?: Record<string, unknown>;

  // Behavior
  refreshInterval?: number; // Auto-refresh in seconds
  enabled: boolean;
}

export interface WidgetProps {
  instance: WidgetInstance;
  definition: WidgetDefinition;
  isEditing: boolean;
  onConfigChange?: (config: Record<string, unknown>) => void;
}

export interface WidgetInstance {
  id: string;                              // Unique instance ID
  widgetId: string;                        // Reference to widget definition
  title?: string;                          // Custom title override
  configuration?: Record<string, unknown>; // Widget-specific config
  refreshInterval?: number;                // Override default refresh
}

export interface LayoutItem {
  i: string;      // Widget instance ID
  x: number;      // Grid X position (0-11)
  y: number;      // Grid Y position
  w: number;      // Width in grid units
  h: number;      // Height in grid units
  minW?: number;
  maxW?: number;
  minH?: number;
  maxH?: number;
  static?: boolean; // Locked position
}

export interface DashboardConfig {
  widgets: WidgetInstance[];
  layout: LayoutItem[];
  version: string;
}

export interface ConfigField {
  key: string;
  label: string;
  type: 'text' | 'number' | 'select' | 'checkbox' | 'date-range';
  options?: { value: string; label: string }[];
  defaultValue?: unknown;
  required?: boolean;
}

2. Create apps/web/features/dashboard/widgets/registry.ts:

import { WidgetDefinition, WidgetCategory } from './types';

class WidgetRegistry {
  private widgets = new Map<string, WidgetDefinition>();

  register(widget: WidgetDefinition): void {
    if (this.widgets.has(widget.id)) {
      console.warn(`Widget ${widget.id} already registered, overwriting`);
    }
    this.widgets.set(widget.id, widget);
  }

  get(id: string): WidgetDefinition | undefined {
    return this.widgets.get(id);
  }

  getAll(): WidgetDefinition[] {
    return Array.from(this.widgets.values()).filter((w) => w.enabled);
  }

  getByCategory(category: WidgetCategory): WidgetDefinition[] {
    return this.getAll().filter((w) => w.category === category);
  }

  getForUser(userRoles: string[]): WidgetDefinition[] {
    return this.getAll().filter((w) => {
      // Check role requirements
      if (w.permissions.requiredRoles?.length) {
        const hasRole = w.permissions.requiredRoles.some((role) =>
          userRoles.includes(role)
        );
        if (!hasRole) return false;
      }
      return true;
    });
  }
}

// Singleton instance
export const widgetRegistry = new WidgetRegistry();

3. Create apps/web/features/dashboard/widgets/index.ts:

export * from './types';
export * from './registry';

Gate

cd apps/web
npm run build
# Should compile without type errors

# Check files exist
ls -la features/dashboard/widgets/
# Should show: types.ts, registry.ts, index.ts

Common Errors

ErrorCauseFix
Module not foundWrong pathCheck import paths
Type errorMissing type importImport from './types'

Rollback

rm -rf apps/web/features/dashboard/widgets/

Lock

apps/web/features/dashboard/widgets/types.ts
apps/web/features/dashboard/widgets/registry.ts

Checkpoint

  • Widget types defined
  • Widget registry class created
  • Singleton instance exported
  • Frontend compiles

Step 130: Create DashboardService

Input

  • Step 129 complete
  • Widget types defined

Prerequisites

mkdir -p apps/api/src/dashboards/dto

Constraints

  • Tenant-filtered queries
  • Owner-based access control
  • Soft delete support

Task

1. Create apps/api/src/dashboards/dto/create-dashboard.dto.ts:

import { IsString, IsOptional, IsEnum, IsObject, IsBoolean } from 'class-validator';
import { DashboardType } from '@prisma/client';

export class CreateDashboardDto {
  @IsString()
  name: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsEnum(DashboardType)
  type?: DashboardType;

  @IsOptional()
  @IsObject()
  config?: {
    widgets: Array<{
      id: string;
      widgetId: string;
      title?: string;
      configuration?: Record<string, unknown>;
    }>;
    layout: Array<{
      i: string;
      x: number;
      y: number;
      w: number;
      h: number;
    }>;
    version: string;
  };

  @IsOptional()
  @IsBoolean()
  isDefault?: boolean;
}

2. Create apps/api/src/dashboards/dto/update-dashboard.dto.ts:

import { PartialType } from '@nestjs/mapped-types';
import { CreateDashboardDto } from './create-dashboard.dto';

export class UpdateDashboardDto extends PartialType(CreateDashboardDto) {}

3. Create apps/api/src/dashboards/dto/share-dashboard.dto.ts:

import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ShareType, SharePermission } from '@prisma/client';

export class ShareDashboardDto {
  @IsEnum(ShareType)
  shareType: ShareType;

  @IsOptional()
  @IsString()
  targetId?: string; // userId, teamId, deptId - null for COMPANY

  @IsOptional()
  @IsEnum(SharePermission)
  permission?: SharePermission;
}

4. Create apps/api/src/dashboards/dto/save-layout.dto.ts:

import { IsString, IsOptional, IsArray, IsBoolean, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class LayoutItemDto {
  @IsString()
  i: string;

  x: number;
  y: number;
  w: number;
  h: number;
}

export class SaveLayoutDto {
  @IsString()
  name: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => LayoutItemDto)
  layout: LayoutItemDto[];

  @IsOptional()
  @IsBoolean()
  isDefault?: boolean;
}

5. Create apps/api/src/dashboards/dto/index.ts:

export * from './create-dashboard.dto';
export * from './update-dashboard.dto';
export * from './share-dashboard.dto';
export * from './save-layout.dto';

6. Create apps/api/src/dashboards/dashboard.service.ts:

import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DashboardType, ShareType } from '@prisma/client';
import {
  CreateDashboardDto,
  UpdateDashboardDto,
  ShareDashboardDto,
  SaveLayoutDto,
} from './dto';

@Injectable()
export class DashboardService {
  constructor(private prisma: PrismaService) {}

  // List dashboards accessible to user
  async listForUser(tenantId: string, userId: string) {
    const [owned, shared, system] = await Promise.all([
      // User's own dashboards
      this.prisma.dashboard.findMany({
        where: {
          tenantId,
          ownerId: userId,
          deletedAt: null,
        },
        orderBy: { updatedAt: 'desc' },
      }),
      // Dashboards shared with user
      this.prisma.dashboard.findMany({
        where: {
          tenantId,
          deletedAt: null,
          shares: {
            some: {
              AND: [
                {
                  OR: [
                    { shareType: ShareType.USER, targetId: userId },
                    { shareType: ShareType.COMPANY },
                    // Team/Department sharing would need user's team/dept IDs
                  ],
                },
                {
                  OR: [
                    { expiresAt: null },
                    { expiresAt: { gt: new Date() } },
                  ],
                },
              ],
            },
          },
        },
        include: {
          shares: true,
        },
      }),
      // System dashboards
      this.prisma.dashboard.findMany({
        where: {
          tenantId,
          type: DashboardType.SYSTEM,
          deletedAt: null,
        },
      }),
    ]);

    // Deduplicate
    const seen = new Set<string>();
    const result = [];
    for (const dashboard of [...owned, ...shared, ...system]) {
      if (!seen.has(dashboard.id)) {
        seen.add(dashboard.id);
        result.push(dashboard);
      }
    }

    return result;
  }

  // Get single dashboard with access check
  async getWithAccessCheck(id: string, tenantId: string, userId: string) {
    const dashboard = await this.prisma.dashboard.findFirst({
      where: {
        id,
        tenantId,
        deletedAt: null,
      },
      include: {
        shares: true,
        layouts: {
          where: { userId },
        },
      },
    });

    if (!dashboard) {
      throw new NotFoundException('Dashboard not found');
    }

    // Check access
    const hasAccess =
      dashboard.type === DashboardType.SYSTEM ||
      dashboard.ownerId === userId ||
      dashboard.shares.some(
        (s) =>
          (s.shareType === ShareType.USER && s.targetId === userId) ||
          s.shareType === ShareType.COMPANY
      );

    if (!hasAccess) {
      throw new ForbiddenException('Access denied');
    }

    return dashboard;
  }

  // Create dashboard
  async create(dto: CreateDashboardDto, tenantId: string, userId: string) {
    const defaultConfig = {
      widgets: [],
      layout: [],
      version: '1.0',
    };

    return this.prisma.dashboard.create({
      data: {
        tenantId,
        ownerId: userId,
        name: dto.name,
        description: dto.description,
        type: dto.type || DashboardType.PERSONAL,
        config: dto.config || defaultConfig,
        isDefault: dto.isDefault || false,
      },
    });
  }

  // Update dashboard
  async update(
    id: string,
    dto: UpdateDashboardDto,
    tenantId: string,
    userId: string
  ) {
    const dashboard = await this.getWithAccessCheck(id, tenantId, userId);

    // Only owner can update
    if (dashboard.ownerId !== userId && dashboard.type !== DashboardType.SYSTEM) {
      throw new ForbiddenException('Only owner can update dashboard');
    }

    // Use updateMany to enforce tenant isolation
    const result = await this.prisma.dashboard.updateMany({
      where: { id, tenantId },
      data: {
        name: dto.name,
        description: dto.description,
        config: dto.config,
        isDefault: dto.isDefault,
      },
    });

    if (result.count === 0) {
      throw new NotFoundException('Dashboard not found');
    }

    return this.prisma.dashboard.findUnique({ where: { id } });
  }

  // Delete dashboard (soft delete)
  async delete(id: string, tenantId: string, userId: string) {
    const dashboard = await this.getWithAccessCheck(id, tenantId, userId);

    if (dashboard.ownerId !== userId) {
      throw new ForbiddenException('Only owner can delete dashboard');
    }

    if (dashboard.type === DashboardType.SYSTEM) {
      throw new ForbiddenException('Cannot delete system dashboard');
    }

    // Use updateMany to enforce tenant isolation
    const result = await this.prisma.dashboard.updateMany({
      where: { id, tenantId },
      data: { deletedAt: new Date() },
    });

    if (result.count === 0) {
      throw new NotFoundException('Dashboard not found');
    }
  }

  // Share dashboard
  async share(
    id: string,
    dto: ShareDashboardDto,
    tenantId: string,
    userId: string
  ) {
    const dashboard = await this.getWithAccessCheck(id, tenantId, userId);

    if (dashboard.ownerId !== userId) {
      throw new ForbiddenException('Only owner can share dashboard');
    }

    // Mark as SHARED type if it was PERSONAL (use updateMany for tenant isolation)
    if (dashboard.type === DashboardType.PERSONAL) {
      await this.prisma.dashboard.updateMany({
        where: { id, tenantId },
        data: { type: DashboardType.SHARED },
      });
    }

    return this.prisma.dashboardShare.create({
      data: {
        dashboardId: id,
        shareType: dto.shareType,
        targetId: dto.targetId,
        permission: dto.permission,
        sharedBy: userId,
      },
    });
  }

  // Revoke share
  async revokeShare(dashboardId: string, shareId: string, tenantId: string, userId: string) {
    const dashboard = await this.getWithAccessCheck(dashboardId, tenantId, userId);

    if (dashboard.ownerId !== userId) {
      throw new ForbiddenException('Only owner can revoke share');
    }

    // Use deleteMany with dashboardId to ensure share belongs to this dashboard
    const result = await this.prisma.dashboardShare.deleteMany({
      where: { id: shareId, dashboardId },
    });

    if (result.count === 0) {
      throw new NotFoundException('Share not found');
    }
  }

  // Save layout
  async saveLayout(
    dashboardId: string,
    dto: SaveLayoutDto,
    tenantId: string,
    userId: string
  ) {
    await this.getWithAccessCheck(dashboardId, tenantId, userId);

    // If setting as default, unset other defaults
    if (dto.isDefault) {
      await this.prisma.dashboardLayout.updateMany({
        where: { dashboardId, userId, isDefault: true },
        data: { isDefault: false },
      });
    }

    return this.prisma.dashboardLayout.create({
      data: {
        dashboardId,
        userId,
        name: dto.name,
        description: dto.description,
        layout: dto.layout,
        isDefault: dto.isDefault || false,
      },
    });
  }

  // Get saved layouts
  async getLayouts(dashboardId: string, tenantId: string, userId: string) {
    await this.getWithAccessCheck(dashboardId, tenantId, userId);

    return this.prisma.dashboardLayout.findMany({
      where: { dashboardId, userId },
      orderBy: { createdAt: 'desc' },
    });
  }

  // Delete layout
  async deleteLayout(
    dashboardId: string,
    layoutId: string,
    tenantId: string,
    userId: string
  ) {
    await this.getWithAccessCheck(dashboardId, tenantId, userId);

    const layout = await this.prisma.dashboardLayout.findFirst({
      where: { id: layoutId, dashboardId, userId },
    });

    if (!layout) {
      throw new NotFoundException('Layout not found');
    }

    await this.prisma.dashboardLayout.delete({
      where: { id: layoutId },
    });
  }
}

Gate

cd apps/api
npm run build
# Should compile without errors

# Check files exist
ls -la src/dashboards/
# Should show: dashboard.service.ts, dto/

Common Errors

ErrorCauseFix
Cannot find module '@prisma/client'prisma generate not runRun cd packages/database && npx prisma generate
Property 'dashboard' does not existPrismaService missing modelCheck PrismaService extends PrismaClient

Rollback

rm -rf apps/api/src/dashboards/

Lock

apps/api/src/dashboards/dashboard.service.ts
apps/api/src/dashboards/dto/*

Checkpoint

  • All DTOs created
  • DashboardService implements CRUD
  • Access control implemented
  • Sharing logic implemented
  • Layout persistence implemented
  • API compiles

Step 131: Create DashboardController

Input

  • Step 130 complete
  • DashboardService exists

Constraints

  • Use TenantGuard
  • Extract tenantId from header
  • Use @CurrentUser() decorator (established pattern from Phases 01-07)

Task

1. Create apps/api/src/dashboards/dashboard.controller.ts:

import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  UseGuards,
} from '@nestjs/common';
import { TenantGuard } from '../tenant/tenant.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { DashboardService } from './dashboard.service';
import {
  CreateDashboardDto,
  UpdateDashboardDto,
  ShareDashboardDto,
  SaveLayoutDto,
} from './dto';

// AuthUser type from Phase 01
interface AuthUser {
  id: string;
  systemRole: string;
}

@Controller('api/v1/dashboards')
@UseGuards(TenantGuard)
export class DashboardController {
  constructor(private readonly dashboardService: DashboardService) {}

  // List accessible dashboards
  @Get()
  async list(@TenantId() tenantId: string, @CurrentUser() user: AuthUser) {
    return {
      data: await this.dashboardService.listForUser(tenantId, user.id),
      error: null,
    };
  }

  // Get single dashboard
  @Get(':id')
  async get(
    @Param('id') id: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    return {
      data: await this.dashboardService.getWithAccessCheck(id, tenantId, user.id),
      error: null,
    };
  }

  // Create dashboard
  @Post()
  async create(
    @Body() dto: CreateDashboardDto,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    return {
      data: await this.dashboardService.create(dto, tenantId, user.id),
      error: null,
    };
  }

  // Update dashboard
  @Put(':id')
  async update(
    @Param('id') id: string,
    @Body() dto: UpdateDashboardDto,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    return {
      data: await this.dashboardService.update(id, dto, tenantId, user.id),
      error: null,
    };
  }

  // Delete dashboard
  @Delete(':id')
  async delete(
    @Param('id') id: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    await this.dashboardService.delete(id, tenantId, user.id);
    return { data: { success: true }, error: null };
  }

  // Share dashboard
  @Post(':id/share')
  async share(
    @Param('id') id: string,
    @Body() dto: ShareDashboardDto,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    return {
      data: await this.dashboardService.share(id, dto, tenantId, user.id),
      error: null,
    };
  }

  // Revoke share
  @Delete(':id/share/:shareId')
  async revokeShare(
    @Param('id') id: string,
    @Param('shareId') shareId: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    await this.dashboardService.revokeShare(id, shareId, tenantId, user.id);
    return { data: { success: true }, error: null };
  }

  // List saved layouts
  @Get(':id/layouts')
  async listLayouts(
    @Param('id') id: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    return {
      data: await this.dashboardService.getLayouts(id, tenantId, user.id),
      error: null,
    };
  }

  // Save layout
  @Post(':id/layouts')
  async saveLayout(
    @Param('id') id: string,
    @Body() dto: SaveLayoutDto,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    return {
      data: await this.dashboardService.saveLayout(id, dto, tenantId, user.id),
      error: null,
    };
  }

  // Delete layout
  @Delete(':id/layouts/:layoutId')
  async deleteLayout(
    @Param('id') id: string,
    @Param('layoutId') layoutId: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: AuthUser
  ) {
    await this.dashboardService.deleteLayout(id, layoutId, tenantId, user.id);
    return { data: { success: true }, error: null };
  }
}

2. Create apps/api/src/dashboards/dashboards.module.ts:

import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [DashboardController],
  providers: [DashboardService],
  exports: [DashboardService],
})
export class DashboardsModule {}

3. Register module in apps/api/src/app.module.ts:

import { DashboardsModule } from './dashboards/dashboards.module';

@Module({
  imports: [
    // ... existing modules
    DashboardsModule,
  ],
})
export class AppModule {}

Gate

cd apps/api
npm run build
# Should compile without errors

# Start the API
npm run start:dev

# Test endpoint (replace with valid tenant ID)
curl -X POST http://localhost:3001/api/v1/dashboards \
  -H "X-Tenant-ID: test-tenant" \
  -H "Content-Type: application/json" \
  -d '{"name": "My Dashboard"}'
# Should return created dashboard or auth error (expected if no auth middleware)

Common Errors

ErrorCauseFix
TenantGuard not foundMissing importImport from '../tenant/tenant.guard'
Cannot find moduleModule not registeredAdd to app.module.ts imports

Rollback

# Remove DashboardsModule from app.module.ts
rm apps/api/src/dashboards/dashboard.controller.ts
rm apps/api/src/dashboards/dashboards.module.ts

Lock

apps/api/src/dashboards/dashboard.controller.ts
apps/api/src/dashboards/dashboards.module.ts

Checkpoint

  • Controller with all CRUD endpoints
  • Module created and registered
  • API compiles
  • Endpoints accessible (auth errors OK)

Step 132: Install react-grid-layout

Input

  • Step 131 complete
  • API endpoints working

Constraints

  • Install in apps/web only
  • Include TypeScript types

Task

cd apps/web
npm install react-grid-layout
npm install -D @types/react-grid-layout

Gate

cd apps/web

# Check package.json
cat package.json | grep react-grid-layout
# Should show version

# Test import compiles
cat > /tmp/test-import.tsx << 'EOF'
import { Responsive, WidthProvider } from 'react-grid-layout';
const ResponsiveGridLayout = WidthProvider(Responsive);
console.log(ResponsiveGridLayout);
EOF

npm run build
# Should compile without errors

Common Errors

ErrorCauseFix
Module not foundNot installedRun install command again
Types not foundMissing @types packageInstall @types/react-grid-layout

Rollback

cd apps/web
npm uninstall react-grid-layout @types/react-grid-layout

Lock

apps/web/package.json (react-grid-layout dependency)

Checkpoint

  • react-grid-layout installed
  • TypeScript types installed
  • Frontend compiles

Step 133: Create DashboardContainer Component

Input

  • Step 132 complete
  • react-grid-layout installed

Prerequisites

1. Create directory:

mkdir -p apps/web/features/dashboard/components

2. Ensure api.put() and api.delete() methods exist in apps/web/lib/api.ts:

If these methods don't exist (they should from previous phases), add them now following the existing pattern:

Note: Ensure these methods follow the existing pattern in apps/web/lib/api.ts. The api helper should use API_URL and add the /api/v1 prefix, so use /dashboards/... not /api/v1/dashboards/... in your calls.

// In apps/web/lib/api.ts, add the put method:
// Follow existing pattern - use API_URL constant and /api/v1 prefix
async put<T = unknown>(path: string, body: unknown): Promise<{ data: T; error: null }> {
  const tenantId = await getTenantId();
  const response = await fetch(`${API_URL}/api/v1${path}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'x-tenant-id': tenantId,
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({ message: 'Request failed' }));
    throw new Error(error.message || 'Request failed');
  }

  const data = await response.json();
  return { data: data.data ?? data, error: null };
}

// Also add delete method if missing:
async delete<T = unknown>(path: string): Promise<{ data: T; error: null }> {
  const tenantId = await getTenantId();
  const response = await fetch(`${API_URL}/api/v1${path}`, {
    method: 'DELETE',
    headers: {
      'x-tenant-id': tenantId,
    },
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({ message: 'Request failed' }));
    throw new Error(error.message || 'Request failed');
  }

  const data = await response.json();
  return { data: data.data ?? data, error: null };
}

Constraints

  • Main orchestrator component
  • Manages edit mode
  • Handles widget add/remove

Task

1. Create apps/web/lib/queries/dashboards.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api';

// Export type for consistency
export type DashboardType = 'SYSTEM' | 'PERSONAL' | 'SHARED';

export interface Dashboard {
  id: string;
  tenantId: string;
  name: string;
  description: string | null;
  type: DashboardType;
  ownerId: string | null;
  config: {
    widgets: Array<{
      id: string;
      widgetId: string;
      title?: string;
      configuration?: Record<string, unknown>;
    }>;
    layout: Array<{
      i: string;
      x: number;
      y: number;
      w: number;
      h: number;
      minW?: number;
      maxW?: number;
      minH?: number;
      maxH?: number;
    }>;
    version: string;
  };
  isDefault: boolean;
  createdAt: string;
  updatedAt: string;
}

export function useDashboards() {
  return useQuery({
    queryKey: ['dashboards'],
    queryFn: async (): Promise<Dashboard[]> => {
      const response = await api.get<Dashboard[]>('/dashboards');
      return response.data;
    },
  });
}

export function useDashboard(id: string) {
  return useQuery({
    queryKey: ['dashboards', id],
    queryFn: async (): Promise<Dashboard> => {
      const response = await api.get<Dashboard>(`/dashboards/${id}`);
      return response.data;
    },
    enabled: !!id,
  });
}

export function useCreateDashboard() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: { name: string; description?: string }) => {
      const response = await api.post<Dashboard>('/dashboards', data);
      return response.data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['dashboards'] });
    },
  });
}

export function useUpdateDashboard() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({
      id,
      ...data
    }: {
      id: string;
      name?: string;
      description?: string;
      config?: Dashboard['config'];
    }) => {
      const response = await api.put<Dashboard>(`/dashboards/${id}`, data);
      return response.data;
    },
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({ queryKey: ['dashboards', variables.id] });
      queryClient.invalidateQueries({ queryKey: ['dashboards'] });
    },
  });
}

export function useDeleteDashboard() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (id: string) => {
      await api.delete(`/dashboards/${id}`);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['dashboards'] });
    },
  });
}

2. Create apps/web/features/dashboard/components/DashboardHeader.tsx:

'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Edit2, Save, X, Plus, MoreVertical, Trash2 } from 'lucide-react';
import { Dashboard, useUpdateDashboard, useDeleteDashboard } from '@/lib/queries/dashboards';

interface DashboardHeaderProps {
  dashboard: Dashboard;
  isEditing: boolean;
  onEditToggle: () => void;
  onAddWidget: () => void;
}

export function DashboardHeader({
  dashboard,
  isEditing,
  onEditToggle,
  onAddWidget,
}: DashboardHeaderProps) {
  const [isRenaming, setIsRenaming] = useState(false);
  const [name, setName] = useState(dashboard.name);
  const updateMutation = useUpdateDashboard();
  const deleteMutation = useDeleteDashboard();

  const handleSaveName = async () => {
    if (name !== dashboard.name) {
      await updateMutation.mutateAsync({ id: dashboard.id, name });
    }
    setIsRenaming(false);
  };

  const handleDelete = async () => {
    if (confirm('Are you sure you want to delete this dashboard?')) {
      await deleteMutation.mutateAsync(dashboard.id);
    }
  };

  return (
    <div className="flex items-center justify-between border-b border-gray-100 pb-4 mb-4">
      <div className="flex items-center gap-2">
        {isRenaming ? (
          <div className="flex items-center gap-2">
            <Input
              value={name}
              onChange={(e) => setName(e.target.value)}
              className="h-8 w-64"
              autoFocus
            />
            <Button size="sm" variant="ghost" onClick={handleSaveName}>
              <Save className="h-4 w-4" />
            </Button>
            <Button
              size="sm"
              variant="ghost"
              onClick={() => {
                setName(dashboard.name);
                setIsRenaming(false);
              }}
            >
              <X className="h-4 w-4" />
            </Button>
          </div>
        ) : (
          <h1
            className="text-2xl font-bold cursor-pointer hover:text-primary"
            onClick={() => dashboard.type !== 'SYSTEM' && setIsRenaming(true)}
          >
            {dashboard.name}
          </h1>
        )}
        {dashboard.type === 'SYSTEM' && (
          <span className="text-xs bg-muted px-2 py-1 rounded">System</span>
        )}
      </div>

      <div className="flex items-center gap-2">
        {isEditing && (
          <Button variant="outline" size="sm" onClick={onAddWidget}>
            <Plus className="h-4 w-4 mr-1" />
            Add Widget
          </Button>
        )}

        <Button
          variant={isEditing ? 'default' : 'outline'}
          size="sm"
          onClick={onEditToggle}
        >
          {isEditing ? (
            <>
              <Save className="h-4 w-4 mr-1" />
              Done
            </>
          ) : (
            <>
              <Edit2 className="h-4 w-4 mr-1" />
              Edit
            </>
          )}
        </Button>

        {dashboard.type !== 'SYSTEM' && (
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="ghost" size="sm">
                <MoreVertical className="h-4 w-4" />
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuItem
                className="text-destructive"
                onClick={handleDelete}
              >
                <Trash2 className="h-4 w-4 mr-2" />
                Delete Dashboard
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        )}
      </div>
    </div>
  );
}

3. Create apps/web/features/dashboard/components/DashboardContainer.tsx:

'use client';

import { useState, useCallback } from 'react';
import { useDashboard, useUpdateDashboard, Dashboard } from '@/lib/queries/dashboards';
import { widgetRegistry, WidgetInstance, LayoutItem } from '../widgets';
import { DashboardHeader } from './DashboardHeader';
import { GridLayout } from './GridLayout';
import { WidgetGallery } from './WidgetGallery';
import { useLayoutPersistence } from '../hooks/useLayoutPersistence';

interface DashboardContainerProps {
  dashboardId: string;
}

export function DashboardContainer({ dashboardId }: DashboardContainerProps) {
  const { data: dashboard, isLoading, error } = useDashboard(dashboardId);
  const updateMutation = useUpdateDashboard();
  const [isEditing, setIsEditing] = useState(false);
  const [showGallery, setShowGallery] = useState(false);

  // Use layout persistence hook for localStorage + debounced API sync
  const { saveLayout, isSyncing } = useLayoutPersistence({
    dashboardId,
    enabled: isEditing,
  });

  const handleLayoutChange = useCallback(
    (newLayout: LayoutItem[]) => {
      if (!dashboard) return;

      // Use layout persistence hook instead of direct mutation
      // This saves to localStorage immediately and syncs to API with debounce
      saveLayout(newLayout);
    },
    [dashboard, saveLayout]
  );

  const handleAddWidget = useCallback(
    (widgetId: string) => {
      if (!dashboard) return;

      const definition = widgetRegistry.get(widgetId);
      if (!definition) return;

      const instance: WidgetInstance = {
        id: `${widgetId}-${Date.now()}`,
        widgetId,
      };

      const layoutItem: LayoutItem = {
        i: instance.id,
        x: 0,
        y: Infinity, // Add at bottom
        w: definition.defaultSize.width,
        h: definition.defaultSize.height,
        minW: definition.minSize.width,
        minH: definition.minSize.height,
        maxW: definition.maxSize?.width,
        maxH: definition.maxSize?.height,
      };

      updateMutation.mutate({
        id: dashboardId,
        config: {
          ...dashboard.config,
          widgets: [...dashboard.config.widgets, instance],
          layout: [...dashboard.config.layout, layoutItem],
        },
      });

      setShowGallery(false);
    },
    [dashboard, dashboardId, updateMutation]
  );

  const handleRemoveWidget = useCallback(
    (instanceId: string) => {
      if (!dashboard) return;

      updateMutation.mutate({
        id: dashboardId,
        config: {
          ...dashboard.config,
          widgets: dashboard.config.widgets.filter((w) => w.id !== instanceId),
          layout: dashboard.config.layout.filter((l) => l.i !== instanceId),
        },
      });
    },
    [dashboard, dashboardId, updateMutation]
  );

  if (isLoading) {
    return (
      <div className="animate-pulse space-y-4">
        <div className="h-10 bg-muted rounded w-1/3" />
        <div className="grid grid-cols-3 gap-4">
          <div className="h-48 bg-muted rounded" />
          <div className="h-48 bg-muted rounded" />
          <div className="h-48 bg-muted rounded" />
        </div>
      </div>
    );
  }

  if (error || !dashboard) {
    return (
      <div className="text-center py-12">
        <p className="text-muted-foreground">Failed to load dashboard</p>
      </div>
    );
  }

  return (
    <div className="dashboard-container">
      <DashboardHeader
        dashboard={dashboard}
        isEditing={isEditing}
        onEditToggle={() => setIsEditing(!isEditing)}
        onAddWidget={() => setShowGallery(true)}
      />

      <GridLayout
        layout={dashboard.config.layout}
        widgets={dashboard.config.widgets}
        isEditing={isEditing}
        onLayoutChange={handleLayoutChange}
        onRemoveWidget={handleRemoveWidget}
      />

      <WidgetGallery
        open={showGallery}
        onClose={() => setShowGallery(false)}
        onSelect={handleAddWidget}
      />
    </div>
  );
}

4. Create apps/web/features/dashboard/components/index.ts:

export * from './DashboardContainer';
export * from './DashboardHeader';

Gate

cd apps/web
npm run build
# May have errors for missing GridLayout and WidgetGallery - that's OK
# They will be created in next steps

# Check files exist
ls -la features/dashboard/components/
# Should show: DashboardContainer.tsx, DashboardHeader.tsx, index.ts

Common Errors

ErrorCauseFix
Module not found: GridLayoutNot created yetCreated in Step 134
Module not found: WidgetGalleryNot created yetCreated in Step 135

Rollback

rm -rf apps/web/features/dashboard/components/
rm apps/web/lib/queries/dashboards.ts

Lock

apps/web/features/dashboard/components/DashboardContainer.tsx
apps/web/features/dashboard/components/DashboardHeader.tsx
apps/web/lib/queries/dashboards.ts

Checkpoint

  • Dashboard queries created
  • DashboardHeader component created
  • DashboardContainer component created
  • Edit mode toggle works

Step 134: Create GridLayout Wrapper

Input

  • Step 133 complete
  • DashboardContainer exists

Constraints

  • Wrap react-grid-layout with our types
  • Support responsive breakpoints
  • Handle layout changes

Prerequisites

Add react-grid-layout CSS to global styles (apps/web/app/globals.css):

/* At the top of globals.css, add: */
@import 'react-grid-layout/css/styles.css';
@import 'react-resizable/css/styles.css';

Note: CSS imports in component files may not work correctly with Next.js App Router. Adding them to globals.css ensures proper loading.

Task

1. Create apps/web/features/dashboard/components/GridLayout.tsx:

'use client';

import { useCallback, useMemo } from 'react';
import { Responsive, WidthProvider, Layout, Layouts } from 'react-grid-layout';
import { WidgetInstance, LayoutItem } from '../widgets';
import { WidgetRenderer } from './WidgetRenderer';

// CSS moved to globals.css for Next.js App Router compatibility

const ResponsiveGridLayout = WidthProvider(Responsive);

const BREAKPOINTS = { xl: 1920, lg: 1200, md: 996, sm: 768, xs: 480 };
const COLS = { xl: 12, lg: 12, md: 8, sm: 4, xs: 1 };
const ROW_HEIGHT = 120;
const MARGIN: [number, number] = [16, 16];

interface GridLayoutProps {
  layout: LayoutItem[];
  widgets: WidgetInstance[];
  isEditing: boolean;
  onLayoutChange: (layout: LayoutItem[]) => void;
  onRemoveWidget: (instanceId: string) => void;
}

export function GridLayout({
  layout,
  widgets,
  isEditing,
  onLayoutChange,
  onRemoveWidget,
}: GridLayoutProps) {
  // Convert our layout format to react-grid-layout format
  const rglLayout = useMemo(
    () =>
      layout.map((item) => ({
        i: item.i,
        x: item.x,
        y: item.y,
        w: item.w,
        h: item.h,
        minW: item.minW,
        minH: item.minH,
        maxW: item.maxW,
        maxH: item.maxH,
        static: item.static,
      })),
    [layout]
  );

  const handleLayoutChange = useCallback(
    (currentLayout: Layout[], allLayouts: Layouts) => {
      // Convert react-grid-layout format back to our format
      const newLayout: LayoutItem[] = currentLayout.map((item) => ({
        i: item.i,
        x: item.x,
        y: item.y,
        w: item.w,
        h: item.h,
        minW: item.minW,
        minH: item.minH,
        maxW: item.maxW,
        maxH: item.maxH,
      }));
      onLayoutChange(newLayout);
    },
    [onLayoutChange]
  );

  if (widgets.length === 0) {
    return (
      <div className="text-center py-24 border-2 border-dashed rounded-lg">
        <p className="text-muted-foreground">
          {isEditing
            ? 'Click "Add Widget" to add your first widget'
            : 'No widgets on this dashboard'}
        </p>
      </div>
    );
  }

  return (
    <ResponsiveGridLayout
      layouts={{ lg: rglLayout }}
      breakpoints={BREAKPOINTS}
      cols={COLS}
      rowHeight={ROW_HEIGHT}
      margin={MARGIN}
      isDraggable={isEditing}
      isResizable={isEditing}
      onLayoutChange={handleLayoutChange}
      compactType="vertical"
      preventCollision={false}
      useCSSTransforms={true}
      resizeHandles={['se', 'sw', 'ne', 'nw', 'e', 'w', 'n', 's']}
      draggableHandle=".widget-drag-handle"
    >
      {widgets.map((widget) => (
        <div key={widget.id} className="widget-wrapper">
          <WidgetRenderer
            instance={widget}
            isEditing={isEditing}
            onRemove={() => onRemoveWidget(widget.id)}
          />
        </div>
      ))}
    </ResponsiveGridLayout>
  );
}

2. Create apps/web/features/dashboard/components/WidgetRenderer.tsx:

'use client';

import { Suspense } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { GripVertical, X, AlertCircle } from 'lucide-react';
import { widgetRegistry, WidgetInstance } from '../widgets';
import { ErrorBoundary } from 'react-error-boundary';

interface WidgetRendererProps {
  instance: WidgetInstance;
  isEditing: boolean;
  onRemove: () => void;
}

function WidgetError({ error }: { error?: Error }) {
  return (
    <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
      <AlertCircle className="h-8 w-8 mb-2" />
      <p className="text-sm">Widget failed to load</p>
      {error && <p className="text-xs mt-1">{error.message}</p>}
    </div>
  );
}

function WidgetSkeleton() {
  return (
    <div className="animate-pulse h-full">
      <div className="h-4 bg-muted rounded w-1/2 mb-4" />
      <div className="h-24 bg-muted rounded" />
    </div>
  );
}

export function WidgetRenderer({
  instance,
  isEditing,
  onRemove,
}: WidgetRendererProps) {
  const definition = widgetRegistry.get(instance.widgetId);

  if (!definition) {
    return (
      <Card className="h-full">
        <CardContent className="flex items-center justify-center h-full">
          <WidgetError error={new Error(`Widget "${instance.widgetId}" not found`)} />
        </CardContent>
      </Card>
    );
  }

  const WidgetComponent = definition.component;
  const title = instance.title || definition.title;

  return (
    <Card className="h-full flex flex-col overflow-hidden">
      <CardHeader className="py-3 px-4 flex-shrink-0">
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-2">
            {isEditing && (
              <GripVertical className="h-4 w-4 text-muted-foreground cursor-move widget-drag-handle" />
            )}
            <CardTitle className="text-sm font-medium">{title}</CardTitle>
          </div>
          {isEditing && (
            <Button
              variant="ghost"
              size="sm"
              className="h-6 w-6 p-0"
              onClick={onRemove}
            >
              <X className="h-4 w-4" />
            </Button>
          )}
        </div>
      </CardHeader>
      <CardContent className="flex-1 overflow-auto p-6">
        <ErrorBoundary fallbackRender={({ error }) => <WidgetError error={error} />}>
          <Suspense fallback={<WidgetSkeleton />}>
            <WidgetComponent
              instance={instance}
              definition={definition}
              isEditing={isEditing}
            />
          </Suspense>
        </ErrorBoundary>
      </CardContent>
    </Card>
  );
}

3. Install react-error-boundary if not present:

cd apps/web
npm install react-error-boundary

4. Update apps/web/features/dashboard/components/index.ts:

export * from './DashboardContainer';
export * from './DashboardHeader';
export * from './GridLayout';
export * from './WidgetRenderer';

Gate

cd apps/web
npm run build
# May have error for missing WidgetGallery - that's OK

# Check files exist
ls -la features/dashboard/components/
# Should show: GridLayout.tsx, WidgetRenderer.tsx

Common Errors

ErrorCauseFix
CSS not foundMissing stylesImport react-grid-layout CSS
WidthProvider errorWrong importUse named import from react-grid-layout

Rollback

rm apps/web/features/dashboard/components/GridLayout.tsx
rm apps/web/features/dashboard/components/WidgetRenderer.tsx

Lock

apps/web/features/dashboard/components/GridLayout.tsx
apps/web/features/dashboard/components/WidgetRenderer.tsx

Checkpoint

  • GridLayout component created
  • WidgetRenderer component created
  • Drag-and-drop enabled in edit mode
  • Error boundary handles widget failures

Step 135: Create WidgetGallery Component

Input

  • Step 134 complete
  • GridLayout exists

Constraints

  • Modal to browse and add widgets
  • Filter by category
  • Search widgets

Task

1. Create apps/web/features/dashboard/components/WidgetGallery.tsx:

'use client';

import { useState, useMemo } from 'react';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent } from '@/components/ui/card';
import { widgetRegistry, WidgetCategory, WidgetDefinition } from '../widgets';
import {
  Users,
  BarChart3,
  List,
  Bot,
  Zap,
  LucideIcon,
} from 'lucide-react';

const categoryIcons: Record<WidgetCategory | 'all', LucideIcon> = {
  all: Zap,
  metrics: Users,
  charts: BarChart3,
  lists: List,
  ai: Bot,
  'quick-actions': Zap,
};

const categoryLabels: Record<WidgetCategory | 'all', string> = {
  all: 'All',
  metrics: 'Metrics',
  charts: 'Charts',
  lists: 'Lists',
  ai: 'AI',
  'quick-actions': 'Quick Actions',
};

interface WidgetGalleryProps {
  open: boolean;
  onClose: () => void;
  onSelect: (widgetId: string) => void;
}

export function WidgetGallery({ open, onClose, onSelect }: WidgetGalleryProps) {
  const [search, setSearch] = useState('');
  const [category, setCategory] = useState<WidgetCategory | 'all'>('all');

  // In a real app, get user roles from session/context
  const userRoles = ['SYSTEM_ADMIN', 'HR_ADMIN', 'MANAGER', 'EMPLOYEE'];

  const availableWidgets = useMemo(() => {
    return widgetRegistry.getForUser(userRoles);
  }, []);

  const filteredWidgets = useMemo(() => {
    return availableWidgets.filter((w) => {
      const matchesSearch =
        w.title.toLowerCase().includes(search.toLowerCase()) ||
        w.description.toLowerCase().includes(search.toLowerCase());
      const matchesCategory = category === 'all' || w.category === category;
      return matchesSearch && matchesCategory;
    });
  }, [availableWidgets, search, category]);

  const categories: (WidgetCategory | 'all')[] = [
    'all',
    'metrics',
    'charts',
    'lists',
    'ai',
    'quick-actions',
  ];

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
        <DialogHeader>
          <DialogTitle>Add Widget</DialogTitle>
        </DialogHeader>

        <div className="space-y-4 flex-1 overflow-hidden flex flex-col">
          <Input
            placeholder="Search widgets..."
            value={search}
            onChange={(e) => setSearch(e.target.value)}
          />

          <Tabs
            value={category}
            onValueChange={(v) => setCategory(v as WidgetCategory | 'all')}
          >
            <TabsList className="w-full justify-start">
              {categories.map((cat) => {
                const Icon = categoryIcons[cat];
                return (
                  <TabsTrigger key={cat} value={cat} className="gap-1">
                    <Icon className="h-4 w-4" />
                    {categoryLabels[cat]}
                  </TabsTrigger>
                );
              })}
            </TabsList>
          </Tabs>

          <div className="flex-1 overflow-auto">
            {filteredWidgets.length === 0 ? (
              <div className="text-center py-12 text-muted-foreground">
                No widgets found
              </div>
            ) : (
              <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
                {filteredWidgets.map((widget) => (
                  <WidgetCard
                    key={widget.id}
                    widget={widget}
                    onClick={() => onSelect(widget.id)}
                  />
                ))}
              </div>
            )}
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}

interface WidgetCardProps {
  widget: WidgetDefinition;
  onClick: () => void;
}

function WidgetCard({ widget, onClick }: WidgetCardProps) {
  const Icon = categoryIcons[widget.category];

  return (
    <Card
      className="cursor-pointer hover:shadow-xl transition-all"
      onClick={onClick}
    >
      <CardContent className="p-6">
        <div className="flex items-start gap-3">
          <div className="p-2 bg-muted rounded-lg">
            <Icon className="h-5 w-5" />
          </div>
          <div className="flex-1 min-w-0">
            <h4 className="font-medium text-sm truncate">{widget.title}</h4>
            <p className="text-xs text-muted-foreground line-clamp-2 mt-1">
              {widget.description}
            </p>
            <div className="flex items-center gap-2 mt-2">
              <span className="text-xs bg-muted px-2 py-0.5 rounded">
                {widget.defaultSize.width}x{widget.defaultSize.height}
              </span>
              {widget.configurable && (
                <span className="text-xs bg-muted px-2 py-0.5 rounded">
                  Configurable
                </span>
              )}
            </div>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

2. Update apps/web/features/dashboard/components/index.ts:

export * from './DashboardContainer';
export * from './DashboardHeader';
export * from './GridLayout';
export * from './WidgetRenderer';
export * from './WidgetGallery';

Gate

cd apps/web
npm run build
# Should compile (may warn about no widgets registered)

# Check file exists
ls -la features/dashboard/components/WidgetGallery.tsx

Common Errors

ErrorCauseFix
Dialog not foundShadcn not installedRun npx shadcn@latest add dialog
Tabs not foundShadcn not installedRun npx shadcn@latest add tabs

Rollback

rm apps/web/features/dashboard/components/WidgetGallery.tsx

Lock

apps/web/features/dashboard/components/WidgetGallery.tsx

Checkpoint

  • WidgetGallery component created
  • Category filtering works
  • Search filtering works
  • Frontend compiles

Step 136: Create HeadcountWidget

Input

  • Step 135 complete
  • Widget gallery exists

Constraints

  • First working widget
  • Fetch real data from API
  • Register in widget registry

Task

1. Create apps/web/features/dashboard/widgets/components/HeadcountWidget.tsx:

'use client';

import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { WidgetProps } from '../types';
import { Users, TrendingUp, TrendingDown, Minus } from 'lucide-react';

interface HeadcountData {
  total: number;
  active: number;
  onLeave: number;
  terminated: number;
  trend: number; // Percentage change from last month
}

export function HeadcountWidget({ instance, definition, isEditing }: WidgetProps) {
  const showTrend = instance.configuration?.showTrend !== false;

  const { data, isLoading, error } = useQuery({
    queryKey: ['widget', 'headcount'],
    queryFn: async (): Promise<HeadcountData> => {
      // NOTE: Uses mock data until HeadcountWidget API endpoint is implemented in production
      // When ready, replace with:
      // const response = await api.get<HeadcountData>('/employees/stats/headcount');
      // return response.data;

      return {
        total: 127,
        active: 115,
        onLeave: 8,
        terminated: 4,
        trend: 3.2,
      };
    },
    refetchInterval: definition.refreshInterval
      ? definition.refreshInterval * 1000
      : undefined,
  });

  if (isLoading) {
    return (
      <div className="animate-pulse space-y-3">
        <div className="h-8 bg-muted rounded w-1/3" />
        <div className="grid grid-cols-3 gap-2">
          <div className="h-12 bg-muted rounded" />
          <div className="h-12 bg-muted rounded" />
          <div className="h-12 bg-muted rounded" />
        </div>
      </div>
    );
  }

  if (error || !data) {
    return (
      <div className="text-center text-muted-foreground">
        Failed to load headcount data
      </div>
    );
  }

  const TrendIcon =
    data.trend > 0 ? TrendingUp : data.trend < 0 ? TrendingDown : Minus;
  const trendColor =
    data.trend > 0
      ? 'text-green-500'
      : data.trend < 0
      ? 'text-red-500'
      : 'text-muted-foreground';

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-3">
          <div className="p-2 bg-primary/10 rounded-lg">
            <Users className="h-6 w-6 text-primary" />
          </div>
          <div>
            <p className="text-3xl font-bold">{data.total}</p>
            <p className="text-sm text-muted-foreground">Total Employees</p>
          </div>
        </div>
        {showTrend && (
          <div className={`flex items-center gap-1 ${trendColor}`}>
            <TrendIcon className="h-4 w-4" />
            <span className="text-sm font-medium">
              {Math.abs(data.trend).toFixed(1)}%
            </span>
          </div>
        )}
      </div>

      <div className="grid grid-cols-3 gap-3">
        <div className="text-center p-3 bg-green-50 dark:bg-green-950/20 rounded-lg">
          <p className="text-lg font-semibold text-green-600">{data.active}</p>
          <p className="text-xs text-muted-foreground">Active</p>
        </div>
        <div className="text-center p-3 bg-yellow-50 dark:bg-yellow-950/20 rounded-lg">
          <p className="text-lg font-semibold text-yellow-600">{data.onLeave}</p>
          <p className="text-xs text-muted-foreground">On Leave</p>
        </div>
        <div className="text-center p-3 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
          <p className="text-lg font-semibold text-gray-600">{data.terminated}</p>
          <p className="text-xs text-muted-foreground">Terminated</p>
        </div>
      </div>
    </div>
  );
}

2. Create apps/web/features/dashboard/widgets/definitions/index.ts:

import { widgetRegistry } from '../registry';
import { HeadcountWidget } from '../components/HeadcountWidget';

// Register Headcount Widget
widgetRegistry.register({
  id: 'headcount-summary',
  title: 'Headcount Summary',
  description: 'Total employees with active/leave/terminated breakdown',
  category: 'metrics',
  icon: 'Users',
  component: HeadcountWidget,
  defaultSize: { width: 4, height: 3 },
  minSize: { width: 3, height: 2 },
  maxSize: { width: 6, height: 4 },
  permissions: {
    requiredRoles: ['SYSTEM_ADMIN', 'HR_ADMIN', 'MANAGER'],
  },
  configurable: true,
  configSchema: [
    {
      key: 'showTrend',
      label: 'Show trend indicator',
      type: 'checkbox',
      defaultValue: true,
    },
  ],
  refreshInterval: 300, // 5 minutes
  enabled: true,
});

// Explicit initialization function to avoid tree-shaking issues
export function initializeWidgets(): void {
  // Widgets are registered above via widgetRegistry.register()
  // This function ensures the module is imported and executed
  console.log('Dashboard widgets initialized');
}

3. Create apps/web/features/dashboard/widgets/components/index.ts:

export * from './HeadcountWidget';

4. Update apps/web/features/dashboard/widgets/index.ts:

export * from './types';
export * from './registry';

// Import and initialize widgets explicitly (avoids tree-shaking issues)
import { initializeWidgets } from './definitions';

// Initialize widgets when this module is imported
initializeWidgets();

Gate

cd apps/web
npm run build
# Should compile without errors

# Check HeadcountWidget is registered
# (Would need to run app and check WidgetGallery)

Common Errors

ErrorCauseFix
Widget not showing in galleryDefinitions not importedImport './definitions' in index.ts
TanStack Query errorMissing QueryClientProviderEnsure providers.tsx wraps app

Rollback

rm -rf apps/web/features/dashboard/widgets/components/
rm -rf apps/web/features/dashboard/widgets/definitions/

Lock

apps/web/features/dashboard/widgets/components/HeadcountWidget.tsx
apps/web/features/dashboard/widgets/definitions/index.ts

Checkpoint

  • HeadcountWidget component created
  • Widget registered in registry
  • Shows mock headcount data
  • Trend indicator works
  • Frontend compiles

Step 137: Create Layout Persistence

Input

  • Step 136 complete
  • HeadcountWidget exists

Constraints

  • Save to localStorage immediately
  • Sync to API with debounce
  • Load from localStorage first, then API

Task

1. Create apps/web/features/dashboard/services/layoutPersistence.ts:

import { LayoutItem, WidgetInstance } from '../widgets';
import { api } from '@/lib/api';

const STORAGE_PREFIX = 'dashboard-layout';

class LayoutPersistenceService {
  // Get storage key for local storage
  private getStorageKey(dashboardId: string): string {
    return `${STORAGE_PREFIX}-${dashboardId}`;
  }

  // Save layout to local storage (with error handling for quota exceeded)
  saveToLocal(dashboardId: string, layout: LayoutItem[]): void {
    const key = this.getStorageKey(dashboardId);
    try {
      localStorage.setItem(
        key,
        JSON.stringify({
          layout,
          timestamp: Date.now(),
        })
      );
    } catch (error) {
      // Handle quota exceeded or other localStorage errors
      console.warn('Failed to save layout to localStorage:', error);
    }
  }

  // Load layout from local storage
  loadFromLocal(dashboardId: string): LayoutItem[] | null {
    const key = this.getStorageKey(dashboardId);
    const stored = localStorage.getItem(key);

    if (!stored) return null;

    try {
      const { layout, timestamp } = JSON.parse(stored);
      // Use local if less than 5 minutes old
      if (Date.now() - timestamp < 5 * 60 * 1000) {
        return layout;
      }
    } catch {
      // Invalid JSON, ignore
    }

    return null;
  }

  // Clear local storage for dashboard
  clearLocal(dashboardId: string): void {
    const key = this.getStorageKey(dashboardId);
    localStorage.removeItem(key);
  }

  // Export layout as JSON string
  exportLayout(
    layout: LayoutItem[],
    widgets: WidgetInstance[]
  ): string {
    return JSON.stringify(
      {
        version: '1.0',
        exportedAt: new Date().toISOString(),
        layout,
        widgets,
      },
      null,
      2
    );
  }

  // Import layout from JSON string
  importLayout(json: string): {
    layout: LayoutItem[];
    widgets: WidgetInstance[];
  } {
    const data = JSON.parse(json);

    if (data.version !== '1.0') {
      throw new Error('Unsupported layout version');
    }

    return {
      layout: data.layout,
      widgets: data.widgets,
    };
  }

  // Download layout as file
  downloadLayout(
    dashboardName: string,
    layout: LayoutItem[],
    widgets: WidgetInstance[]
  ): void {
    const json = this.exportLayout(layout, widgets);
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `${dashboardName.toLowerCase().replace(/\s+/g, '-')}-layout.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }
}

export const layoutPersistence = new LayoutPersistenceService();

2. Create apps/web/features/dashboard/hooks/useLayoutPersistence.ts:

import { useCallback, useEffect, useRef } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { LayoutItem } from '../widgets';
import { layoutPersistence } from '../services/layoutPersistence';
import { useUpdateDashboard } from '@/lib/queries/dashboards';

interface UseLayoutPersistenceOptions {
  dashboardId: string;
  enabled?: boolean;
  debounceMs?: number;
}

export function useLayoutPersistence({
  dashboardId,
  enabled = true,
  debounceMs = 500,
}: UseLayoutPersistenceOptions) {
  const updateMutation = useUpdateDashboard();
  const pendingLayout = useRef<LayoutItem[] | null>(null);

  // Debounced API save
  const saveToApi = useDebouncedCallback(
    async (layout: LayoutItem[]) => {
      if (!enabled) return;

      try {
        await updateMutation.mutateAsync({
          id: dashboardId,
          config: {
            widgets: [], // Will be merged on server
            layout,
            version: '1.0',
          },
        });
      } catch (error) {
        console.error('Failed to save layout to API:', error);
      }
    },
    debounceMs
  );

  // Save layout (local immediately, API debounced)
  const saveLayout = useCallback(
    (layout: LayoutItem[]) => {
      if (!enabled) return;

      // Save to local storage immediately
      layoutPersistence.saveToLocal(dashboardId, layout);
      pendingLayout.current = layout;

      // Debounced save to API
      saveToApi(layout);
    },
    [dashboardId, enabled, saveToApi]
  );

  // Load layout from local storage
  const loadLocalLayout = useCallback(() => {
    if (!enabled) return null;
    return layoutPersistence.loadFromLocal(dashboardId);
  }, [dashboardId, enabled]);

  // Clear local storage
  const clearLocalLayout = useCallback(() => {
    layoutPersistence.clearLocal(dashboardId);
  }, [dashboardId]);

  // Flush pending save on unmount
  useEffect(() => {
    return () => {
      if (pendingLayout.current) {
        saveToApi.flush();
      }
    };
  }, [saveToApi]);

  return {
    saveLayout,
    loadLocalLayout,
    clearLocalLayout,
    isSaving: updateMutation.isPending,
  };
}

3. Install use-debounce if not present:

cd apps/web
npm install use-debounce

4. Create apps/web/features/dashboard/hooks/index.ts:

export * from './useLayoutPersistence';

5. Create apps/web/features/dashboard/services/index.ts:

export * from './layoutPersistence';

Gate

cd apps/web
npm run build
# Should compile without errors

# Check files exist
ls -la features/dashboard/services/
ls -la features/dashboard/hooks/

Common Errors

ErrorCauseFix
useDebouncedCallback not foundMissing packageRun npm install use-debounce
localStorage not definedSSR issueAdd typeof window check

Rollback

rm -rf apps/web/features/dashboard/services/
rm -rf apps/web/features/dashboard/hooks/

Lock

apps/web/features/dashboard/services/layoutPersistence.ts
apps/web/features/dashboard/hooks/useLayoutPersistence.ts

Checkpoint

  • Layout persistence service created
  • useLayoutPersistence hook created
  • Local storage save/load works
  • Debounced API sync works
  • Frontend compiles

Step 138: Create Dashboard Page

Input

  • Step 137 complete
  • All components exist

Prerequisites

mkdir -p apps/web/app/dashboard/dashboards

Constraints

  • Create dashboard list and detail pages
  • Add to sidebar navigation

Task

1. Create apps/web/app/dashboard/dashboards/page.tsx:

'use client';

import { useState } from 'react';
import { useDashboards, useCreateDashboard } from '@/lib/queries/dashboards';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Plus, LayoutDashboard, Lock, Users } from 'lucide-react';
import Link from 'next/link';

export default function DashboardsPage() {
  const { data: dashboards, isLoading } = useDashboards();
  const createMutation = useCreateDashboard();
  const [showCreate, setShowCreate] = useState(false);
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');

  const handleCreate = async () => {
    if (!name.trim()) return;

    await createMutation.mutateAsync({
      name: name.trim(),
      description: description.trim() || undefined,
    });

    setName('');
    setDescription('');
    setShowCreate(false);
  };

  if (isLoading) {
    return (
      <div className="space-y-4">
        <div className="h-10 bg-muted rounded w-1/4 animate-pulse" />
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {[1, 2, 3].map((i) => (
            <div key={i} className="h-32 bg-muted rounded animate-pulse" />
          ))}
        </div>
      </div>
    );
  }

  const systemDashboards = dashboards?.filter((d) => d.type === 'SYSTEM') || [];
  const personalDashboards =
    dashboards?.filter((d) => d.type !== 'SYSTEM') || [];

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">Dashboards</h1>
        <Dialog open={showCreate} onOpenChange={setShowCreate}>
          <DialogTrigger asChild>
            <Button>
              <Plus className="h-4 w-4 mr-2" />
              New Dashboard
            </Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>Create Dashboard</DialogTitle>
            </DialogHeader>
            <div className="space-y-4">
              <div className="space-y-2">
                <Label htmlFor="name">Name</Label>
                <Input
                  id="name"
                  value={name}
                  onChange={(e) => setName(e.target.value)}
                  placeholder="My Dashboard"
                />
              </div>
              <div className="space-y-2">
                <Label htmlFor="description">Description (optional)</Label>
                <Textarea
                  id="description"
                  value={description}
                  onChange={(e) => setDescription(e.target.value)}
                  placeholder="Dashboard for tracking..."
                />
              </div>
              <div className="flex justify-end gap-2">
                <Button variant="outline" onClick={() => setShowCreate(false)}>
                  Cancel
                </Button>
                <Button
                  onClick={handleCreate}
                  disabled={!name.trim() || createMutation.isPending}
                >
                  {createMutation.isPending ? 'Creating...' : 'Create'}
                </Button>
              </div>
            </div>
          </DialogContent>
        </Dialog>
      </div>

      {systemDashboards.length > 0 && (
        <div className="space-y-3">
          <h2 className="text-lg font-medium flex items-center gap-2">
            <Lock className="h-4 w-4" />
            System Dashboards
          </h2>
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
            {systemDashboards.map((dashboard) => (
              <DashboardCard key={dashboard.id} dashboard={dashboard} />
            ))}
          </div>
        </div>
      )}

      <div className="space-y-3">
        <h2 className="text-lg font-medium flex items-center gap-2">
          <Users className="h-4 w-4" />
          My Dashboards
        </h2>
        {personalDashboards.length === 0 ? (
          <Card>
            <CardContent className="py-12 text-center">
              <LayoutDashboard className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
              <p className="text-muted-foreground">
                No dashboards yet. Create your first dashboard!
              </p>
            </CardContent>
          </Card>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
            {personalDashboards.map((dashboard) => (
              <DashboardCard key={dashboard.id} dashboard={dashboard} />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

function DashboardCard({
  dashboard,
}: {
  dashboard: { id: string; name: string; description: string | null; type: string };
}) {
  return (
    <Link href={`/dashboard/dashboards/${dashboard.id}`}>
      <Card className="h-full hover:border-primary transition-colors cursor-pointer">
        <CardHeader className="pb-2">
          <div className="flex items-center justify-between">
            <CardTitle className="text-base">{dashboard.name}</CardTitle>
            {dashboard.type === 'SYSTEM' && (
              <Lock className="h-4 w-4 text-muted-foreground" />
            )}
            {dashboard.type === 'SHARED' && (
              <Users className="h-4 w-4 text-muted-foreground" />
            )}
          </div>
        </CardHeader>
        <CardContent>
          <p className="text-sm text-muted-foreground line-clamp-2">
            {dashboard.description || 'No description'}
          </p>
        </CardContent>
      </Card>
    </Link>
  );
}

2. Create apps/web/app/dashboard/dashboards/[id]/page.tsx:

'use client';

import { use } from 'react';
import { DashboardContainer } from '@/features/dashboard/components';

interface Props {
  params: Promise<{ id: string }>;
}

export default function DashboardDetailPage({ params }: Props) {
  const { id } = use(params);

  return (
    <div className="p-6">
      <DashboardContainer dashboardId={id} />
    </div>
  );
}

3. Create apps/web/features/dashboard/index.ts:

export * from './components';
export * from './widgets';
export * from './hooks';
export * from './services';

Add sidebar entry for dashboards:

// In sidebar navigation
{
  name: 'Dashboards',
  href: '/dashboard/dashboards',
  icon: LayoutDashboard,
}

Gate

cd apps/web
npm run build
# Should compile without errors

# Start the app
npm run dev
# Navigate to /dashboard/dashboards
# Should see dashboard list page

Common Errors

ErrorCauseFix
Module not foundWrong import pathCheck @/features path alias
Page not foundMissing page fileCheck file path and name
use() only works in Client ComponentsMissing 'use client'Add 'use client' directive

Rollback

rm -rf apps/web/app/dashboard/dashboards/

Lock

apps/web/app/dashboard/dashboards/page.tsx
apps/web/app/dashboard/dashboards/[id]/page.tsx

Checkpoint

  • Dashboard list page created
  • Dashboard detail page created
  • Create dashboard dialog works
  • Navigation to individual dashboards works
  • Frontend compiles

Files Created

StepFiles
123schema.prisma (DashboardType enum)
124schema.prisma (ShareType, SharePermission enums)
125schema.prisma (Dashboard model)
126schema.prisma (DashboardShare model)
127schema.prisma (DashboardLayout model + all relations)
129apps/web/features/dashboard/widgets/*
130apps/api/src/dashboards/dashboard.service.ts, apps/api/src/dashboards/dto/*
131apps/api/src/dashboards/dashboard.controller.ts, apps/api/src/dashboards/dashboards.module.ts
133apps/web/lib/queries/dashboards.ts, apps/web/features/dashboard/components/DashboardContainer.tsx, apps/web/features/dashboard/components/DashboardHeader.tsx
134apps/web/features/dashboard/components/GridLayout.tsx, apps/web/features/dashboard/components/WidgetRenderer.tsx
135apps/web/features/dashboard/components/WidgetGallery.tsx
136apps/web/features/dashboard/widgets/components/HeadcountWidget.tsx, apps/web/features/dashboard/widgets/definitions/index.ts
137apps/web/features/dashboard/services/layoutPersistence.ts, apps/web/features/dashboard/hooks/useLayoutPersistence.ts
138apps/web/app/dashboard/dashboards/page.tsx, apps/web/app/dashboard/dashboards/[id]/page.tsx

Phase Completion Gate

Before moving to Phase 09:

# 1. All dashboard tables exist
npx prisma studio
# Verify: dashboards, dashboard_shares, dashboard_layouts

# 2. API compiles
cd apps/api && npm run build

# 3. Frontend compiles
cd apps/web && npm run build

# 4. Dashboard endpoints work
curl -X POST http://localhost:3001/api/v1/dashboards \
  -H "X-Tenant-ID: test" \
  -H "Content-Type: application/json" \
  -d '{"name": "Test Dashboard"}'

# 5. Dashboard list page loads
# Navigate to /dashboard/dashboards

# 6. Widget gallery shows HeadcountWidget
# Click "Add Widget" in edit mode

Locked Files After Phase 8

  • All Phase 7 locks, plus:
  • All dashboard models in schema.prisma
  • apps/api/src/dashboards/*
  • apps/web/features/dashboard/*
  • apps/web/app/dashboard/dashboards/*
  • apps/web/lib/queries/dashboards.ts

Add sidebar entry for dashboards:

// In sidebar navigation
{
  name: 'Dashboards',
  href: '/dashboard/dashboards',
  icon: LayoutDashboard,
}

Step 139: Add Dashboard Sharing Endpoint (DB-03)

Input

  • Step 138 complete
  • DashboardShare model exists

Constraints

  • Only dashboard owner can share
  • Support user and role-based sharing
  • ONLY add sharing endpoints

Task

1. Create Share DTO at apps/api/src/dashboards/dto/share-dashboard.dto.ts:

import { IsString, IsEnum, IsOptional, IsArray } from 'class-validator';

export enum ShareTypeEnum {
  USER = 'USER',
  ROLE = 'ROLE',
}

export enum SharePermissionEnum {
  VIEW = 'VIEW',
  EDIT = 'EDIT',
}

export class ShareDashboardDto {
  @IsEnum(ShareTypeEnum)
  shareType: ShareTypeEnum;

  @IsString()
  shareWithId: string;  // userId or role name

  @IsEnum(SharePermissionEnum)
  permission: SharePermissionEnum;
}

export class BulkShareDto {
  @IsArray()
  shares: ShareDashboardDto[];
}

2. Add Sharing Methods to DashboardService:

// Add to apps/api/src/dashboards/dashboard.service.ts

async shareDashboard(
  tenantId: string,
  dashboardId: string,
  dto: ShareDashboardDto,
  ownerId: string,
) {
  // Verify ownership
  const dashboard = await this.prisma.dashboard.findFirst({
    where: { id: dashboardId, tenantId, ownerId },
  });
  if (!dashboard) {
    throw new ForbiddenException('Only dashboard owner can share');
  }

  // Create or update share
  return this.prisma.dashboardShare.upsert({
    where: {
      dashboardId_shareType_shareWithId: {
        dashboardId,
        shareType: dto.shareType,
        shareWithId: dto.shareWithId,
      },
    },
    create: {
      dashboardId,
      shareType: dto.shareType,
      shareWithId: dto.shareWithId,
      permission: dto.permission,
    },
    update: {
      permission: dto.permission,
    },
  });
}

async removeShare(
  tenantId: string,
  dashboardId: string,
  shareId: string,
  ownerId: string,
) {
  // Verify ownership
  const dashboard = await this.prisma.dashboard.findFirst({
    where: { id: dashboardId, tenantId, ownerId },
  });
  if (!dashboard) {
    throw new ForbiddenException('Only dashboard owner can remove shares');
  }

  return this.prisma.dashboardShare.delete({
    where: { id: shareId },
  });
}

async getShares(tenantId: string, dashboardId: string) {
  return this.prisma.dashboardShare.findMany({
    where: {
      dashboardId,
      dashboard: { tenantId },
    },
    include: {
      // Include user info if shared with user
    },
  });
}

3. Add Sharing Endpoints to DashboardController:

// Add to apps/api/src/dashboards/dashboard.controller.ts

@Post(':id/shares')
async shareDashboard(
  @TenantId() tenantId: string,
  @Param('id') dashboardId: string,
  @Body() dto: ShareDashboardDto,
  @CurrentUser('id') userId: string,
) {
  const data = await this.dashboardService.shareDashboard(
    tenantId,
    dashboardId,
    dto,
    userId,
  );
  return { data, error: null };
}

@Get(':id/shares')
async getShares(
  @TenantId() tenantId: string,
  @Param('id') dashboardId: string,
) {
  const data = await this.dashboardService.getShares(tenantId, dashboardId);
  return { data, error: null };
}

@Delete(':id/shares/:shareId')
async removeShare(
  @TenantId() tenantId: string,
  @Param('id') dashboardId: string,
  @Param('shareId') shareId: string,
  @CurrentUser('id') userId: string,
) {
  await this.dashboardService.removeShare(tenantId, dashboardId, shareId, userId);
  return { success: true, error: null };
}

Gate

# Test sharing
curl -X POST http://localhost:3001/api/v1/dashboards/DASHBOARD_ID/shares \
  -H "Content-Type: application/json" \
  -H "x-tenant-id: YOUR_TENANT_ID" \
  -d '{
    "shareType": "USER",
    "shareWithId": "USER_ID",
    "permission": "VIEW"
  }'
# Should return share object

# Get shares
curl http://localhost:3001/api/v1/dashboards/DASHBOARD_ID/shares \
  -H "x-tenant-id: YOUR_TENANT_ID"
# Should return array of shares

Checkpoint

  • POST /dashboards/:id/shares creates share
  • GET /dashboards/:id/shares lists shares
  • DELETE /dashboards/:id/shares/:shareId removes share
  • Only owner can share
  • Type "GATE 139 PASSED" to continue

Step 140: Add Dashboard Sharing UI (DB-04)

Input

  • Step 139 complete
  • Sharing API endpoints work

Constraints

  • Show share dialog from dashboard header
  • Support user search for sharing
  • Show current shares with permission levels
  • ONLY add sharing UI components

Task

1. Create Share Dialog Component at apps/web/features/dashboard/components/ShareDialog.tsx:

'use client';

import { useState } from 'react';
import { toast } from 'sonner';
import { Users, X, Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { useShareDashboard, useRemoveShare, useDashboardShares } from '@/lib/queries/dashboards';
import { useEmployees } from '@/lib/queries/employees';

interface ShareDialogProps {
  dashboardId: string;
  dashboardName: string;
  isOwner: boolean;
  children: React.ReactNode;
}

export function ShareDialog({
  dashboardId,
  dashboardName,
  isOwner,
  children,
}: ShareDialogProps) {
  const [open, setOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');
  const [selectedUserId, setSelectedUserId] = useState('');
  const [permission, setPermission] = useState<'VIEW' | 'EDIT'>('VIEW');

  const { data: shares, isLoading: sharesLoading } = useDashboardShares(dashboardId);
  const { data: employees } = useEmployees(searchQuery);
  const shareDashboard = useShareDashboard();
  const removeShare = useRemoveShare();

  const handleShare = async () => {
    if (!selectedUserId) {
      toast.error('Please select a user');
      return;
    }

    try {
      await shareDashboard.mutateAsync({
        dashboardId,
        shareType: 'USER',
        shareWithId: selectedUserId,
        permission,
      });
      toast.success('Dashboard shared successfully');
      setSelectedUserId('');
      setSearchQuery('');
    } catch (error) {
      toast.error('Failed to share dashboard');
    }
  };

  const handleRemoveShare = async (shareId: string) => {
    try {
      await removeShare.mutateAsync({ dashboardId, shareId });
      toast.success('Share removed');
    } catch (error) {
      toast.error('Failed to remove share');
    }
  };

  const copyLink = () => {
    navigator.clipboard.writeText(
      `${window.location.origin}/dashboard/dashboards/${dashboardId}`
    );
    toast.success('Link copied to clipboard');
  };

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>{children}</DialogTrigger>
      <DialogContent className="max-w-md">
        <DialogHeader>
          <DialogTitle className="flex items-center gap-2">
            <Users className="h-5 w-5" />
            Share "{dashboardName}"
          </DialogTitle>
          <DialogDescription>
            Share this dashboard with team members
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-4 py-4">
          {/* Copy link */}
          <div className="flex items-center gap-2">
            <Input
              value={`${window.location.origin}/dashboard/dashboards/${dashboardId}`}
              readOnly
              className="text-sm"
            />
            <Button variant="outline" size="icon" onClick={copyLink}>
              <Copy className="h-4 w-4" />
            </Button>
          </div>

          {isOwner && (
            <>
              {/* Add new share */}
              <div className="space-y-2">
                <label className="text-sm font-medium">Share with user</label>
                <div className="flex gap-2">
                  <Select value={selectedUserId} onValueChange={setSelectedUserId}>
                    <SelectTrigger className="flex-1">
                      <SelectValue placeholder="Select user..." />
                    </SelectTrigger>
                    <SelectContent>
                      {employees?.map((emp: any) => (
                        <SelectItem key={emp.id} value={emp.userId || emp.id}>
                          {emp.firstName} {emp.lastName}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                  <Select
                    value={permission}
                    onValueChange={(v) => setPermission(v as 'VIEW' | 'EDIT')}
                  >
                    <SelectTrigger className="w-24">
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="VIEW">View</SelectItem>
                      <SelectItem value="EDIT">Edit</SelectItem>
                    </SelectContent>
                  </Select>
                  <Button onClick={handleShare} disabled={shareDashboard.isPending}>
                    Share
                  </Button>
                </div>
              </div>

              {/* Current shares */}
              {shares && shares.length > 0 && (
                <div className="space-y-2">
                  <label className="text-sm font-medium">Shared with</label>
                  <div className="space-y-2">
                    {shares.map((share: any) => (
                      <div
                        key={share.id}
                        className="flex items-center justify-between p-2 rounded-lg border"
                      >
                        <div className="flex items-center gap-2">
                          <Avatar className="h-8 w-8">
                            <AvatarFallback>
                              {share.shareType === 'ROLE' ? 'R' : 'U'}
                            </AvatarFallback>
                          </Avatar>
                          <div>
                            <p className="text-sm font-medium">
                              {share.shareType === 'ROLE'
                                ? share.shareWithId
                                : share.user?.name || share.shareWithId}
                            </p>
                            <Badge variant="secondary" className="text-xs">
                              {share.permission}
                            </Badge>
                          </div>
                        </div>
                        <Button
                          variant="ghost"
                          size="icon"
                          onClick={() => handleRemoveShare(share.id)}
                          disabled={removeShare.isPending}
                        >
                          <X className="h-4 w-4" />
                        </Button>
                      </div>
                    ))}
                  </div>
                </div>
              )}
            </>
          )}
        </div>
      </DialogContent>
    </Dialog>
  );
}

2. Add Share Button to Dashboard Header - Update apps/web/features/dashboard/components/DashboardHeader.tsx:

import { ShareDialog } from './ShareDialog';

// Add in the header actions:
<ShareDialog
  dashboardId={dashboard.id}
  dashboardName={dashboard.name}
  isOwner={dashboard.ownerId === currentUserId}
>
  <Button variant="outline" size="sm">
    <Users className="h-4 w-4 mr-2" />
    Share
  </Button>
</ShareDialog>

3. Add Query Hooks to apps/web/lib/queries/dashboards.ts:

export function useDashboardShares(dashboardId: string) {
  return useQuery({
    queryKey: ['dashboard-shares', dashboardId],
    queryFn: async () => {
      const response = await api.get(`/dashboards/${dashboardId}/shares`);
      return response.data;
    },
    enabled: !!dashboardId,
  });
}

export function useShareDashboard() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: {
      dashboardId: string;
      shareType: 'USER' | 'ROLE';
      shareWithId: string;
      permission: 'VIEW' | 'EDIT';
    }) => {
      const { dashboardId, ...body } = data;
      const response = await api.post(`/dashboards/${dashboardId}/shares`, body);
      return response.data;
    },
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({
        queryKey: ['dashboard-shares', variables.dashboardId],
      });
    },
  });
}

export function useRemoveShare() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ dashboardId, shareId }: { dashboardId: string; shareId: string }) => {
      const response = await api.delete(`/dashboards/${dashboardId}/shares/${shareId}`);
      return response.data;
    },
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({
        queryKey: ['dashboard-shares', variables.dashboardId],
      });
    },
  });
}

Gate

cd apps/web && npm run dev
# Navigate to a dashboard you own
# Click "Share" button
# Should show share dialog with:
# - Copy link button
# - User selector for sharing
# - Permission dropdown (View/Edit)
# - List of current shares
# Share with a user and verify it appears in the list

Checkpoint

  • Share button appears in dashboard header
  • Share dialog opens with correct content
  • Can share with users
  • Can change permission level
  • Can remove shares
  • Type "GATE 140 PASSED" to continue

Step 141: Add Quick Stats Widget (DB-09)

Input

  • Step 140 complete
  • Widget system exists

Constraints

  • Show key metrics at a glance
  • Configurable metric selection
  • Real-time data from existing endpoints
  • ONLY add QuickStatsWidget

Task

1. Create Quick Stats Widget at apps/web/features/dashboard/widgets/components/QuickStatsWidget.tsx:

'use client';

import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { TrendingUp, TrendingDown, Users, Calendar, FileText, Clock } from 'lucide-react';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';

interface QuickStatsWidgetProps {
  config?: {
    metrics?: string[];  // Which metrics to show
  };
}

interface Stat {
  label: string;
  value: string | number;
  change?: number;
  changeLabel?: string;
  icon: React.ReactNode;
  color: string;
}

export function QuickStatsWidget({ config }: QuickStatsWidgetProps) {
  const selectedMetrics = config?.metrics || [
    'totalEmployees',
    'pendingApprovals',
    'documentsUploaded',
    'pendingTimeOff',
  ];

  const { data: stats, isLoading } = useQuery({
    queryKey: ['quick-stats', selectedMetrics],
    queryFn: async () => {
      // Fetch multiple stats in parallel
      const [employees, pendingApprovals, documents, timeOffPending] = await Promise.all([
        api.get('/employees/count').catch(() => ({ data: { count: 0 } })),
        api.get('/timeoff/requests/pending-approvals/count').catch(() => ({ data: { count: 0 } })),
        api.get('/documents/count').catch(() => ({ data: { count: 0 } })),
        api.get('/timeoff/requests?status=PENDING').catch(() => ({ data: { count: 0 } })),
      ]);

      return {
        totalEmployees: employees.data?.count || 0,
        pendingApprovals: pendingApprovals.data?.count || 0,
        documentsUploaded: documents.data?.count || 0,
        pendingTimeOff: Array.isArray(timeOffPending.data) ? timeOffPending.data.length : 0,
      };
    },
    refetchInterval: 60000, // Refresh every minute
  });

  const statDefinitions: Record<string, Stat> = {
    totalEmployees: {
      label: 'Total Employees',
      value: stats?.totalEmployees ?? 0,
      icon: <Users className="h-4 w-4" />,
      color: 'text-blue-600',
    },
    pendingApprovals: {
      label: 'Pending Approvals',
      value: stats?.pendingApprovals ?? 0,
      icon: <Clock className="h-4 w-4" />,
      color: 'text-orange-600',
    },
    documentsUploaded: {
      label: 'Documents',
      value: stats?.documentsUploaded ?? 0,
      icon: <FileText className="h-4 w-4" />,
      color: 'text-green-600',
    },
    pendingTimeOff: {
      label: 'Time-Off Pending',
      value: stats?.pendingTimeOff ?? 0,
      icon: <Calendar className="h-4 w-4" />,
      color: 'text-purple-600',
    },
  };

  if (isLoading) {
    return (
      <Card className="h-full">
        <CardHeader className="pb-2">
          <CardTitle className="text-sm font-medium">Quick Stats</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="grid grid-cols-2 gap-4">
            {[...Array(4)].map((_, i) => (
              <Skeleton key={i} className="h-16" />
            ))}
          </div>
        </CardContent>
      </Card>
    );
  }

  const displayStats = selectedMetrics
    .map((key) => statDefinitions[key])
    .filter(Boolean);

  return (
    <Card className="h-full">
      <CardHeader className="pb-2">
        <CardTitle className="text-sm font-medium">Quick Stats</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="grid grid-cols-2 gap-4">
          {displayStats.map((stat, index) => (
            <div
              key={index}
              className="flex items-center justify-between p-3 rounded-lg bg-muted/50"
            >
              <div>
                <p className="text-xs text-muted-foreground">{stat.label}</p>
                <p className="text-2xl font-bold">{stat.value}</p>
                {stat.change !== undefined && (
                  <div
                    className={cn(
                      'flex items-center text-xs',
                      stat.change >= 0 ? 'text-green-600' : 'text-red-600'
                    )}
                  >
                    {stat.change >= 0 ? (
                      <TrendingUp className="h-3 w-3 mr-1" />
                    ) : (
                      <TrendingDown className="h-3 w-3 mr-1" />
                    )}
                    {Math.abs(stat.change)}% {stat.changeLabel}
                  </div>
                )}
              </div>
              <div
                className={cn(
                  'p-2 rounded-full bg-background',
                  stat.color
                )}
              >
                {stat.icon}
              </div>
            </div>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}

2. Register Widget in apps/web/features/dashboard/widgets/definitions/index.ts:

import { QuickStatsWidget } from '../components/QuickStatsWidget';

export const widgetDefinitions = {
  // ... existing widgets ...
  quickStats: {
    id: 'quickStats',
    name: 'Quick Stats',
    description: 'Key metrics at a glance',
    component: QuickStatsWidget,
    defaultSize: { w: 4, h: 2 },
    minSize: { w: 2, h: 2 },
    configurable: true,
    configSchema: {
      metrics: {
        type: 'multiselect',
        label: 'Metrics to display',
        options: [
          { value: 'totalEmployees', label: 'Total Employees' },
          { value: 'pendingApprovals', label: 'Pending Approvals' },
          { value: 'documentsUploaded', label: 'Documents' },
          { value: 'pendingTimeOff', label: 'Time-Off Pending' },
        ],
        default: ['totalEmployees', 'pendingApprovals', 'documentsUploaded', 'pendingTimeOff'],
      },
    },
  },
};

3. Add Count Endpoints (if not already existing) to relevant controllers:

// apps/api/src/employees/employees.controller.ts
@Get('count')
async count(@TenantId() tenantId: string) {
  const count = await this.employeesService.count(tenantId);
  return { data: { count }, error: null };
}

// apps/api/src/documents/documents.controller.ts
@Get('count')
async count(@TenantId() tenantId: string) {
  const count = await this.documentsService.count(tenantId);
  return { data: { count }, error: null };
}

// apps/api/src/time-off/time-off.controller.ts
@Get('requests/pending-approvals/count')
async countPendingApprovals(@TenantId() tenantId: string, @CurrentUser('id') userId: string) {
  const count = await this.requestService.countPendingApprovals(tenantId, userId);
  return { data: { count }, error: null };
}

Gate

cd apps/web && npm run dev
# Navigate to a dashboard
# Add "Quick Stats" widget from the gallery
# Should show 4 stat cards:
# - Total Employees
# - Pending Approvals
# - Documents
# - Time-Off Pending
# Stats should update when data changes

Checkpoint

  • QuickStatsWidget component created
  • Widget registered in definitions
  • Shows 4 default metrics
  • Data fetches from API
  • Configurable metrics selection
  • Type "GATE 141 PASSED" to continue


Phase Completion Checklist (MANDATORY)

BEFORE MOVING TO NEXT PHASE

Complete ALL items before proceeding. Do NOT skip any step.

1. Gate Verification

  • All step gates passed
  • Dashboard widgets working
  • Quick actions functional
  • Stats cards showing data

2. Update PROJECT_STATE.md

- Mark Phase 08 as COMPLETED with timestamp
- Update "Current Phase" to Phase 09
- Add session log entry

3. Update WHAT_EXISTS.md

## API Endpoints
- /api/v1/dashboard/stats

## Frontend Routes
- /dashboard (enhanced)

## Established Patterns
- Dashboard widget pattern

4. Git Tag & Commit

git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 08 - Dashboard"
git tag phase-08-dashboard

Next Phase

After verification, proceed to Phase 09: AI Integration


Last Updated: 2025-11-30

On this page

Phase 08: Dashboard SystemStep Timing EstimatesPhase Context (READ FIRST)What This Phase AccomplishesWhat This Phase Does NOT IncludeBluewoo Anti-Pattern ReminderKnown Limitations (MVP)Step 123: Add DashboardType EnumInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 124: Add ShareType and SharePermission EnumsInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 125: Add Dashboard ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 126: Add DashboardShare ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 127: Add DashboardLayout Model + ALL RelationsInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 128: Run MigrationInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 129: Create Widget Registry TypesInputPrerequisitesConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 130: Create DashboardServiceInputPrerequisitesConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 131: Create DashboardControllerInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 132: Install react-grid-layoutInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 133: Create DashboardContainer ComponentInputPrerequisitesConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 134: Create GridLayout WrapperInputConstraintsPrerequisitesTaskGateCommon ErrorsRollbackLockCheckpointStep 135: Create WidgetGallery ComponentInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 136: Create HeadcountWidgetInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 137: Create Layout PersistenceInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 138: Create Dashboard PageInputPrerequisitesConstraintsTaskNavigationGateCommon ErrorsRollbackLockCheckpointFiles CreatedPhase Completion GateLocked Files After Phase 8NavigationStep 139: Add Dashboard Sharing Endpoint (DB-03)InputConstraintsTaskGateCheckpointStep 140: Add Dashboard Sharing UI (DB-04)InputConstraintsTaskGateCheckpointStep 141: Add Quick Stats Widget (DB-09)InputConstraintsTaskGateCheckpointRelated DocumentationPhase Completion Checklist (MANDATORY)1. Gate Verification2. Update PROJECT_STATE.md3. Update WHAT_EXISTS.md4. Git Tag & CommitNext Phase