workspace-v1/shared/catalog/portales/IMPLEMENTATION.md
rckrdmrd cb4c0681d3 feat(workspace): Add new projects and update architecture
New projects created:
- michangarrito (marketplace mobile)
- template-saas (SaaS template)
- clinica-dental (dental ERP)
- clinica-veterinaria (veterinary ERP)

Architecture updates:
- Move catalog from core/ to shared/
- Add MCP servers structure and templates
- Add git management scripts
- Update SUBREPOSITORIOS.md with 15 new repos
- Update .gitignore for new projects

Repository infrastructure:
- 4 main repositories
- 11 subrepositorios
- Gitea remotes configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:43:28 -06:00

13 KiB

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

-- 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

// 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<string, any>;
}

@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

// 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<DashboardLayout>,
    @InjectRepository(UserDashboardPreferences)
    private prefsRepo: Repository<UserDashboardPreferences>,
  ) {}

  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<UserDashboardPreferences>) {
    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

// 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

// 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<string, React.ComponentType<any>> = {
  '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

// 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 (
    <div className="bg-white rounded-lg shadow p-6">
      <div className="flex items-center justify-between">
        <div>
          <p className="text-sm text-gray-500">{title}</p>
          <p className="text-2xl font-bold mt-1">{formattedValue}</p>
          {trend && (
            <p className={`text-sm mt-1 ${trend.direction === 'up' ? 'text-green-500' : 'text-red-500'}`}>
              {trend.direction === 'up' ? '↑' : '↓'} {trend.value}%
            </p>
          )}
        </div>
        {icon && <div className="text-gray-400">{icon}</div>}
      </div>
    </div>
  );
}

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

// 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 (
    <GridLayout
      className="layout"
      layout={layout}
      cols={12}
      rowHeight={100}
      width={1200}
      onLayoutChange={handleLayoutChange}
      draggableHandle=".widget-header"
    >
      {layout.map((item) => {
        const Widget = getWidget(item.component);
        if (!Widget) return null;

        return (
          <div key={item.i} className="bg-white rounded-lg shadow">
            <div className="widget-header p-2 border-b cursor-move">
              <span className="text-sm font-medium">{item.title}</span>
            </div>
            <div className="p-4">
              <Widget
                {...item.config}
                data={widgetData[item.i]}
              />
            </div>
          </div>
        );
      })}
    </GridLayout>
  );
}

3.4 Dashboard Store

// 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<string, any>;
  loading: boolean;

  fetchDashboard: () => Promise<void>;
  updateLayout: (layout: WidgetLayout[]) => void;
  subscribeToWidgets: () => void;
  unsubscribeFromWidgets: () => void;
}

export const useDashboardStore = create<DashboardState>((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

// 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 (
    <div className="flex h-screen bg-gray-100">
      <Sidebar items={adminMenuItems} />
      <div className="flex-1 flex flex-col">
        <TopBar />
        <main className="flex-1 overflow-auto p-6">
          {children}
        </main>
      </div>
    </div>
  );
}

Paso 5: Crear Widget Personalizado

5.1 Template de Widget

// 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 <div className="animate-pulse bg-gray-200 h-full rounded" />;
  }

  return (
    <div>
      {/* Renderizar datos */}
      {JSON.stringify(data)}
    </div>
  );
}

// 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