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.
| Attribute | Value |
|---|---|
| Steps | 123-138 |
| Estimated Time | 8-12 hours |
| Dependencies | Phase 07 complete (Tags & Custom Fields) |
| Completion Gate | Users can create dashboards, add widgets, drag-and-drop layout, layouts persist. |
Step Timing Estimates
| Step | Task | Est. Time |
|---|---|---|
| 123 | Add DashboardType enum | 10 min |
| 124 | Add ShareType, SharePermission enums | 10 min |
| 125 | Add Dashboard model (no relations) | 20 min |
| 126 | Add DashboardShare model | 15 min |
| 127 | Add DashboardLayout model + ALL relations | 20 min |
| 128 | Run migration | 15 min |
| 129 | Create widget registry types | 30 min |
| 130 | Create DashboardService | 45 min |
| 131 | Create DashboardController | 40 min |
| 132 | Install react-grid-layout | 15 min |
| 133 | Create DashboardContainer component | 45 min |
| 134 | Create GridLayout wrapper | 40 min |
| 135 | Create WidgetGallery component | 35 min |
| 136 | Create HeadcountWidget | 30 min |
| 137 | Create layout persistence | 35 min |
| 138 | Create dashboard page | 30 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
USERandCOMPANYshare types are currently enforced TEAMandDEPARTMENTshares 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/usageendpoint 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, SHAREDCommon Errors
| Error | Cause | Fix |
|---|---|---|
Enum already exists | Duplicate definition | Remove duplicate |
Rollback
# Remove DashboardType enum from schema.prismaLock
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, EDITCommon Errors
| Error | Cause | Fix |
|---|---|---|
Enum already exists | Duplicate definition | Remove duplicate |
Rollback
# Remove ShareType and SharePermission enums from schema.prismaLock
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 fieldsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "DashboardType" | Enum not defined | Complete Step 123 first |
Json type not supported | Old Prisma version | Run npm update @prisma/client prisma |
Rollback
# Remove Dashboard model from schema.prismaLock
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 fieldsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "ShareType" | Enum not defined | Complete Step 124 first |
Rollback
# Remove DashboardShare model from schema.prismaLock
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 validCommon Errors
| Error | Cause | Fix |
|---|---|---|
Unknown model "Dashboard" | Model not defined | Complete Step 125 first |
Relation not found | Missing reverse relation | Add relation to both sides |
Ambiguous relation | Multiple relations to same model | Use named relations (e.g., "UserDashboards") |
Rollback
# Remove DashboardLayout model
# Remove relations from Dashboard, DashboardShare, User, TenantLock
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 generateGate
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
| Error | Cause | Fix |
|---|---|---|
Foreign key constraint failed | Missing related table | Run migration in correct order |
Column type mismatch | Schema changed incompatibly | Reset 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/components2. 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.tsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Module not found | Wrong path | Check import paths |
Type error | Missing type import | Import from './types' |
Rollback
rm -rf apps/web/features/dashboard/widgets/Lock
apps/web/features/dashboard/widgets/types.ts
apps/web/features/dashboard/widgets/registry.tsCheckpoint
- 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/dtoConstraints
- 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
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@prisma/client' | prisma generate not run | Run cd packages/database && npx prisma generate |
Property 'dashboard' does not exist | PrismaService missing model | Check 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
| Error | Cause | Fix |
|---|---|---|
TenantGuard not found | Missing import | Import from '../tenant/tenant.guard' |
Cannot find module | Module not registered | Add 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.tsLock
apps/api/src/dashboards/dashboard.controller.ts
apps/api/src/dashboards/dashboards.module.tsCheckpoint
- 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-layoutGate
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 errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Module not found | Not installed | Run install command again |
Types not found | Missing @types package | Install @types/react-grid-layout |
Rollback
cd apps/web
npm uninstall react-grid-layout @types/react-grid-layoutLock
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/components2. 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 useAPI_URLand add the/api/v1prefix, 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.tsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Module not found: GridLayout | Not created yet | Created in Step 134 |
Module not found: WidgetGallery | Not created yet | Created in Step 135 |
Rollback
rm -rf apps/web/features/dashboard/components/
rm apps/web/lib/queries/dashboards.tsLock
apps/web/features/dashboard/components/DashboardContainer.tsx
apps/web/features/dashboard/components/DashboardHeader.tsx
apps/web/lib/queries/dashboards.tsCheckpoint
- 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-boundary4. 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.tsxCommon Errors
| Error | Cause | Fix |
|---|---|---|
CSS not found | Missing styles | Import react-grid-layout CSS |
WidthProvider error | Wrong import | Use named import from react-grid-layout |
Rollback
rm apps/web/features/dashboard/components/GridLayout.tsx
rm apps/web/features/dashboard/components/WidgetRenderer.tsxLock
apps/web/features/dashboard/components/GridLayout.tsx
apps/web/features/dashboard/components/WidgetRenderer.tsxCheckpoint
- 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.tsxCommon Errors
| Error | Cause | Fix |
|---|---|---|
Dialog not found | Shadcn not installed | Run npx shadcn@latest add dialog |
Tabs not found | Shadcn not installed | Run npx shadcn@latest add tabs |
Rollback
rm apps/web/features/dashboard/components/WidgetGallery.tsxLock
apps/web/features/dashboard/components/WidgetGallery.tsxCheckpoint
- 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
| Error | Cause | Fix |
|---|---|---|
Widget not showing in gallery | Definitions not imported | Import './definitions' in index.ts |
TanStack Query error | Missing QueryClientProvider | Ensure 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.tsCheckpoint
- 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-debounce4. 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
| Error | Cause | Fix |
|---|---|---|
useDebouncedCallback not found | Missing package | Run npm install use-debounce |
localStorage not defined | SSR issue | Add 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.tsCheckpoint
- 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/dashboardsConstraints
- 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';Navigation
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 pageCommon Errors
| Error | Cause | Fix |
|---|---|---|
Module not found | Wrong import path | Check @/features path alias |
Page not found | Missing page file | Check file path and name |
use() only works in Client Components | Missing '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.tsxCheckpoint
- Dashboard list page created
- Dashboard detail page created
- Create dashboard dialog works
- Navigation to individual dashboards works
- Frontend compiles
Files Created
| Step | Files |
|---|---|
| 123 | schema.prisma (DashboardType enum) |
| 124 | schema.prisma (ShareType, SharePermission enums) |
| 125 | schema.prisma (Dashboard model) |
| 126 | schema.prisma (DashboardShare model) |
| 127 | schema.prisma (DashboardLayout model + all relations) |
| 129 | apps/web/features/dashboard/widgets/* |
| 130 | apps/api/src/dashboards/dashboard.service.ts, apps/api/src/dashboards/dto/* |
| 131 | apps/api/src/dashboards/dashboard.controller.ts, apps/api/src/dashboards/dashboards.module.ts |
| 133 | apps/web/lib/queries/dashboards.ts, apps/web/features/dashboard/components/DashboardContainer.tsx, apps/web/features/dashboard/components/DashboardHeader.tsx |
| 134 | apps/web/features/dashboard/components/GridLayout.tsx, apps/web/features/dashboard/components/WidgetRenderer.tsx |
| 135 | apps/web/features/dashboard/components/WidgetGallery.tsx |
| 136 | apps/web/features/dashboard/widgets/components/HeadcountWidget.tsx, apps/web/features/dashboard/widgets/definitions/index.ts |
| 137 | apps/web/features/dashboard/services/layoutPersistence.ts, apps/web/features/dashboard/hooks/useLayoutPersistence.ts |
| 138 | apps/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 modeLocked 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
Navigation
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 sharesCheckpoint
- 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 listCheckpoint
- 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 changesCheckpoint
- QuickStatsWidget component created
- Widget registered in definitions
- Shows 4 default metrics
- Data fetches from API
- Configurable metrics selection
- Type "GATE 141 PASSED" to continue
Related Documentation
- Dashboard System - Widget architecture spec
- Phase 07: Tags & Custom Fields - Previous phase
- Phase 09: AI Integration - Next phase
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 entry3. Update WHAT_EXISTS.md
## API Endpoints
- /api/v1/dashboard/stats
## Frontend Routes
- /dashboard (enhanced)
## Established Patterns
- Dashboard widget pattern4. Git Tag & Commit
git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 08 - Dashboard"
git tag phase-08-dashboardNext Phase
After verification, proceed to Phase 09: AI Integration
Last Updated: 2025-11-30