Bluewoo HRMS
AI Development GuideFeature Specifications

Dashboard System Implementation

Technical guide for implementing the flexible widget-based dashboard system

Dashboard System Implementation

This document provides the complete technical specification for implementing the flexible, widget-based dashboard system. The system supports system-defined dashboards, user-created custom dashboards, drag-and-drop layout customization, and dashboard sharing.

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    Dashboard Architecture                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                  DashboardContainer                  │    │
│  │              (Main Orchestrator Component)           │    │
│  └──────────────┬───────────────────┬──────────────────┘    │
│                 │                   │                        │
│     ┌───────────▼───────┐   ┌───────▼──────────┐            │
│     │   GridLayout      │   │  LayoutManager   │            │
│     │   (react-grid-    │   │  (Save/Load/     │            │
│     │    layout)        │   │   Export)        │            │
│     └───────────┬───────┘   └──────────────────┘            │
│                 │                                           │
│     ┌───────────▼──────────────────────────────────────┐    │
│     │              WidgetRenderer                       │    │
│     │   (Loads from registry, handles errors)           │    │
│     └───────────┬──────────────────────────────────────┘    │
│                 │                                           │
│     ┌───────────▼──────────────────────────────────────┐    │
│     │              Widget Registry                      │    │
│     │   (Centralized widget definitions)                │    │
│     └──────────────────────────────────────────────────┘    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Database Schema

Dashboard Models

Note: This schema aligns with the authoritative database-schema.mdx in the foundations section.

// Dashboard entity
model Dashboard {
  id          String   @id @default(cuid())
  tenantId    String
  userId      String
  name        String
  description String?
  isDefault   Boolean  @default(false)
  layout      Json     // Grid layout configuration
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  tenant  Tenant            @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  user    User              @relation(fields: [userId], references: [id])
  widgets DashboardWidget[]
  shares  DashboardShare[]

  @@index([tenantId])
  @@index([userId])
  @@map("dashboards")
}

// Dashboard widget instance
model DashboardWidget {
  id          String   @id @default(cuid())
  dashboardId String
  widgetType  String   // "headcount", "turnover", "timeoff_calendar", etc.
  title       String?  // Optional title override
  config      Json     // Widget-specific configuration
  position    Json     // { x, y, w, h } for grid layout

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

  @@index([dashboardId])
  @@map("dashboard_widgets")
}

// Dashboard sharing
model DashboardShare {
  id          String          @id @default(cuid())
  dashboardId String
  shareType   ShareType
  targetId    String?         // userId, teamId, departmentId
  permission  SharePermission @default(VIEW)

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

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

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
}

Config JSON Structures

// Dashboard.config structure
interface DashboardConfig {
  widgets: WidgetInstance[]
  layout: LayoutItem[]
  version: string // For migrations
}

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

// Layout item (react-grid-layout format)
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
}

Widget Registry

Widget Definition Interface

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

interface WidgetDefinition {
  // Identity
  id: string
  title: string
  description: string
  category: WidgetCategory
  icon: string // Lucide icon name
  
  // Component
  component: React.ComponentType<WidgetProps>
  
  // Size constraints (in grid units)
  defaultSize: { width: number; height: number }
  minSize: { width: number; height: number }
  maxSize?: { width: number; height: number }
  
  // Permissions
  permissions: {
    requiredRoles?: string[]        // e.g., ['ADMIN', 'HR_MANAGER']
    requiredPermissions?: string[]  // e.g., ['dashboard:read:company']
    tenantAdminOnly?: boolean
  }
  
  // Configuration
  configurable: boolean
  configSchema?: ConfigField[]      // Dynamic config form
  defaultConfig?: Record<string, any>
  
  // Behavior
  refreshInterval?: number          // Auto-refresh in seconds
  cacheable?: boolean               // Can cache data
  
  // System
  enabled: boolean
  systemDashboards?: string[]       // Which system dashboards to include in
}

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

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

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

Widget Registry Implementation

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

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())
  }
  
  getByCategory(category: WidgetCategory): WidgetDefinition[] {
    return this.getAll().filter(w => w.category === category)
  }
  
  getForUser(user: User): WidgetDefinition[] {
    return this.getAll().filter(w => {
      if (!w.enabled) return false
      
      // Check role requirements
      if (w.permissions.requiredRoles?.length) {
        const hasRole = w.permissions.requiredRoles.some(
          role => user.roles.includes(role)
        )
        if (!hasRole) return false
      }
      
      // Check permission requirements
      if (w.permissions.requiredPermissions?.length) {
        const hasPermission = w.permissions.requiredPermissions.every(
          perm => user.permissions.includes(perm)
        )
        if (!hasPermission) return false
      }
      
      // Check tenant admin requirement
      if (w.permissions.tenantAdminOnly && !user.isTenantAdmin) {
        return false
      }
      
      return true
    })
  }
  
  getForSystemDashboard(dashboardId: string): WidgetDefinition[] {
    return this.getAll().filter(
      w => w.systemDashboards?.includes(dashboardId)
    )
  }
}

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

Registering Widgets

// apps/web/src/features/dashboard/widgets/definitions/metrics.widgets.ts

import { widgetRegistry } from '../registry'
import { HeadcountWidget } from '../components/HeadcountWidget'
import { LeaveBalanceWidget } from '../components/LeaveBalanceWidget'
import { PendingApprovalsWidget } from '../components/PendingApprovalsWidget'

// Headcount Summary 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: 2, height: 2 },
  maxSize: { width: 6, height: 4 },
  permissions: {
    requiredRoles: ['ADMIN', 'HR_MANAGER'],
  },
  configurable: true,
  configSchema: [
    {
      key: 'showDepartmentBreakdown',
      label: 'Show department breakdown',
      type: 'checkbox',
      defaultValue: true,
    },
    {
      key: 'showTrend',
      label: 'Show trend indicator',
      type: 'checkbox',
      defaultValue: true,
    },
  ],
  refreshInterval: 300, // 5 minutes
  enabled: true,
  systemDashboards: ['company', 'hr'],
})

// Leave Balance Widget
widgetRegistry.register({
  id: 'leave-balance',
  title: 'My Leave Balance',
  description: 'Your current leave balances by type',
  category: 'metrics',
  icon: 'Calendar',
  component: LeaveBalanceWidget,
  defaultSize: { width: 3, height: 2 },
  minSize: { width: 2, height: 2 },
  permissions: {}, // Available to all authenticated users
  configurable: false,
  refreshInterval: 60,
  enabled: true,
  systemDashboards: ['employee', 'team'],
})

// Pending Approvals Widget
widgetRegistry.register({
  id: 'pending-approvals',
  title: 'Pending Approvals',
  description: 'Items waiting for your approval',
  category: 'metrics',
  icon: 'Bell',
  component: PendingApprovalsWidget,
  defaultSize: { width: 4, height: 3 },
  minSize: { width: 3, height: 2 },
  permissions: {
    requiredPermissions: ['approvals:read'],
  },
  configurable: true,
  configSchema: [
    {
      key: 'types',
      label: 'Approval types to show',
      type: 'select',
      options: [
        { value: 'all', label: 'All types' },
        { value: 'leave', label: 'Leave requests only' },
        { value: 'documents', label: 'Document access only' },
      ],
      defaultValue: 'all',
    },
  ],
  refreshInterval: 30,
  enabled: true,
  systemDashboards: ['team', 'hr'],
})

API Endpoints

Dashboard CRUD

// apps/api/src/modules/dashboard/dashboard.controller.ts

@Controller('dashboards')
@UseGuards(AuthGuard, TenantGuard)
export class DashboardController {
  
  // List user's accessible dashboards
  @Get()
  async list(@CurrentUser() user: User): Promise<Dashboard[]> {
    return this.dashboardService.listForUser(user)
  }
  
  // Get single dashboard
  @Get(':id')
  async get(
    @Param('id') id: string,
    @CurrentUser() user: User
  ): Promise<Dashboard> {
    return this.dashboardService.getWithAccessCheck(id, user)
  }
  
  // Create custom dashboard
  @Post()
  async create(
    @Body() dto: CreateDashboardDto,
    @CurrentUser() user: User
  ): Promise<Dashboard> {
    // Check dashboard limit
    await this.dashboardService.checkDashboardLimit(user)
    return this.dashboardService.create(dto, user)
  }
  
  // Update dashboard
  @Put(':id')
  async update(
    @Param('id') id: string,
    @Body() dto: UpdateDashboardDto,
    @CurrentUser() user: User
  ): Promise<Dashboard> {
    return this.dashboardService.updateWithAccessCheck(id, dto, user)
  }
  
  // Delete dashboard
  @Delete(':id')
  async delete(
    @Param('id') id: string,
    @CurrentUser() user: User
  ): Promise<void> {
    return this.dashboardService.deleteWithAccessCheck(id, user)
  }
  
  // Share dashboard
  @Post(':id/share')
  async share(
    @Param('id') id: string,
    @Body() dto: ShareDashboardDto,
    @CurrentUser() user: User
  ): Promise<DashboardShare> {
    return this.dashboardService.share(id, dto, user)
  }
  
  // Revoke share
  @Delete(':id/share/:shareId')
  async revokeShare(
    @Param('id') id: string,
    @Param('shareId') shareId: string,
    @CurrentUser() user: User
  ): Promise<void> {
    return this.dashboardService.revokeShare(id, shareId, user)
  }
}

// DTOs
class CreateDashboardDto {
  @IsString()
  name: string
  
  @IsOptional()
  @IsString()
  description?: string
  
  @IsOptional()
  @IsObject()
  config?: DashboardConfig
}

class UpdateDashboardDto {
  @IsOptional()
  @IsString()
  name?: string
  
  @IsOptional()
  @IsString()
  description?: string
  
  @IsOptional()
  @IsObject()
  config?: DashboardConfig
}

class ShareDashboardDto {
  @IsEnum(ShareType)
  shareType: ShareType
  
  @IsOptional()
  @IsUUID()
  targetId?: string
  
  @IsEnum(SharePermission)
  permission: SharePermission
}

Layout Endpoints

// apps/api/src/modules/dashboard/layout.controller.ts

@Controller('dashboards/:dashboardId/layouts')
@UseGuards(AuthGuard, TenantGuard)
export class LayoutController {
  
  // Get saved layouts
  @Get()
  async list(
    @Param('dashboardId') dashboardId: string,
    @CurrentUser() user: User
  ): Promise<DashboardLayout[]> {
    return this.layoutService.listForUser(dashboardId, user)
  }
  
  // Save layout
  @Post()
  async save(
    @Param('dashboardId') dashboardId: string,
    @Body() dto: SaveLayoutDto,
    @CurrentUser() user: User
  ): Promise<DashboardLayout> {
    return this.layoutService.save(dashboardId, dto, user)
  }
  
  // Delete layout
  @Delete(':layoutId')
  async delete(
    @Param('dashboardId') dashboardId: string,
    @Param('layoutId') layoutId: string,
    @CurrentUser() user: User
  ): Promise<void> {
    return this.layoutService.delete(dashboardId, layoutId, user)
  }
  
  // Set default layout
  @Post(':layoutId/default')
  async setDefault(
    @Param('dashboardId') dashboardId: string,
    @Param('layoutId') layoutId: string,
    @CurrentUser() user: User
  ): Promise<void> {
    return this.layoutService.setDefault(dashboardId, layoutId, user)
  }
}

class SaveLayoutDto {
  @IsString()
  name: string
  
  @IsOptional()
  @IsString()
  description?: string
  
  @IsArray()
  layout: LayoutItem[]
  
  @IsOptional()
  @IsBoolean()
  isDefault?: boolean
}

Widget Endpoints

// apps/api/src/modules/dashboard/widget.controller.ts

@Controller('widgets')
@UseGuards(AuthGuard, TenantGuard)
export class WidgetController {
  
  // Get available widgets for current user
  @Get()
  async list(@CurrentUser() user: User): Promise<WidgetDefinition[]> {
    return this.widgetService.getForUser(user)
  }
  
  // Get widget categories
  @Get('categories')
  async categories(): Promise<WidgetCategory[]> {
    return ['metrics', 'charts', 'lists', 'ai', 'quick-actions']
  }
}

Frontend Components

DashboardContainer

// apps/web/src/features/dashboard/components/DashboardContainer.tsx

interface DashboardContainerProps {
  dashboardId: string
}

export function DashboardContainer({ dashboardId }: DashboardContainerProps) {
  const { data: dashboard, isLoading } = useDashboard(dashboardId)
  const { mutate: updateDashboard } = useUpdateDashboard()
  const [isEditing, setIsEditing] = useState(false)
  const [layout, setLayout] = useState<LayoutItem[]>([])
  
  // Debounced save
  const debouncedSave = useDebouncedCallback((newLayout: LayoutItem[]) => {
    updateDashboard({
      id: dashboardId,
      config: { ...dashboard.config, layout: newLayout }
    })
  }, 500)
  
  const handleLayoutChange = (newLayout: LayoutItem[]) => {
    setLayout(newLayout)
    debouncedSave(newLayout)
  }
  
  const handleAddWidget = (widgetId: string) => {
    const widget = widgetRegistry.get(widgetId)
    if (!widget) return
    
    const instance: WidgetInstance = {
      id: `${widgetId}-${Date.now()}`,
      widgetId,
    }
    
    const layoutItem: LayoutItem = {
      i: instance.id,
      x: 0,
      y: Infinity, // Add at bottom
      w: widget.defaultSize.width,
      h: widget.defaultSize.height,
      minW: widget.minSize.width,
      minH: widget.minSize.height,
      maxW: widget.maxSize?.width,
      maxH: widget.maxSize?.height,
    }
    
    // Update dashboard config
    updateDashboard({
      id: dashboardId,
      config: {
        widgets: [...dashboard.config.widgets, instance],
        layout: [...layout, layoutItem],
      }
    })
  }
  
  if (isLoading) return <DashboardSkeleton />
  
  return (
    <div className="dashboard-container">
      <DashboardHeader
        dashboard={dashboard}
        isEditing={isEditing}
        onEditToggle={() => setIsEditing(!isEditing)}
        onAddWidget={handleAddWidget}
      />
      
      <GridLayout
        layout={layout}
        widgets={dashboard.config.widgets}
        isEditing={isEditing}
        onLayoutChange={handleLayoutChange}
      />
    </div>
  )
}

GridLayout (react-grid-layout wrapper)

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

import { Responsive, WidthProvider } from 'react-grid-layout'

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
}

export function GridLayout({
  layout,
  widgets,
  isEditing,
  onLayoutChange,
}: GridLayoutProps) {
  
  const handleLayoutChange = (
    currentLayout: ReactGridLayout.Layout[],
    allLayouts: ReactGridLayout.Layouts
  ) => {
    // Convert react-grid-layout format 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)
  }
  
  return (
    <ResponsiveGridLayout
      layouts={{ lg: layout }}
      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']}
    >
      {widgets.map(widget => (
        <div key={widget.id} className="widget-wrapper">
          <WidgetRenderer
            instance={widget}
            isEditing={isEditing}
          />
        </div>
      ))}
    </ResponsiveGridLayout>
  )
}

WidgetRenderer

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

interface WidgetRendererProps {
  instance: WidgetInstance
  isEditing: boolean
}

export function WidgetRenderer({ instance, isEditing }: WidgetRendererProps) {
  const definition = widgetRegistry.get(instance.widgetId)
  
  if (!definition) {
    return <WidgetError message={`Widget "${instance.widgetId}" not found`} />
  }
  
  const WidgetComponent = definition.component
  
  return (
    <ErrorBoundary fallback={<WidgetError />}>
      <div className="widget">
        <WidgetHeader
          title={instance.title || definition.title}
          icon={definition.icon}
          isEditing={isEditing}
          configurable={definition.configurable}
        />
        <div className="widget-content">
          <Suspense fallback={<WidgetSkeleton />}>
            <WidgetComponent
              instance={instance}
              definition={definition}
              isEditing={isEditing}
            />
          </Suspense>
        </div>
      </div>
    </ErrorBoundary>
  )
}

WidgetGallery

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

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

export function WidgetGallery({ open, onClose, onSelect }: WidgetGalleryProps) {
  const { user } = useAuth()
  const [search, setSearch] = useState('')
  const [category, setCategory] = useState<WidgetCategory | 'all'>('all')
  
  const availableWidgets = useMemo(() => {
    return widgetRegistry.getForUser(user)
  }, [user])
  
  const filteredWidgets = useMemo(() => {
    return availableWidgets.filter(w => {
      const matchesSearch = w.title.toLowerCase().includes(search.toLowerCase())
      const matchesCategory = category === 'all' || w.category === category
      return matchesSearch && matchesCategory
    })
  }, [availableWidgets, search, category])
  
  const categories = ['all', 'metrics', 'charts', 'lists', 'ai', 'quick-actions']
  
  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent className="max-w-3xl">
        <DialogHeader>
          <DialogTitle>Add Widget</DialogTitle>
        </DialogHeader>
        
        <div className="space-y-4">
          <Input
            placeholder="Search widgets..."
            value={search}
            onChange={(e) => setSearch(e.target.value)}
          />
          
          <Tabs value={category} onValueChange={setCategory}>
            <TabsList>
              {categories.map(cat => (
                <TabsTrigger key={cat} value={cat}>
                  {cat === 'all' ? 'All' : cat.charAt(0).toUpperCase() + cat.slice(1)}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
          
          <div className="grid grid-cols-3 gap-4">
            {filteredWidgets.map(widget => (
              <WidgetCard
                key={widget.id}
                widget={widget}
                onClick={() => {
                  onSelect(widget.id)
                  onClose()
                }}
              />
            ))}
          </div>
        </div>
      </DialogContent>
    </Dialog>
  )
}

Layout Persistence Service

// apps/web/src/features/dashboard/services/layoutPersistence.ts

const STORAGE_PREFIX = 'dashboard-layout'

class LayoutPersistenceService {
  private apiClient: ApiClient
  
  constructor(apiClient: ApiClient) {
    this.apiClient = apiClient
  }
  
  // Get storage key for local storage
  private getStorageKey(dashboardId: string, userId: string): string {
    return `${STORAGE_PREFIX}-${dashboardId}-${userId}`
  }
  
  // Save layout to local storage (immediate) and API (debounced)
  async saveLayout(
    dashboardId: string,
    userId: string,
    layout: LayoutItem[]
  ): Promise<void> {
    const key = this.getStorageKey(dashboardId, userId)
    
    // Save to local storage immediately
    localStorage.setItem(key, JSON.stringify({
      layout,
      timestamp: Date.now(),
    }))
    
    // Sync to API
    await this.apiClient.put(`/dashboards/${dashboardId}`, {
      config: { layout }
    })
  }
  
  // Load layout - try local first, fallback to API
  async loadLayout(
    dashboardId: string,
    userId: string
  ): Promise<LayoutItem[] | null> {
    const key = this.getStorageKey(dashboardId, userId)
    
    // Try local storage first
    const local = localStorage.getItem(key)
    if (local) {
      const { layout, timestamp } = JSON.parse(local)
      // Use local if less than 5 minutes old
      if (Date.now() - timestamp < 5 * 60 * 1000) {
        return layout
      }
    }
    
    // Fetch from API
    const dashboard = await this.apiClient.get(`/dashboards/${dashboardId}`)
    return dashboard.config?.layout || null
  }
  
  // Save named layout
  async saveNamedLayout(
    dashboardId: string,
    name: string,
    layout: LayoutItem[],
    isDefault: boolean = false
  ): Promise<DashboardLayout> {
    return this.apiClient.post(`/dashboards/${dashboardId}/layouts`, {
      name,
      layout,
      isDefault,
    })
  }
  
  // Load all saved layouts
  async loadSavedLayouts(dashboardId: string): Promise<DashboardLayout[]> {
    return this.apiClient.get(`/dashboards/${dashboardId}/layouts`)
  }
  
  // Export layout as JSON
  exportLayout(layout: LayoutItem[], widgets: WidgetInstance[]): string {
    return JSON.stringify({
      version: '1.0',
      exportedAt: new Date().toISOString(),
      layout,
      widgets,
    }, null, 2)
  }
  
  // Import layout from JSON
  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,
    }
  }
}

export const layoutPersistence = new LayoutPersistenceService(apiClient)

System Dashboards Seeding

// apps/api/prisma/seeds/dashboards.seed.ts

const systemDashboards = [
  {
    name: 'Company Dashboard',
    type: 'SYSTEM',
    config: {
      widgets: [
        { id: 'headcount-1', widgetId: 'headcount-summary' },
        { id: 'dept-dist-1', widgetId: 'department-distribution' },
        { id: 'activity-1', widgetId: 'recent-activity' },
        { id: 'insights-1', widgetId: 'ai-insights' },
      ],
      layout: [
        { i: 'headcount-1', x: 0, y: 0, w: 4, h: 3 },
        { i: 'dept-dist-1', x: 4, y: 0, w: 4, h: 3 },
        { i: 'activity-1', x: 8, y: 0, w: 4, h: 4 },
        { i: 'insights-1', x: 0, y: 3, w: 8, h: 4 },
      ],
    },
    targetRoles: ['TENANT_ADMIN'],
  },
  {
    name: 'Team Dashboard',
    type: 'SYSTEM',
    config: {
      widgets: [
        { id: 'profile-1', widgetId: 'my-profile' },
        { id: 'team-1', widgetId: 'my-team' },
        { id: 'approvals-1', widgetId: 'pending-approvals' },
        { id: 'calendar-1', widgetId: 'team-calendar' },
      ],
      layout: [
        { i: 'profile-1', x: 0, y: 0, w: 4, h: 3 },
        { i: 'team-1', x: 4, y: 0, w: 4, h: 3 },
        { i: 'approvals-1', x: 8, y: 0, w: 4, h: 3 },
        { i: 'calendar-1', x: 0, y: 3, w: 12, h: 4 },
      ],
    },
    targetRoles: ['MANAGER'],
  },
  {
    name: 'Employee Dashboard',
    type: 'SYSTEM',
    config: {
      widgets: [
        { id: 'profile-1', widgetId: 'my-profile' },
        { id: 'balance-1', widgetId: 'leave-balance' },
        { id: 'tasks-1', widgetId: 'my-tasks' },
        { id: 'actions-1', widgetId: 'quick-actions' },
      ],
      layout: [
        { i: 'profile-1', x: 0, y: 0, w: 6, h: 3 },
        { i: 'balance-1', x: 6, y: 0, w: 3, h: 2 },
        { i: 'actions-1', x: 9, y: 0, w: 3, h: 2 },
        { i: 'tasks-1', x: 0, y: 3, w: 12, h: 3 },
      ],
    },
    targetRoles: ['EMPLOYEE'],
  },
]

AI Chat Integration

Dashboard actions can be triggered via AI Chat. Register tools in the AI service:

// Dashboard-related AI tools
const dashboardTools = [
  {
    name: 'dashboard_metrics',
    description: 'Get dashboard metrics and company/team data',
    parameters: {
      scope: { type: 'string', enum: ['company', 'team', 'personal'] },
      metrics: { type: 'array', items: { type: 'string' } },
    },
    handler: async (params, context) => {
      return dashboardService.getMetrics(params.scope, params.metrics, context.user)
    },
  },
  {
    name: 'dashboard_insights',
    description: 'Get AI-generated insights for the user',
    parameters: {
      scope: { type: 'string', enum: ['company', 'team'] },
    },
    handler: async (params, context) => {
      return insightsService.getInsights(params.scope, context.user)
    },
  },
  {
    name: 'dashboard_create',
    description: 'Create a new custom dashboard',
    parameters: {
      name: { type: 'string', required: true },
      description: { type: 'string' },
    },
    requiresConfirmation: true,
    handler: async (params, context) => {
      return dashboardService.create(params, context.user)
    },
  },
]

Performance Considerations

  1. Debounced Auto-Save: 500ms delay prevents excessive API calls
  2. Local Storage Cache: Immediate feedback while syncing to server
  3. Lazy Widget Loading: Widgets loaded on-demand with Suspense
  4. Error Boundaries: Widget failures don't crash entire dashboard
  5. CSS Transforms: Hardware-accelerated drag animations
  6. Memoization: Layout calculations cached to prevent re-renders

Security Considerations

  1. Permission Filtering: Widgets filtered by user roles/permissions
  2. Dashboard Ownership: Only owner or admin can edit
  3. Share Validation: Users can only share within their scope
  4. Tenant Isolation: All queries filtered by tenantId
  5. Audit Logging: Dashboard changes tracked in audit log