workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-006-reportes-analytics/especificaciones/ET-BI-002-implementacion-dashboards-interactivos.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

54 KiB

ET-BI-002: Implementación de Dashboards Interactivos

Épica: MAI-006 - Reportes y Business Intelligence Módulo: Dashboards Interactivos y Personalización Responsable Técnico: Frontend + Backend + UX Fecha: 2025-11-17 Versión: 1.0


1. Objetivo Técnico

Implementar el sistema de dashboards interactivos con:

  • Dashboards personalizables con drag & drop
  • Widgets configurables (KPIs, gráficas, tablas)
  • Filtros dinámicos y drill-down
  • Vistas guardadas por usuario
  • Actualización en tiempo real vía WebSocket
  • Exportación de dashboards a PDF/PNG
  • Segmentación de datos por dimensiones
  • Layout responsivo con react-grid-layout

2. Stack Tecnológico

Backend

- NestJS 10+
- TypeORM con PostgreSQL 15+
- WebSocket (Socket.io) para real-time
- Bull/BullMQ para generación async de PDFs
- Puppeteer para exportación a PDF/PNG
- Redis para cache de queries

Frontend

- React 18 con TypeScript
- Zustand para state management
- react-grid-layout para drag & drop
- Chart.js / Recharts para gráficas
- react-query para data fetching y cache
- Socket.io-client para WebSocket
- html2canvas para screenshots
- jsPDF para generación de PDFs
- TailwindCSS para estilos

Real-time

- Socket.io (WebSocket) para actualizaciones
- Redis Pub/Sub para broadcasting
- Server-Sent Events (SSE) como fallback

3. Modelo de Datos SQL

3.1 Schema Principal

-- =====================================================
-- SCHEMA: dashboards (extensión de analytics_reports)
-- Descripción: Dashboards interactivos y widgets
-- =====================================================

CREATE SCHEMA IF NOT EXISTS dashboards;

-- =====================================================
-- TABLE: dashboards.user_dashboards
-- Descripción: Dashboards personalizados por usuario
-- =====================================================

CREATE TABLE dashboards.user_dashboards (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Propietario
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE,

  -- Identificación
  name VARCHAR(255) NOT NULL,
  description TEXT,
  icon VARCHAR(50), -- lucide icon name

  -- Layout
  layout_config JSONB NOT NULL DEFAULT '{}',
  /*
  {
    "breakpoints": {"lg": 1200, "md": 996, "sm": 768, "xs": 480, "xxs": 0},
    "cols": {"lg": 12, "md": 10, "sm": 6, "xs": 4, "xxs": 2},
    "rowHeight": 100,
    "compactType": "vertical"
  }
  */

  -- Widgets
  widgets JSONB NOT NULL DEFAULT '[]',
  /*
  [
    {
      "id": "widget-1",
      "type": "kpi_card",
      "title": "Margen Bruto",
      "dataSource": "project_metrics",
      "config": {
        "metric": "gross_margin_pct",
        "aggregation": "avg",
        "threshold": {"warning": 15, "critical": 10}
      },
      "layout": {
        "lg": {"x": 0, "y": 0, "w": 3, "h": 2},
        "md": {"x": 0, "y": 0, "w": 5, "h": 2}
      }
    }
  ]
  */

  -- Filtros globales
  global_filters JSONB DEFAULT '{}',
  /*
  {
    "dateRange": {"start": "2025-01-01", "end": "2025-12-31"},
    "projectIds": ["uuid-1", "uuid-2"],
    "regionId": "uuid-region"
  }
  */

  -- Configuración de actualización
  auto_refresh BOOLEAN DEFAULT false,
  refresh_interval INTEGER DEFAULT 300000, -- ms (5 minutos)

  -- Compartir
  is_public BOOLEAN DEFAULT false,
  shared_with_users UUID[],
  shared_with_roles TEXT[],

  -- Metadata
  is_favorite BOOLEAN DEFAULT false,
  is_default BOOLEAN DEFAULT false,
  view_count INTEGER DEFAULT 0,
  last_viewed_at TIMESTAMP,

  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_user_dashboards_user ON dashboards.user_dashboards(user_id);
CREATE INDEX idx_user_dashboards_constructora ON dashboards.user_dashboards(constructora_id);
CREATE INDEX idx_user_dashboards_favorite ON dashboards.user_dashboards(is_favorite) WHERE is_favorite = true;


-- =====================================================
-- TABLE: dashboards.widgets
-- Descripción: Catálogo de widgets disponibles
-- =====================================================

CREATE TABLE dashboards.widgets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE,

  -- Identificación
  widget_code VARCHAR(50) NOT NULL,
  widget_name VARCHAR(255) NOT NULL,
  description TEXT,
  category VARCHAR(50) NOT NULL, -- kpi, chart, table, map, custom

  -- Tipo de widget
  widget_type VARCHAR(30) NOT NULL,
  -- kpi_card, line_chart, bar_chart, pie_chart, donut_chart,
  -- area_chart, scatter_chart, table, pivot_table, gauge, map

  -- Configuración por defecto
  default_config JSONB NOT NULL DEFAULT '{}',
  /*
  {
    "dataSource": "project_metrics",
    "metrics": ["gross_margin_pct"],
    "dimensions": ["project_name"],
    "aggregations": ["avg"],
    "filters": [],
    "chartOptions": {
      "showLegend": true,
      "showGrid": true,
      "stacked": false
    }
  }
  */

  -- Data source
  data_source_type VARCHAR(30) NOT NULL,
  -- sql_query, materialized_view, api_endpoint, aggregation

  data_source_config JSONB,
  /*
  {
    "query": "SELECT...",
    "view": "mv_project_health_indicators",
    "endpoint": "/api/metrics/custom",
    "refreshInterval": 60000
  }
  */

  -- Dimensiones soportadas
  supported_dimensions TEXT[], -- ['project_id', 'region_id', 'date']
  supported_metrics TEXT[], -- ['revenue', 'cost', 'margin_pct']

  -- Permisos
  required_roles TEXT[], -- ['admin', 'director']
  is_premium BOOLEAN DEFAULT false,

  -- Estado
  is_active BOOLEAN DEFAULT true,
  is_system BOOLEAN DEFAULT false, -- widgets del sistema no editables

  -- Metadata
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT valid_category CHECK (category IN ('kpi', 'chart', 'table', 'map', 'custom')),
  CONSTRAINT valid_data_source_type CHECK (data_source_type IN (
    'sql_query', 'materialized_view', 'api_endpoint', 'aggregation'
  )),
  UNIQUE(constructora_id, widget_code)
);

CREATE INDEX idx_widgets_constructora ON dashboards.widgets(constructora_id);
CREATE INDEX idx_widgets_category ON dashboards.widgets(category);
CREATE INDEX idx_widgets_type ON dashboards.widgets(widget_type);


-- =====================================================
-- TABLE: dashboards.saved_views
-- Descripción: Vistas guardadas de dashboards
-- =====================================================

CREATE TABLE dashboards.saved_views (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Dashboard
  dashboard_id UUID NOT NULL REFERENCES dashboards.user_dashboards(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,

  -- Identificación
  view_name VARCHAR(255) NOT NULL,
  description TEXT,

  -- Configuración de la vista
  filters JSONB NOT NULL DEFAULT '{}',
  /*
  {
    "dateRange": {"start": "2025-Q1", "end": "2025-Q2"},
    "projectIds": ["uuid-1"],
    "status": ["active"]
  }
  */

  sort_config JSONB DEFAULT '{}',
  /*
  {
    "field": "gross_margin_pct",
    "direction": "desc"
  }
  */

  visible_widgets UUID[], -- IDs de widgets visibles en esta vista

  -- Metadata
  is_default BOOLEAN DEFAULT false,
  is_shared BOOLEAN DEFAULT false,
  usage_count INTEGER DEFAULT 0,

  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  last_used_at TIMESTAMP
);

CREATE INDEX idx_saved_views_dashboard ON dashboards.saved_views(dashboard_id);
CREATE INDEX idx_saved_views_user ON dashboards.saved_views(user_id);


-- =====================================================
-- TABLE: dashboards.widget_data_cache
-- Descripción: Cache de datos de widgets
-- =====================================================

CREATE TABLE dashboards.widget_data_cache (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Widget
  widget_id UUID NOT NULL,
  dashboard_id UUID NOT NULL REFERENCES dashboards.user_dashboards(id) ON DELETE CASCADE,

  -- Cache key (hash de filtros + config)
  cache_key VARCHAR(255) NOT NULL,

  -- Datos
  data JSONB NOT NULL,

  -- TTL
  expires_at TIMESTAMP NOT NULL,

  -- Metadata
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  UNIQUE(widget_id, cache_key)
);

CREATE INDEX idx_widget_cache_widget ON dashboards.widget_data_cache(widget_id);
CREATE INDEX idx_widget_cache_expires ON dashboards.widget_data_cache(expires_at);

-- Auto-cleanup de cache expirado
CREATE INDEX idx_widget_cache_cleanup ON dashboards.widget_data_cache(expires_at)
  WHERE expires_at < CURRENT_TIMESTAMP;


-- =====================================================
-- TABLE: dashboards.user_preferences
-- Descripción: Preferencias de usuario para dashboards
-- =====================================================

CREATE TABLE dashboards.user_preferences (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,

  -- Preferencias generales
  default_dashboard_id UUID REFERENCES dashboards.user_dashboards(id),
  theme VARCHAR(20) DEFAULT 'light', -- light, dark, auto

  -- Preferencias de visualización
  default_date_range VARCHAR(20) DEFAULT 'last_30_days',
  -- today, yesterday, last_7_days, last_30_days, this_month, last_month, custom

  default_chart_type VARCHAR(30) DEFAULT 'line_chart',
  show_grid BOOLEAN DEFAULT true,
  show_tooltips BOOLEAN DEFAULT true,
  animation_enabled BOOLEAN DEFAULT true,

  -- Notificaciones
  enable_notifications BOOLEAN DEFAULT true,
  notification_frequency VARCHAR(20) DEFAULT 'realtime',
  -- realtime, hourly, daily

  -- Otras preferencias
  preferences JSONB DEFAULT '{}',

  -- Metadata
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT valid_theme CHECK (theme IN ('light', 'dark', 'auto')),
  UNIQUE(user_id)
);

CREATE INDEX idx_user_prefs_user ON dashboards.user_preferences(user_id);


-- =====================================================
-- TABLE: dashboards.drill_down_paths
-- Descripción: Rutas de drill-down configuradas
-- =====================================================

CREATE TABLE dashboards.drill_down_paths (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  widget_id UUID NOT NULL REFERENCES dashboards.widgets(id) ON DELETE CASCADE,

  -- Ruta de drill-down
  from_dimension VARCHAR(100) NOT NULL, -- 'project'
  to_dimension VARCHAR(100) NOT NULL, -- 'workfront'
  order_sequence INTEGER NOT NULL,

  -- Configuración
  target_widget_type VARCHAR(30), -- widget que se abre al hacer drill-down
  filter_mapping JSONB,
  /*
  {
    "sourceField": "project_id",
    "targetField": "project_id",
    "operator": "="
  }
  */

  -- Metadata
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  UNIQUE(widget_id, from_dimension, to_dimension)
);

CREATE INDEX idx_drill_down_widget ON dashboards.drill_down_paths(widget_id);


-- =====================================================
-- TABLE: dashboards.dashboard_segments
-- Descripción: Segmentación de datos
-- =====================================================

CREATE TABLE dashboards.dashboard_segments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  constructora_id UUID NOT NULL REFERENCES public.constructoras(id) ON DELETE CASCADE,

  -- Identificación
  segment_code VARCHAR(50) NOT NULL,
  segment_name VARCHAR(255) NOT NULL,
  description TEXT,

  -- Tipo de segmento
  segment_type VARCHAR(30) NOT NULL,
  -- project_status, region, date_range, budget_range, risk_level, custom

  -- Condiciones
  conditions JSONB NOT NULL,
  /*
  {
    "operator": "AND",
    "rules": [
      {"field": "status", "operator": "IN", "value": ["active", "on_hold"]},
      {"field": "total_budget", "operator": ">=", "value": 5000000}
    ]
  }
  */

  -- Uso
  is_system BOOLEAN DEFAULT false,
  is_active BOOLEAN DEFAULT true,

  -- Metadata
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  UNIQUE(constructora_id, segment_code)
);

CREATE INDEX idx_segments_constructora ON dashboards.dashboard_segments(constructora_id);
CREATE INDEX idx_segments_type ON dashboards.dashboard_segments(segment_type);

3.2 Triggers

-- =====================================================
-- TRIGGER: Actualizar updated_at
-- =====================================================

CREATE OR REPLACE FUNCTION dashboards.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = CURRENT_TIMESTAMP;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_user_dashboards_updated_at
  BEFORE UPDATE ON dashboards.user_dashboards
  FOR EACH ROW
  EXECUTE FUNCTION dashboards.update_timestamp();

CREATE TRIGGER trg_widgets_updated_at
  BEFORE UPDATE ON dashboards.widgets
  FOR EACH ROW
  EXECUTE FUNCTION dashboards.update_timestamp();


-- =====================================================
-- TRIGGER: Incrementar view_count
-- =====================================================

CREATE OR REPLACE FUNCTION dashboards.increment_view_count()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE dashboards.user_dashboards
  SET
    view_count = view_count + 1,
    last_viewed_at = CURRENT_TIMESTAMP
  WHERE id = NEW.dashboard_id;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Se dispara cuando se consulta una vista guardada
CREATE TRIGGER trg_increment_view_count
  AFTER INSERT OR UPDATE ON dashboards.saved_views
  FOR EACH ROW
  EXECUTE FUNCTION dashboards.increment_view_count();


-- =====================================================
-- FUNCTION: Limpiar cache expirado
-- =====================================================

CREATE OR REPLACE FUNCTION dashboards.cleanup_expired_cache()
RETURNS INTEGER AS $$
DECLARE
  v_deleted INTEGER;
BEGIN
  DELETE FROM dashboards.widget_data_cache
  WHERE expires_at < CURRENT_TIMESTAMP;

  GET DIAGNOSTICS v_deleted = ROW_COUNT;

  RETURN v_deleted;
END;
$$ LANGUAGE plpgsql;

-- CRON job para ejecutar cada hora (configurar con pg_cron)
-- SELECT cron.schedule('cleanup-widget-cache', '0 * * * *',
--   'SELECT dashboards.cleanup_expired_cache()');

4. TypeORM Entities

4.1 UserDashboard Entity

// src/modules/dashboards/entities/user-dashboard.entity.ts

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  OneToMany,
  JoinColumn,
  CreateDateColumn,
  UpdateDateColumn,
  Index,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
import { Constructora } from '../../constructoras/entities/constructora.entity';
import { SavedView } from './saved-view.entity';

export interface LayoutConfig {
  breakpoints: { [key: string]: number };
  cols: { [key: string]: number };
  rowHeight: number;
  compactType: 'vertical' | 'horizontal';
}

export interface WidgetDefinition {
  id: string;
  type: string;
  title: string;
  dataSource: string;
  config: any;
  layout: {
    [breakpoint: string]: {
      x: number;
      y: number;
      w: number;
      h: number;
      minW?: number;
      minH?: number;
      maxW?: number;
      maxH?: number;
    };
  };
}

export interface GlobalFilters {
  dateRange?: {
    start: string;
    end: string;
  };
  projectIds?: string[];
  regionId?: string;
  [key: string]: any;
}

@Entity('user_dashboards', { schema: 'dashboards' })
export class UserDashboard {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'user_id', type: 'uuid' })
  @Index()
  userId: string;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;

  @Column({ name: 'constructora_id', type: 'uuid' })
  @Index()
  constructoraId: string;

  @ManyToOne(() => Constructora)
  @JoinColumn({ name: 'constructora_id' })
  constructora: Constructora;

  // Identificación
  @Column({ type: 'varchar', length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description?: string;

  @Column({ type: 'varchar', length: 50, nullable: true })
  icon?: string;

  // Layout
  @Column({ name: 'layout_config', type: 'jsonb', default: {} })
  layoutConfig: LayoutConfig;

  @Column({ type: 'jsonb', default: [] })
  widgets: WidgetDefinition[];

  // Filtros globales
  @Column({ name: 'global_filters', type: 'jsonb', default: {} })
  globalFilters: GlobalFilters;

  // Configuración de actualización
  @Column({ name: 'auto_refresh', type: 'boolean', default: false })
  autoRefresh: boolean;

  @Column({ name: 'refresh_interval', type: 'integer', default: 300000 })
  refreshInterval: number;

  // Compartir
  @Column({ name: 'is_public', type: 'boolean', default: false })
  isPublic: boolean;

  @Column({ name: 'shared_with_users', type: 'uuid', array: true, nullable: true })
  sharedWithUsers?: string[];

  @Column({ name: 'shared_with_roles', type: 'text', array: true, nullable: true })
  sharedWithRoles?: string[];

  // Metadata
  @Column({ name: 'is_favorite', type: 'boolean', default: false })
  @Index()
  isFavorite: boolean;

  @Column({ name: 'is_default', type: 'boolean', default: false })
  isDefault: boolean;

  @Column({ name: 'view_count', type: 'integer', default: 0 })
  viewCount: number;

  @Column({ name: 'last_viewed_at', type: 'timestamp', nullable: true })
  lastViewedAt?: Date;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  // Relaciones
  @OneToMany(() => SavedView, (view) => view.dashboard)
  savedViews: SavedView[];
}

4.2 Widget Entity

// src/modules/dashboards/entities/widget.entity.ts

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  OneToMany,
  JoinColumn,
  CreateDateColumn,
  UpdateDateColumn,
  Index,
} from 'typeorm';
import { Constructora } from '../../constructoras/entities/constructora.entity';
import { User } from '../../auth/entities/user.entity';
import { DrillDownPath } from './drill-down-path.entity';

export enum WidgetCategory {
  KPI = 'kpi',
  CHART = 'chart',
  TABLE = 'table',
  MAP = 'map',
  CUSTOM = 'custom',
}

export enum WidgetType {
  KPI_CARD = 'kpi_card',
  LINE_CHART = 'line_chart',
  BAR_CHART = 'bar_chart',
  PIE_CHART = 'pie_chart',
  DONUT_CHART = 'donut_chart',
  AREA_CHART = 'area_chart',
  SCATTER_CHART = 'scatter_chart',
  TABLE = 'table',
  PIVOT_TABLE = 'pivot_table',
  GAUGE = 'gauge',
  MAP = 'map',
}

export enum DataSourceType {
  SQL_QUERY = 'sql_query',
  MATERIALIZED_VIEW = 'materialized_view',
  API_ENDPOINT = 'api_endpoint',
  AGGREGATION = 'aggregation',
}

@Entity('widgets', { schema: 'dashboards' })
@Index(['constructoraId', 'widgetCode'], { unique: true })
export class Widget {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'constructora_id', type: 'uuid' })
  @Index()
  constructoraId: string;

  @ManyToOne(() => Constructora)
  @JoinColumn({ name: 'constructora_id' })
  constructora: Constructora;

  // Identificación
  @Column({ name: 'widget_code', type: 'varchar', length: 50 })
  widgetCode: string;

  @Column({ name: 'widget_name', type: 'varchar', length: 255 })
  widgetName: string;

  @Column({ type: 'text', nullable: true })
  description?: string;

  @Column({ type: 'enum', enum: WidgetCategory })
  @Index()
  category: WidgetCategory;

  @Column({ name: 'widget_type', type: 'enum', enum: WidgetType })
  @Index()
  widgetType: WidgetType;

  // Configuración
  @Column({ name: 'default_config', type: 'jsonb', default: {} })
  defaultConfig: any;

  // Data source
  @Column({ name: 'data_source_type', type: 'enum', enum: DataSourceType })
  dataSourceType: DataSourceType;

  @Column({ name: 'data_source_config', type: 'jsonb', nullable: true })
  dataSourceConfig?: any;

  // Dimensiones y métricas
  @Column({ name: 'supported_dimensions', type: 'text', array: true, nullable: true })
  supportedDimensions?: string[];

  @Column({ name: 'supported_metrics', type: 'text', array: true, nullable: true })
  supportedMetrics?: string[];

  // Permisos
  @Column({ name: 'required_roles', type: 'text', array: true, nullable: true })
  requiredRoles?: string[];

  @Column({ name: 'is_premium', type: 'boolean', default: false })
  isPremium: boolean;

  // Estado
  @Column({ name: 'is_active', type: 'boolean', default: true })
  isActive: boolean;

  @Column({ name: 'is_system', type: 'boolean', default: false })
  isSystem: boolean;

  // Metadata
  @Column({ name: 'created_by', type: 'uuid', nullable: true })
  createdBy?: string;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'created_by' })
  creator?: User;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  // Relaciones
  @OneToMany(() => DrillDownPath, (path) => path.widget)
  drillDownPaths: DrillDownPath[];
}

4.3 SavedView Entity

// src/modules/dashboards/entities/saved-view.entity.ts

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  JoinColumn,
  CreateDateColumn,
  Index,
} from 'typeorm';
import { UserDashboard } from './user-dashboard.entity';
import { User } from '../../auth/entities/user.entity';

@Entity('saved_views', { schema: 'dashboards' })
export class SavedView {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'dashboard_id', type: 'uuid' })
  @Index()
  dashboardId: string;

  @ManyToOne(() => UserDashboard, (dashboard) => dashboard.savedViews, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'dashboard_id' })
  dashboard: UserDashboard;

  @Column({ name: 'user_id', type: 'uuid' })
  @Index()
  userId: string;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;

  // Identificación
  @Column({ name: 'view_name', type: 'varchar', length: 255 })
  viewName: string;

  @Column({ type: 'text', nullable: true })
  description?: string;

  // Configuración
  @Column({ type: 'jsonb', default: {} })
  filters: any;

  @Column({ name: 'sort_config', type: 'jsonb', default: {} })
  sortConfig?: any;

  @Column({ name: 'visible_widgets', type: 'uuid', array: true, nullable: true })
  visibleWidgets?: string[];

  // Metadata
  @Column({ name: 'is_default', type: 'boolean', default: false })
  isDefault: boolean;

  @Column({ name: 'is_shared', type: 'boolean', default: false })
  isShared: boolean;

  @Column({ name: 'usage_count', type: 'integer', default: 0 })
  usageCount: number;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @Column({ name: 'last_used_at', type: 'timestamp', nullable: true })
  lastUsedAt?: Date;
}

5. Services (Lógica de Negocio)

5.1 DashboardService

// src/modules/dashboards/services/dashboard.service.ts

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserDashboard, WidgetDefinition } from '../entities/user-dashboard.entity';
import { SavedView } from '../entities/saved-view.entity';
import { CreateDashboardDto, UpdateDashboardDto, AddWidgetDto } from '../dto';
import { EventEmitter2 } from '@nestjs/event-emitter';

@Injectable()
export class DashboardService {
  constructor(
    @InjectRepository(UserDashboard)
    private dashboardRepo: Repository<UserDashboard>,
    @InjectRepository(SavedView)
    private savedViewRepo: Repository<SavedView>,
    private eventEmitter: EventEmitter2,
  ) {}

  /**
   * Crear un nuevo dashboard
   */
  async create(dto: CreateDashboardDto, userId: string, constructoraId: string): Promise<UserDashboard> {
    const dashboard = this.dashboardRepo.create({
      ...dto,
      userId,
      constructoraId,
      layoutConfig: dto.layoutConfig || this.getDefaultLayoutConfig(),
      widgets: [],
    });

    const saved = await this.dashboardRepo.save(dashboard);

    this.eventEmitter.emit('dashboard.created', {
      dashboardId: saved.id,
      userId,
    });

    return saved;
  }

  /**
   * Actualizar dashboard
   */
  async update(id: string, dto: UpdateDashboardDto, userId: string): Promise<UserDashboard> {
    const dashboard = await this.findOneByUser(id, userId);

    Object.assign(dashboard, dto);

    const updated = await this.dashboardRepo.save(dashboard);

    this.eventEmitter.emit('dashboard.updated', {
      dashboardId: id,
      userId,
    });

    return updated;
  }

  /**
   * Agregar widget al dashboard
   */
  async addWidget(dashboardId: string, dto: AddWidgetDto, userId: string): Promise<UserDashboard> {
    const dashboard = await this.findOneByUser(dashboardId, userId);

    const newWidget: WidgetDefinition = {
      id: `widget-${Date.now()}`,
      type: dto.type,
      title: dto.title,
      dataSource: dto.dataSource,
      config: dto.config || {},
      layout: dto.layout,
    };

    dashboard.widgets.push(newWidget);

    const updated = await this.dashboardRepo.save(dashboard);

    this.eventEmitter.emit('dashboard.widget_added', {
      dashboardId,
      widgetId: newWidget.id,
      userId,
    });

    return updated;
  }

  /**
   * Eliminar widget del dashboard
   */
  async removeWidget(dashboardId: string, widgetId: string, userId: string): Promise<UserDashboard> {
    const dashboard = await this.findOneByUser(dashboardId, userId);

    dashboard.widgets = dashboard.widgets.filter((w) => w.id !== widgetId);

    return this.dashboardRepo.save(dashboard);
  }

  /**
   * Actualizar layout del dashboard
   */
  async updateLayout(
    dashboardId: string,
    widgets: WidgetDefinition[],
    userId: string,
  ): Promise<UserDashboard> {
    const dashboard = await this.findOneByUser(dashboardId, userId);

    dashboard.widgets = widgets;

    return this.dashboardRepo.save(dashboard);
  }

  /**
   * Guardar vista
   */
  async saveView(dashboardId: string, viewName: string, filters: any, userId: string): Promise<SavedView> {
    const dashboard = await this.findOneByUser(dashboardId, userId);

    // Si es vista por defecto, desactivar otras vistas por defecto
    const isDefault = filters.isDefault || false;
    if (isDefault) {
      await this.savedViewRepo.update(
        { dashboardId, userId, isDefault: true },
        { isDefault: false },
      );
    }

    const savedView = this.savedViewRepo.create({
      dashboardId,
      userId,
      viewName,
      filters,
      isDefault,
    });

    return this.savedViewRepo.save(savedView);
  }

  /**
   * Obtener vistas guardadas
   */
  async getSavedViews(dashboardId: string, userId: string): Promise<SavedView[]> {
    return this.savedViewRepo.find({
      where: { dashboardId, userId },
      order: { createdAt: 'DESC' },
    });
  }

  /**
   * Aplicar vista guardada
   */
  async applySavedView(viewId: string, userId: string): Promise<SavedView> {
    const view = await this.savedViewRepo.findOne({
      where: { id: viewId, userId },
      relations: ['dashboard'],
    });

    if (!view) {
      throw new NotFoundException('Saved view not found');
    }

    // Incrementar contador de uso
    view.usageCount += 1;
    view.lastUsedAt = new Date();

    return this.savedViewRepo.save(view);
  }

  /**
   * Clonar dashboard
   */
  async clone(dashboardId: string, newName: string, userId: string): Promise<UserDashboard> {
    const original = await this.findOneByUser(dashboardId, userId);

    const cloned = this.dashboardRepo.create({
      userId,
      constructoraId: original.constructoraId,
      name: newName,
      description: original.description,
      icon: original.icon,
      layoutConfig: original.layoutConfig,
      widgets: original.widgets,
      globalFilters: original.globalFilters,
      autoRefresh: original.autoRefresh,
      refreshInterval: original.refreshInterval,
    });

    return this.dashboardRepo.save(cloned);
  }

  /**
   * Marcar como favorito
   */
  async toggleFavorite(dashboardId: string, userId: string): Promise<UserDashboard> {
    const dashboard = await this.findOneByUser(dashboardId, userId);

    dashboard.isFavorite = !dashboard.isFavorite;

    return this.dashboardRepo.save(dashboard);
  }

  /**
   * Obtener dashboards del usuario
   */
  async findAllByUser(userId: string): Promise<UserDashboard[]> {
    return this.dashboardRepo.find({
      where: { userId },
      order: {
        isFavorite: 'DESC',
        lastViewedAt: 'DESC',
        createdAt: 'DESC',
      },
    });
  }

  /**
   * Obtener dashboard por ID
   */
  async findOneByUser(id: string, userId: string): Promise<UserDashboard> {
    const dashboard = await this.dashboardRepo.findOne({
      where: { id, userId },
    });

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

    // Actualizar last_viewed_at
    dashboard.lastViewedAt = new Date();
    await this.dashboardRepo.save(dashboard);

    return dashboard;
  }

  /**
   * Configuración de layout por defecto
   */
  private getDefaultLayoutConfig(): any {
    return {
      breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 },
      cols: { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 },
      rowHeight: 100,
      compactType: 'vertical',
    };
  }
}

5.2 WidgetService

// src/modules/dashboards/services/widget.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Widget, DataSourceType } from '../entities/widget.entity';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import crypto from 'crypto';

@Injectable()
export class WidgetService {
  constructor(
    @InjectRepository(Widget)
    private widgetRepo: Repository<Widget>,
    @InjectRedis() private readonly redis: Redis,
  ) {}

  /**
   * Obtener datos de un widget
   */
  async getWidgetData(
    widgetId: string,
    filters: any = {},
    useCache: boolean = true,
  ): Promise<any> {
    const widget = await this.widgetRepo.findOne({ where: { id: widgetId } });

    if (!widget) {
      throw new NotFoundException('Widget not found');
    }

    // Generar cache key
    const cacheKey = this.generateCacheKey(widgetId, filters);

    // Intentar obtener de cache
    if (useCache) {
      const cached = await this.redis.get(cacheKey);
      if (cached) {
        return JSON.parse(cached);
      }
    }

    // Obtener datos según tipo de data source
    let data: any;

    switch (widget.dataSourceType) {
      case DataSourceType.SQL_QUERY:
        data = await this.executeQuery(widget.dataSourceConfig.query, filters);
        break;

      case DataSourceType.MATERIALIZED_VIEW:
        data = await this.queryMaterializedView(widget.dataSourceConfig.view, filters);
        break;

      case DataSourceType.API_ENDPOINT:
        data = await this.fetchFromAPI(widget.dataSourceConfig.endpoint, filters);
        break;

      case DataSourceType.AGGREGATION:
        data = await this.performAggregation(widget.dataSourceConfig, filters);
        break;

      default:
        throw new Error(`Unsupported data source type: ${widget.dataSourceType}`);
    }

    // Aplicar transformaciones según tipo de widget
    const transformedData = this.transformDataForWidget(data, widget.widgetType, widget.defaultConfig);

    // Guardar en cache
    if (useCache) {
      const ttl = widget.dataSourceConfig?.refreshInterval || 300000; // 5 min default
      await this.redis.setex(cacheKey, Math.floor(ttl / 1000), JSON.stringify(transformedData));
    }

    return transformedData;
  }

  /**
   * Ejecutar query SQL
   */
  private async executeQuery(query: string, filters: any): Promise<any> {
    // Sanitizar y parametrizar query
    const params = this.buildQueryParams(filters);

    return this.widgetRepo.query(query, params);
  }

  /**
   * Query materialized view
   */
  private async queryMaterializedView(viewName: string, filters: any): Promise<any> {
    const whereClause = this.buildWhereClause(filters);

    const query = `
      SELECT * FROM ${viewName}
      ${whereClause ? `WHERE ${whereClause}` : ''}
    `;

    return this.widgetRepo.query(query);
  }

  /**
   * Fetch from API endpoint
   */
  private async fetchFromAPI(endpoint: string, filters: any): Promise<any> {
    // TODO: Implementar llamada a API externa
    throw new Error('API endpoint not implemented yet');
  }

  /**
   * Perform aggregation
   */
  private async performAggregation(config: any, filters: any): Promise<any> {
    const { sourceTable, aggregation, field } = config;

    const whereClause = this.buildWhereClause(filters);

    const query = `
      SELECT
        ${aggregation}(${field}) AS value
      FROM ${sourceTable}
      ${whereClause ? `WHERE ${whereClause}` : ''}
    `;

    const result = await this.widgetRepo.query(query);
    return result[0]?.value;
  }

  /**
   * Transformar datos según tipo de widget
   */
  private transformDataForWidget(data: any, widgetType: string, config: any): any {
    switch (widgetType) {
      case 'kpi_card':
        return {
          currentValue: data,
          ...config,
        };

      case 'line_chart':
      case 'bar_chart':
      case 'area_chart':
        return {
          labels: data.map((d: any) => d.label || d.date),
          datasets: [
            {
              label: config.datasetLabel || 'Value',
              data: data.map((d: any) => d.value),
              ...config.chartOptions,
            },
          ],
        };

      case 'pie_chart':
      case 'donut_chart':
        return {
          labels: data.map((d: any) => d.label),
          datasets: [
            {
              data: data.map((d: any) => d.value),
              backgroundColor: config.colors || this.generateColors(data.length),
            },
          ],
        };

      case 'table':
        return {
          columns: Object.keys(data[0] || {}),
          rows: data,
        };

      default:
        return data;
    }
  }

  /**
   * Build WHERE clause from filters
   */
  private buildWhereClause(filters: any): string {
    const conditions: string[] = [];

    Object.entries(filters).forEach(([key, value]) => {
      if (Array.isArray(value)) {
        conditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(', ')})`);
      } else if (typeof value === 'object' && value !== null) {
        // Date range
        if ('start' in value && 'end' in value) {
          conditions.push(`${key} BETWEEN '${value.start}' AND '${value.end}'`);
        }
      } else {
        conditions.push(`${key} = '${value}'`);
      }
    });

    return conditions.join(' AND ');
  }

  /**
   * Build query params
   */
  private buildQueryParams(filters: any): any[] {
    return Object.values(filters);
  }

  /**
   * Generate cache key
   */
  private generateCacheKey(widgetId: string, filters: any): string {
    const filterHash = crypto
      .createHash('md5')
      .update(JSON.stringify(filters))
      .digest('hex');

    return `widget:${widgetId}:${filterHash}`;
  }

  /**
   * Generate colors for charts
   */
  private generateColors(count: number): string[] {
    const colors = [
      '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
      '#EC4899', '#14B8A6', '#F97316', '#06B6D4', '#84CC16',
    ];

    return Array.from({ length: count }, (_, i) => colors[i % colors.length]);
  }

  /**
   * Invalidar cache de widget
   */
  async invalidateCache(widgetId: string): Promise<void> {
    const pattern = `widget:${widgetId}:*`;
    const keys = await this.redis.keys(pattern);

    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  }
}

5.3 DrillDownService

// src/modules/dashboards/services/drill-down.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DrillDownPath } from '../entities/drill-down-path.entity';

@Injectable()
export class DrillDownService {
  constructor(
    @InjectRepository(DrillDownPath)
    private drillDownRepo: Repository<DrillDownPath>,
  ) {}

  /**
   * Obtener rutas de drill-down para un widget
   */
  async getDrillDownPaths(widgetId: string): Promise<DrillDownPath[]> {
    return this.drillDownRepo.find({
      where: { widgetId },
      order: { orderSequence: 'ASC' },
    });
  }

  /**
   * Aplicar drill-down
   */
  async applyDrillDown(
    widgetId: string,
    fromDimension: string,
    selectedValue: any,
  ): Promise<{ toDimension: string; filters: any }> {
    const path = await this.drillDownRepo.findOne({
      where: {
        widgetId,
        fromDimension,
      },
    });

    if (!path) {
      throw new Error(`No drill-down path found for ${fromDimension}`);
    }

    // Construir filtros para el siguiente nivel
    const filters = this.buildDrillDownFilters(path, selectedValue);

    return {
      toDimension: path.toDimension,
      filters,
    };
  }

  /**
   * Construir filtros para drill-down
   */
  private buildDrillDownFilters(path: DrillDownPath, selectedValue: any): any {
    const { sourceField, targetField, operator } = path.filterMapping;

    return {
      [targetField]: {
        operator,
        value: selectedValue,
      },
    };
  }
}

6. Controllers (API Endpoints)

// src/modules/dashboards/controllers/dashboard.controller.ts

import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Param,
  Body,
  Query,
  UseGuards,
  Request,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { DashboardService } from '../services/dashboard.service';
import { WidgetService } from '../services/widget.service';
import { DrillDownService } from '../services/drill-down.service';
import {
  CreateDashboardDto,
  UpdateDashboardDto,
  AddWidgetDto,
  SaveViewDto,
} from '../dto';

@Controller('api/dashboards')
@UseGuards(JwtAuthGuard)
export class DashboardController {
  constructor(
    private dashboardService: DashboardService,
    private widgetService: WidgetService,
    private drillDownService: DrillDownService,
  ) {}

  /**
   * POST /api/dashboards
   * Crear nuevo dashboard
   */
  @Post()
  async create(@Body() dto: CreateDashboardDto, @Request() req) {
    return this.dashboardService.create(dto, req.user.sub, req.user.constructoraId);
  }

  /**
   * GET /api/dashboards
   * Obtener todos los dashboards del usuario
   */
  @Get()
  async findAll(@Request() req) {
    return this.dashboardService.findAllByUser(req.user.sub);
  }

  /**
   * GET /api/dashboards/:id
   * Obtener dashboard por ID
   */
  @Get(':id')
  async findOne(@Param('id') id: string, @Request() req) {
    return this.dashboardService.findOneByUser(id, req.user.sub);
  }

  /**
   * PUT /api/dashboards/:id
   * Actualizar dashboard
   */
  @Put(':id')
  async update(@Param('id') id: string, @Body() dto: UpdateDashboardDto, @Request() req) {
    return this.dashboardService.update(id, dto, req.user.sub);
  }

  /**
   * DELETE /api/dashboards/:id
   * Eliminar dashboard
   */
  @Delete(':id')
  async remove(@Param('id') id: string, @Request() req) {
    // TODO: Implementar eliminación
    return { message: 'Dashboard deleted' };
  }

  /**
   * POST /api/dashboards/:id/widgets
   * Agregar widget al dashboard
   */
  @Post(':id/widgets')
  async addWidget(@Param('id') id: string, @Body() dto: AddWidgetDto, @Request() req) {
    return this.dashboardService.addWidget(id, dto, req.user.sub);
  }

  /**
   * DELETE /api/dashboards/:id/widgets/:widgetId
   * Eliminar widget del dashboard
   */
  @Delete(':id/widgets/:widgetId')
  async removeWidget(@Param('id') id: string, @Param('widgetId') widgetId: string, @Request() req) {
    return this.dashboardService.removeWidget(id, widgetId, req.user.sub);
  }

  /**
   * PUT /api/dashboards/:id/layout
   * Actualizar layout del dashboard
   */
  @Put(':id/layout')
  async updateLayout(@Param('id') id: string, @Body() body: { widgets: any[] }, @Request() req) {
    return this.dashboardService.updateLayout(id, body.widgets, req.user.sub);
  }

  /**
   * POST /api/dashboards/:id/views
   * Guardar vista
   */
  @Post(':id/views')
  async saveView(@Param('id') id: string, @Body() dto: SaveViewDto, @Request() req) {
    return this.dashboardService.saveView(id, dto.viewName, dto.filters, req.user.sub);
  }

  /**
   * GET /api/dashboards/:id/views
   * Obtener vistas guardadas
   */
  @Get(':id/views')
  async getSavedViews(@Param('id') id: string, @Request() req) {
    return this.dashboardService.getSavedViews(id, req.user.sub);
  }

  /**
   * POST /api/dashboards/:id/favorite
   * Toggle favorito
   */
  @Post(':id/favorite')
  async toggleFavorite(@Param('id') id: string, @Request() req) {
    return this.dashboardService.toggleFavorite(id, req.user.sub);
  }

  /**
   * POST /api/dashboards/:id/clone
   * Clonar dashboard
   */
  @Post(':id/clone')
  async clone(@Param('id') id: string, @Body() body: { name: string }, @Request() req) {
    return this.dashboardService.clone(id, body.name, req.user.sub);
  }

  /**
   * GET /api/dashboards/widgets/:widgetId/data
   * Obtener datos de un widget
   */
  @Get('widgets/:widgetId/data')
  async getWidgetData(
    @Param('widgetId') widgetId: string,
    @Query('filters') filters: string,
  ) {
    const parsedFilters = filters ? JSON.parse(filters) : {};
    return this.widgetService.getWidgetData(widgetId, parsedFilters);
  }

  /**
   * POST /api/dashboards/widgets/:widgetId/drill-down
   * Aplicar drill-down
   */
  @Post('widgets/:widgetId/drill-down')
  async applyDrillDown(
    @Param('widgetId') widgetId: string,
    @Body() body: { fromDimension: string; selectedValue: any },
  ) {
    return this.drillDownService.applyDrillDown(
      widgetId,
      body.fromDimension,
      body.selectedValue,
    );
  }
}

7. React Components

7.1 DashboardGrid Component

// src/pages/Dashboards/DashboardGrid.tsx

import React, { useState, useEffect } from 'react';
import GridLayout, { Layout } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useDashboardStore } from '../../stores/dashboardStore';
import { WidgetContainer } from '../../components/dashboards/WidgetContainer';
import { DashboardFilters } from '../../components/dashboards/DashboardFilters';
import { Button } from '../../components/ui/Button';
import { Save, Plus } from 'lucide-react';

interface DashboardGridProps {
  dashboardId: string;
}

export const DashboardGrid: React.FC<DashboardGridProps> = ({ dashboardId }) => {
  const { currentDashboard, loading, fetchDashboard, updateLayout } = useDashboardStore();
  const [isEditMode, setIsEditMode] = useState(false);
  const [layouts, setLayouts] = useState<{ [key: string]: Layout[] }>({});

  useEffect(() => {
    fetchDashboard(dashboardId);
  }, [dashboardId]);

  useEffect(() => {
    if (currentDashboard) {
      // Convertir widgets a layouts de react-grid-layout
      const gridLayouts = currentDashboard.widgets.reduce((acc, widget) => {
        Object.entries(widget.layout).forEach(([breakpoint, layout]) => {
          if (!acc[breakpoint]) acc[breakpoint] = [];
          acc[breakpoint].push({
            i: widget.id,
            ...layout,
          });
        });
        return acc;
      }, {} as { [key: string]: Layout[] });

      setLayouts(gridLayouts);
    }
  }, [currentDashboard]);

  const handleLayoutChange = (currentLayout: Layout[], allLayouts: { [key: string]: Layout[] }) => {
    if (!isEditMode) return;

    setLayouts(allLayouts);
  };

  const handleSaveLayout = async () => {
    if (!currentDashboard) return;

    // Convertir layouts de vuelta a formato de widgets
    const updatedWidgets = currentDashboard.widgets.map((widget) => ({
      ...widget,
      layout: Object.entries(layouts).reduce((acc, [breakpoint, layout]) => {
        const widgetLayout = layout.find((l) => l.i === widget.id);
        if (widgetLayout) {
          const { i, ...rest } = widgetLayout;
          acc[breakpoint] = rest;
        }
        return acc;
      }, {} as any),
    }));

    await updateLayout(dashboardId, updatedWidgets);
    setIsEditMode(false);
  };

  if (loading || !currentDashboard) {
    return <div>Cargando dashboard...</div>;
  }

  return (
    <div className="dashboard-grid">
      {/* Header */}
      <div className="dashboard-header flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold">{currentDashboard.name}</h1>
          <p className="text-gray-600">{currentDashboard.description}</p>
        </div>

        <div className="flex gap-2">
          {isEditMode ? (
            <>
              <Button onClick={() => setIsEditMode(false)} variant="outline">
                Cancelar
              </Button>
              <Button onClick={handleSaveLayout} variant="primary">
                <Save className="w-4 h-4 mr-2" />
                Guardar Layout
              </Button>
            </>
          ) : (
            <>
              <Button onClick={() => setIsEditMode(true)} variant="outline">
                Editar
              </Button>
              <Button variant="primary">
                <Plus className="w-4 h-4 mr-2" />
                Agregar Widget
              </Button>
            </>
          )}
        </div>
      </div>

      {/* Filtros globales */}
      <DashboardFilters
        filters={currentDashboard.globalFilters}
        onChange={(filters) => {
          // TODO: Actualizar filtros
        }}
      />

      {/* Grid de widgets */}
      <GridLayout
        className="layout"
        layouts={layouts}
        breakpoints={currentDashboard.layoutConfig.breakpoints}
        cols={currentDashboard.layoutConfig.cols}
        rowHeight={currentDashboard.layoutConfig.rowHeight}
        isDraggable={isEditMode}
        isResizable={isEditMode}
        onLayoutChange={handleLayoutChange}
        compactType={currentDashboard.layoutConfig.compactType}
      >
        {currentDashboard.widgets.map((widget) => (
          <div key={widget.id}>
            <WidgetContainer
              widget={widget}
              filters={currentDashboard.globalFilters}
              isEditMode={isEditMode}
            />
          </div>
        ))}
      </GridLayout>
    </div>
  );
};

7.2 WidgetContainer Component

// src/components/dashboards/WidgetContainer.tsx

import React, { useEffect, useState } from 'react';
import { useWidgetStore } from '../../stores/widgetStore';
import { Card } from '../ui/Card';
import { KPICard } from '../reports/KPICard';
import { LineChart } from '../charts/LineChart';
import { BarChart } from '../charts/BarChart';
import { DataTable } from '../ui/DataTable';
import { RefreshCw, Download, Maximize2, X } from 'lucide-react';

interface WidgetContainerProps {
  widget: any;
  filters: any;
  isEditMode: boolean;
}

export const WidgetContainer: React.FC<WidgetContainerProps> = ({
  widget,
  filters,
  isEditMode,
}) => {
  const { getWidgetData, loading } = useWidgetStore();
  const [data, setData] = useState<any>(null);
  const [isRefreshing, setIsRefreshing] = useState(false);

  useEffect(() => {
    loadData();
  }, [widget.id, filters]);

  const loadData = async () => {
    setIsRefreshing(true);
    try {
      const result = await getWidgetData(widget.id, filters);
      setData(result);
    } catch (error) {
      console.error('Error loading widget data:', error);
    } finally {
      setIsRefreshing(false);
    }
  };

  const renderWidget = () => {
    if (!data) return <div>No data</div>;

    switch (widget.type) {
      case 'kpi_card':
        return (
          <KPICard
            title={widget.title}
            value={data.currentValue}
            {...widget.config}
          />
        );

      case 'line_chart':
        return <LineChart data={data} {...widget.config} />;

      case 'bar_chart':
        return <BarChart data={data} {...widget.config} />;

      case 'table':
        return (
          <DataTable
            columns={data.columns}
            data={data.rows}
            {...widget.config}
          />
        );

      default:
        return <div>Unknown widget type: {widget.type}</div>;
    }
  };

  return (
    <Card
      className={`widget-container h-full ${isEditMode ? 'edit-mode' : ''}`}
      title={widget.title}
      actions={
        !isEditMode && (
          <div className="flex gap-2">
            <button
              onClick={loadData}
              disabled={isRefreshing}
              className="p-1 hover:bg-gray-100 rounded"
            >
              <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
            </button>
            <button className="p-1 hover:bg-gray-100 rounded">
              <Download className="w-4 h-4" />
            </button>
            <button className="p-1 hover:bg-gray-100 rounded">
              <Maximize2 className="w-4 h-4" />
            </button>
          </div>
        )
      }
    >
      <div className="widget-content h-full">
        {loading || isRefreshing ? (
          <div className="flex items-center justify-center h-full">
            <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
          </div>
        ) : (
          renderWidget()
        )}
      </div>
    </Card>
  );
};

8. WebSocket (Real-time Updates)

// src/modules/dashboards/gateways/dashboard.gateway.ts

import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  OnGatewayConnection,
  OnGatewayDisconnect,
  ConnectedSocket,
  MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';

@WebSocketGateway({
  namespace: '/dashboards',
  cors: {
    origin: '*',
  },
})
export class DashboardGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  private logger = new Logger(DashboardGateway.name);

  handleConnection(client: Socket) {
    this.logger.log(`Client connected: ${client.id}`);
  }

  handleDisconnect(client: Socket) {
    this.logger.log(`Client disconnected: ${client.id}`);
  }

  /**
   * Cliente se suscribe a un dashboard
   */
  @SubscribeMessage('subscribe_dashboard')
  handleSubscribeDashboard(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { dashboardId: string },
  ) {
    client.join(`dashboard:${data.dashboardId}`);
    this.logger.log(`Client ${client.id} subscribed to dashboard ${data.dashboardId}`);

    return { event: 'subscribed', dashboardId: data.dashboardId };
  }

  /**
   * Cliente se desuscribe de un dashboard
   */
  @SubscribeMessage('unsubscribe_dashboard')
  handleUnsubscribeDashboard(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { dashboardId: string },
  ) {
    client.leave(`dashboard:${data.dashboardId}`);
    this.logger.log(`Client ${client.id} unsubscribed from dashboard ${data.dashboardId}`);

    return { event: 'unsubscribed', dashboardId: data.dashboardId };
  }

  /**
   * Broadcast cuando se actualiza un widget
   */
  @OnEvent('widget.data_updated')
  handleWidgetDataUpdated(payload: { widgetId: string; dashboardId: string; data: any }) {
    this.server.to(`dashboard:${payload.dashboardId}`).emit('widget_updated', {
      widgetId: payload.widgetId,
      data: payload.data,
      timestamp: new Date(),
    });

    this.logger.log(`Widget ${payload.widgetId} updated in dashboard ${payload.dashboardId}`);
  }

  /**
   * Broadcast cuando se actualiza un dashboard
   */
  @OnEvent('dashboard.updated')
  handleDashboardUpdated(payload: { dashboardId: string }) {
    this.server.to(`dashboard:${payload.dashboardId}`).emit('dashboard_updated', {
      dashboardId: payload.dashboardId,
      timestamp: new Date(),
    });
  }
}

9. Testing

// src/modules/dashboards/services/__tests__/dashboard.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DashboardService } from '../dashboard.service';
import { UserDashboard } from '../../entities/user-dashboard.entity';
import { SavedView } from '../../entities/saved-view.entity';
import { EventEmitter2 } from '@nestjs/event-emitter';

describe('DashboardService', () => {
  let service: DashboardService;
  let dashboardRepo: any;
  let savedViewRepo: any;

  beforeEach(async () => {
    dashboardRepo = {
      create: jest.fn(),
      save: jest.fn(),
      findOne: jest.fn(),
      find: jest.fn(),
    };

    savedViewRepo = {
      create: jest.fn(),
      save: jest.fn(),
      find: jest.fn(),
      update: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DashboardService,
        {
          provide: getRepositoryToken(UserDashboard),
          useValue: dashboardRepo,
        },
        {
          provide: getRepositoryToken(SavedView),
          useValue: savedViewRepo,
        },
        {
          provide: EventEmitter2,
          useValue: { emit: jest.fn() },
        },
      ],
    }).compile();

    service = module.get<DashboardService>(DashboardService);
  });

  describe('create', () => {
    it('should create a new dashboard', async () => {
      const dto = {
        name: 'My Dashboard',
        description: 'Test dashboard',
      };

      dashboardRepo.create.mockReturnValue({ ...dto, id: 'uuid-1' });
      dashboardRepo.save.mockResolvedValue({ ...dto, id: 'uuid-1' });

      const result = await service.create(dto as any, 'user-1', 'constructora-1');

      expect(result.name).toBe(dto.name);
      expect(dashboardRepo.save).toHaveBeenCalled();
    });
  });

  describe('addWidget', () => {
    it('should add a widget to dashboard', async () => {
      const dashboard = {
        id: 'dashboard-1',
        widgets: [],
      };

      const widgetDto = {
        type: 'kpi_card',
        title: 'Test Widget',
        dataSource: 'test',
        layout: { lg: { x: 0, y: 0, w: 3, h: 2 } },
      };

      dashboardRepo.findOne.mockResolvedValue(dashboard);
      dashboardRepo.save.mockImplementation((d) => Promise.resolve(d));

      const result = await service.addWidget('dashboard-1', widgetDto as any, 'user-1');

      expect(result.widgets).toHaveLength(1);
      expect(result.widgets[0].title).toBe(widgetDto.title);
    });
  });
});

10. Criterios de Aceptación Técnicos

  • Schema dashboards creado con todas las tablas
  • Entities TypeORM con relaciones correctas
  • Services para gestión de dashboards y widgets
  • Drag & drop con react-grid-layout
  • Widgets configurables (KPI, gráficas, tablas)
  • Sistema de vistas guardadas
  • Drill-down con navegación entre dimensiones
  • Cache de datos de widgets con Redis
  • WebSocket para actualizaciones en tiempo real
  • Controllers con endpoints RESTful
  • React components responsivos
  • Triggers para auto-cleanup de cache
  • Tests unitarios con >80% coverage

Fecha: 2025-11-17 Preparado por: Equipo Técnico Versión: 1.0 Estado: Listo para Implementación