# Implementacion de Portales y Dashboards **Version:** 1.0.0 **Tiempo estimado:** 1-2 sprints **Prerequisitos:** React, NestJS, Recharts --- ## Paso 1: Schema de Base de Datos ```sql -- database/ddl/config-dashboards.sql CREATE SCHEMA IF NOT EXISTS config; -- Layouts de dashboard por rol CREATE TABLE config.dashboard_layouts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role_code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, is_default BOOLEAN DEFAULT false, layout JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Preferencias de usuario (personalizacion) CREATE TABLE config.user_dashboard_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id), layout_overrides JSONB DEFAULT '{}', widget_settings JSONB DEFAULT '{}', theme VARCHAR(20) DEFAULT 'light', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id) ); -- Widgets disponibles (registro) CREATE TABLE config.widgets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL, description TEXT, component VARCHAR(100) NOT NULL, default_config JSONB DEFAULT '{}', required_permissions TEXT[] DEFAULT '{}', is_active BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Datos de ejemplo INSERT INTO config.widgets (code, name, component, default_config) VALUES ('stats-card', 'Tarjeta de Estadistica', 'StatsCard', '{"size": "small"}'), ('line-chart', 'Grafico de Lineas', 'LineChart', '{"height": 300}'), ('bar-chart', 'Grafico de Barras', 'BarChart', '{"height": 300}'), ('pie-chart', 'Grafico Circular', 'PieChart', '{"height": 250}'), ('recent-activity', 'Actividad Reciente', 'RecentActivity', '{"limit": 10}'), ('quick-actions', 'Accesos Rapidos', 'QuickActions', '{}'), ('data-table', 'Tabla de Datos', 'DataTable', '{"pageSize": 10}'); ``` --- ## Paso 2: Backend - Servicio de Dashboard ### 2.1 Entidades ```typescript // backend/src/modules/dashboards/entities/dashboard-layout.entity.ts import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; interface WidgetPosition { widgetId: string; x: number; y: number; w: number; h: number; config?: Record; } @Entity({ schema: 'config', name: 'dashboard_layouts' }) export class DashboardLayout { @PrimaryGeneratedColumn('uuid') id: string; @Column() roleCode: string; @Column() name: string; @Column({ default: false }) isDefault: boolean; @Column({ type: 'jsonb' }) layout: WidgetPosition[]; } ``` ### 2.2 Servicio de Dashboard ```typescript // backend/src/modules/dashboards/dashboard.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DashboardLayout } from './entities/dashboard-layout.entity'; import { UserDashboardPreferences } from './entities/user-preferences.entity'; @Injectable() export class DashboardService { constructor( @InjectRepository(DashboardLayout) private layoutRepo: Repository, @InjectRepository(UserDashboardPreferences) private prefsRepo: Repository, ) {} async getDashboardForUser(userId: string, roleCode: string) { // 1. Obtener layout por defecto del rol const defaultLayout = await this.layoutRepo.findOne({ where: { roleCode, isDefault: true }, }); // 2. Obtener personalizaciones del usuario const userPrefs = await this.prefsRepo.findOne({ where: { userId }, }); // 3. Mergear layouts return this.mergeLayouts(defaultLayout, userPrefs); } async saveUserPreferences(userId: string, preferences: Partial) { let userPrefs = await this.prefsRepo.findOne({ where: { userId } }); if (!userPrefs) { userPrefs = this.prefsRepo.create({ userId }); } Object.assign(userPrefs, preferences); return this.prefsRepo.save(userPrefs); } async getAvailableWidgets(roleCode: string) { // Retornar widgets disponibles para este rol return this.widgetRepo.find({ where: { isActive: true }, }); } private mergeLayouts(defaultLayout: DashboardLayout, userPrefs: UserDashboardPreferences) { if (!userPrefs?.layoutOverrides) { return defaultLayout.layout; } // Aplicar overrides del usuario sobre el layout por defecto return defaultLayout.layout.map(widget => ({ ...widget, ...(userPrefs.layoutOverrides[widget.widgetId] || {}), })); } } ``` ### 2.3 Gateway para Real-time ```typescript // backend/src/gateways/dashboard.gateway.ts import { WebSocketGateway, WebSocketServer, SubscribeMessage } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; @WebSocketGateway({ namespace: 'dashboard' }) export class DashboardGateway { @WebSocketServer() server: Server; @SubscribeMessage('subscribe:widget') handleSubscribeWidget(client: Socket, widgetId: string) { client.join(`widget:${widgetId}`); return { subscribed: widgetId }; } @SubscribeMessage('unsubscribe:widget') handleUnsubscribeWidget(client: Socket, widgetId: string) { client.leave(`widget:${widgetId}`); return { unsubscribed: widgetId }; } // Llamar desde servicios para emitir actualizaciones emitWidgetUpdate(widgetId: string, data: any) { this.server.to(`widget:${widgetId}`).emit('widget:update', { widgetId, data }); } } ``` --- ## Paso 3: Frontend - Sistema de Widgets ### 3.1 Registro de Widgets ```typescript // frontend/src/components/widgets/widget.registry.ts import { StatsCard } from './StatsCard'; import { LineChart } from './LineChart'; import { BarChart } from './BarChart'; import { PieChart } from './PieChart'; import { RecentActivity } from './RecentActivity'; import { QuickActions } from './QuickActions'; import { DataTable } from './DataTable'; export const widgetRegistry: Record> = { 'StatsCard': StatsCard, 'LineChart': LineChart, 'BarChart': BarChart, 'PieChart': PieChart, 'RecentActivity': RecentActivity, 'QuickActions': QuickActions, 'DataTable': DataTable, }; export function getWidget(componentName: string) { const Widget = widgetRegistry[componentName]; if (!Widget) { console.warn(`Widget "${componentName}" not found in registry`); return null; } return Widget; } ``` ### 3.2 Componente StatsCard ```tsx // frontend/src/components/widgets/StatsCard.tsx interface StatsCardProps { title: string; value: number | string; icon?: React.ReactNode; trend?: { value: number; direction: 'up' | 'down'; }; format?: 'number' | 'currency' | 'percent'; } export function StatsCard({ title, value, icon, trend, format = 'number' }: StatsCardProps) { const formattedValue = formatValue(value, format); return (

{title}

{formattedValue}

{trend && (

{trend.direction === 'up' ? '↑' : '↓'} {trend.value}%

)}
{icon &&
{icon}
}
); } function formatValue(value: number | string, format: string) { if (typeof value === 'string') return value; switch (format) { case 'currency': return `$${value.toLocaleString()}`; case 'percent': return `${value}%`; default: return value.toLocaleString(); } } ``` ### 3.3 Dashboard Grid ```tsx // frontend/src/components/dashboard/DashboardGrid.tsx import GridLayout from 'react-grid-layout'; import { getWidget } from '../widgets/widget.registry'; import { useDashboardStore } from '../../stores/dashboard.store'; export function DashboardGrid() { const { layout, updateLayout, widgetData } = useDashboardStore(); const handleLayoutChange = (newLayout: any[]) => { updateLayout(newLayout); }; return ( {layout.map((item) => { const Widget = getWidget(item.component); if (!Widget) return null; return (
{item.title}
); })}
); } ``` ### 3.4 Dashboard Store ```typescript // frontend/src/stores/dashboard.store.ts import { create } from 'zustand'; import { api } from '../services/api'; import { socket } from '../services/socket'; interface DashboardState { layout: WidgetLayout[]; widgetData: Record; loading: boolean; fetchDashboard: () => Promise; updateLayout: (layout: WidgetLayout[]) => void; subscribeToWidgets: () => void; unsubscribeFromWidgets: () => void; } export const useDashboardStore = create((set, get) => ({ layout: [], widgetData: {}, loading: false, fetchDashboard: async () => { set({ loading: true }); try { const response = await api.get('/dashboard'); set({ layout: response.data.layout }); // Cargar datos iniciales de cada widget for (const widget of response.data.layout) { const data = await api.get(`/dashboard/widget/${widget.i}/data`); set((state) => ({ widgetData: { ...state.widgetData, [widget.i]: data.data }, })); } } finally { set({ loading: false }); } }, updateLayout: async (layout) => { set({ layout }); await api.put('/dashboard/preferences', { layoutOverrides: layout }); }, subscribeToWidgets: () => { const { layout } = get(); layout.forEach((widget) => { socket.emit('subscribe:widget', widget.i); }); socket.on('widget:update', ({ widgetId, data }) => { set((state) => ({ widgetData: { ...state.widgetData, [widgetId]: data }, })); }); }, unsubscribeFromWidgets: () => { const { layout } = get(); layout.forEach((widget) => { socket.emit('unsubscribe:widget', widget.i); }); socket.off('widget:update'); }, })); ``` --- ## Paso 4: Layouts por Rol ### 4.1 Admin Layout ```tsx // frontend/src/layouts/AdminLayout.tsx import { Sidebar } from '../components/navigation/Sidebar'; import { TopBar } from '../components/navigation/TopBar'; const adminMenuItems = [ { icon: 'dashboard', label: 'Dashboard', path: '/admin' }, { icon: 'users', label: 'Usuarios', path: '/admin/users' }, { icon: 'building', label: 'Tenants', path: '/admin/tenants' }, { icon: 'chart', label: 'Metricas', path: '/admin/metrics' }, { icon: 'settings', label: 'Configuracion', path: '/admin/settings' }, ]; export function AdminLayout({ children }: { children: React.ReactNode }) { return (
{children}
); } ``` --- ## Paso 5: Crear Widget Personalizado ### 5.1 Template de Widget ```tsx // frontend/src/components/widgets/CustomWidget.tsx import { useEffect, useState } from 'react'; import { useDashboardStore } from '../../stores/dashboard.store'; interface CustomWidgetProps { config: { endpoint: string; refreshInterval?: number; }; data?: any; } export function CustomWidget({ config, data: initialData }: CustomWidgetProps) { const [data, setData] = useState(initialData); const [loading, setLoading] = useState(!initialData); useEffect(() => { if (!initialData) { fetchData(); } if (config.refreshInterval) { const interval = setInterval(fetchData, config.refreshInterval); return () => clearInterval(interval); } }, []); const fetchData = async () => { setLoading(true); try { const response = await api.get(config.endpoint); setData(response.data); } finally { setLoading(false); } }; if (loading) { return
; } return (
{/* Renderizar datos */} {JSON.stringify(data)}
); } // Registrar widget widgetRegistry['CustomWidget'] = CustomWidget; ``` --- ## Checklist de Implementacion - [ ] Schema de BD creado - [ ] Entidades TypeORM creadas - [ ] Servicio de dashboard implementado - [ ] Gateway WebSocket configurado - [ ] Componentes de widget base - [ ] DashboardGrid con drag-drop - [ ] Store de dashboard - [ ] Layouts por rol - [ ] Preferencias de usuario - [ ] Tests unitarios --- *Catalogo de Funcionalidades - SIMCO v3.4*