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.mdxin 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
- Debounced Auto-Save: 500ms delay prevents excessive API calls
- Local Storage Cache: Immediate feedback while syncing to server
- Lazy Widget Loading: Widgets loaded on-demand with Suspense
- Error Boundaries: Widget failures don't crash entire dashboard
- CSS Transforms: Hardware-accelerated drag animations
- Memoization: Layout calculations cached to prevent re-renders
Security Considerations
- Permission Filtering: Widgets filtered by user roles/permissions
- Dashboard Ownership: Only owner or admin can edit
- Share Validation: Users can only share within their scope
- Tenant Isolation: All queries filtered by tenantId
- Audit Logging: Dashboard changes tracked in audit log