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