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
54 KiB
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
dashboardscreado 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