{children} @@ -76,6 +78,8 @@ MainLayout.displayName = 'MainLayout'; MainLayout.defaultProps = { mockNotice: { isVisible: false }, + backendStatus: { status: 'unknown', source: 'unknown' }, + mockSummary: { domainsUsingMock: 0, totalDomains: 0, metrics: {} }, }; export default MainLayout; diff --git a/ui/src/hooks/useHealthStatus.js b/ui/src/hooks/useHealthStatus.js new file mode 100644 index 00000000..323cdbab --- /dev/null +++ b/ui/src/hooks/useHealthStatus.js @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + selectHealthStatus, + selectHealthSource, + selectLastChecked, + selectHealthError, + selectIsCheckingHealth, + setChecking, + setResult, + setError, +} from '@state/slices/healthSlice'; +import { HealthService } from '@services/health/HealthService'; + +export const useHealthStatus = () => { + const dispatch = useDispatch(); + const status = useSelector(selectHealthStatus); + const source = useSelector(selectHealthSource); + const lastChecked = useSelector(selectLastChecked); + const error = useSelector(selectHealthError); + const isChecking = useSelector(selectIsCheckingHealth); + + const checkHealth = useCallback(async () => { + try { + dispatch(setChecking(true)); + const result = await HealthService.getStatus(); + dispatch( + setResult({ + status: result?.data?.status ?? 'unknown', + checkedAt: result?.data?.checkedAt ?? null, + source: result?.source ?? 'unknown', + errorMessage: result?.error ? result.error.message : null, + }) + ); + } catch (err) { + dispatch(setError(err.message)); + } + }, [dispatch]); + + return { + status, + source, + lastChecked, + error, + isChecking, + checkHealth, + }; +}; diff --git a/ui/src/hooks/useHealthStatus.test.js b/ui/src/hooks/useHealthStatus.test.js new file mode 100644 index 00000000..6e162c41 --- /dev/null +++ b/ui/src/hooks/useHealthStatus.test.js @@ -0,0 +1,56 @@ +import { renderHook, act } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import healthReducer, { selectHealthStatus, selectHealthError } from '@state/slices/healthSlice'; +import { useHealthStatus } from './useHealthStatus'; +import { HealthService } from '@services/health/HealthService'; + +jest.mock('@services/health/HealthService', () => ({ + HealthService: { + getStatus: jest.fn(), + }, +})); + +const createStore = () => + configureStore({ + reducer: { + observability: healthReducer, + }, + }); + +const wrapperFactory = (store) => ({ children }) => {children}; + +describe('useHealthStatus', () => { + it('loads health status into the store', async () => { + const store = createStore(); + HealthService.getStatus.mockResolvedValue({ + data: { status: 'ok', checkedAt: '2025-11-14T12:00:00Z' }, + source: 'api', + error: null, + }); + + const { result } = renderHook(() => useHealthStatus(), { wrapper: wrapperFactory(store) }); + + await act(async () => { + await result.current.checkHealth(); + }); + + expect(selectHealthStatus(store.getState())).toBe('ok'); + expect(result.current.source).toBe('api'); + expect(result.current.lastChecked).toBe('2025-11-14T12:00:00Z'); + }); + + it('stores error when the service throws', async () => { + const store = createStore(); + HealthService.getStatus.mockRejectedValue(new Error('backend caido')); + + const { result } = renderHook(() => useHealthStatus(), { wrapper: wrapperFactory(store) }); + + await act(async () => { + await result.current.checkHealth(); + }); + + expect(selectHealthError(store.getState())).toBe('backend caido'); + expect(result.current.status).toBe('unknown'); + }); +}); diff --git a/ui/src/hooks/useMockMetrics.js b/ui/src/hooks/useMockMetrics.js new file mode 100644 index 00000000..3827f0e0 --- /dev/null +++ b/ui/src/hooks/useMockMetrics.js @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; +import { getMockUsageMetrics, subscribeMockUsage } from '@services/utils/mockUsageTracker'; + +const buildSummary = (metrics) => { + const entries = Object.values(metrics || {}); + const totalDomains = Object.keys(metrics || {}).length; + const domainsUsingMock = entries.filter((entry) => entry.mock > 0).length; + + return { totalDomains, domainsUsingMock }; +}; + +export const useMockMetrics = () => { + const [metrics, setMetrics] = useState(getMockUsageMetrics()); + + useEffect(() => { + const unsubscribe = subscribeMockUsage(setMetrics); + return unsubscribe; + }, []); + + return { metrics, summary: buildSummary(metrics) }; +}; diff --git a/ui/src/hooks/useMockMetrics.test.js b/ui/src/hooks/useMockMetrics.test.js new file mode 100644 index 00000000..67a970de --- /dev/null +++ b/ui/src/hooks/useMockMetrics.test.js @@ -0,0 +1,24 @@ +import { renderHook, act } from '@testing-library/react'; +import { useMockMetrics } from './useMockMetrics'; +import { recordMockUsage, resetMockUsageMetrics } from '@services/utils/mockUsageTracker'; + +describe('useMockMetrics', () => { + beforeEach(() => { + resetMockUsageMetrics(); + }); + + it('returns live mock dependency summary', () => { + const { result } = renderHook(() => useMockMetrics()); + + expect(result.current.summary.totalDomains).toBe(0); + + act(() => { + recordMockUsage('calls', 'mock'); + recordMockUsage('config', 'api'); + }); + + expect(result.current.summary.totalDomains).toBe(2); + expect(result.current.summary.domainsUsingMock).toBe(1); + expect(result.current.metrics.calls).toEqual({ api: 0, mock: 1 }); + }); +}); diff --git a/ui/src/mocks/configuracion.json b/ui/src/mocks/configuracion.json new file mode 100644 index 00000000..30de3281 --- /dev/null +++ b/ui/src/mocks/configuracion.json @@ -0,0 +1,60 @@ +{ + "parametros": [ + { + "id": 1, + "clave": "MAX_CALL_DURATION", + "valor": "3600", + "tipo": "integer", + "descripcion": "Duración máxima de llamada en segundos", + "categoria": "llamadas", + "modificable": true, + "fecha_modificacion": "2025-11-01T10:00:00Z", + "modificado_por": "admin" + }, + { + "id": 2, + "clave": "AUTO_DISCONNECT_TIMEOUT", + "valor": "30", + "tipo": "integer", + "descripcion": "Tiempo de desconexión automática en segundos", + "categoria": "llamadas", + "modificable": true, + "fecha_modificacion": "2025-10-15T14:30:00Z", + "modificado_por": "admin" + }, + { + "id": 3, + "clave": "RECORDING_ENABLED", + "valor": "true", + "tipo": "boolean", + "descripcion": "Habilitar grabación de llamadas", + "categoria": "seguridad", + "modificable": true, + "fecha_modificacion": "2025-09-20T09:00:00Z", + "modificado_por": "admin" + }, + { + "id": 4, + "clave": "MAX_CONCURRENT_CALLS", + "valor": "50", + "tipo": "integer", + "descripcion": "Número máximo de llamadas concurrentes", + "categoria": "capacidad", + "modificable": false, + "fecha_modificacion": "2025-08-01T00:00:00Z", + "modificado_por": "system" + } + ], + "categorias": ["llamadas", "seguridad", "capacidad", "reportes"], + "historial": [ + { + "id": 1, + "parametro_id": 1, + "valor_anterior": "3000", + "valor_nuevo": "3600", + "fecha": "2025-11-01T10:00:00Z", + "usuario": "admin", + "razon": "Ajuste por política interna" + } + ] +} diff --git a/ui/src/mocks/configuration.json b/ui/src/mocks/configuration.json new file mode 100644 index 00000000..4b90cfb4 --- /dev/null +++ b/ui/src/mocks/configuration.json @@ -0,0 +1,49 @@ +{ + "settings": [ + { + "id": 1, + "key": "system.timezone", + "value": "America/Mexico_City", + "type": "string", + "description": "Zona horaria del sistema", + "editable": true, + "updated_at": "2025-11-01T10:00:00Z", + "updated_by": "admin" + }, + { + "id": 2, + "key": "ui.theme", + "value": "light", + "type": "string", + "description": "Tema de la interfaz de usuario", + "editable": true, + "updated_at": "2025-10-20T15:30:00Z", + "updated_by": "admin" + }, + { + "id": 3, + "key": "notifications.email.enabled", + "value": "true", + "type": "boolean", + "description": "Habilitar notificaciones por correo", + "editable": true, + "updated_at": "2025-09-15T08:00:00Z", + "updated_by": "admin" + }, + { + "id": 4, + "key": "api.rate_limit", + "value": "100", + "type": "integer", + "description": "Límite de peticiones por minuto", + "editable": false, + "updated_at": "2025-01-01T00:00:00Z", + "updated_by": "system" + } + ], + "backup_info": { + "last_backup": "2025-11-17T02:00:00Z", + "backup_count": 30, + "next_backup": "2025-11-19T02:00:00Z" + } +} diff --git a/ui/src/mocks/dashboard.json b/ui/src/mocks/dashboard.json new file mode 100644 index 00000000..c0a3fca4 --- /dev/null +++ b/ui/src/mocks/dashboard.json @@ -0,0 +1,55 @@ +{ + "overview": { + "total_calls": 1250, + "calls_today": 85, + "active_agents": 12, + "avg_call_duration": 325, + "customer_satisfaction": 4.2 + }, + "widgets": [ + { + "id": 1, + "type": "chart", + "title": "Llamadas por Hora", + "position": { "x": 0, "y": 0, "w": 6, "h": 3 }, + "config": { + "chartType": "line", + "dataSource": "calls_per_hour" + } + }, + { + "id": 2, + "type": "metric", + "title": "Tiempo Promedio de Espera", + "position": { "x": 6, "y": 0, "w": 3, "h": 2 }, + "config": { + "value": 45, + "unit": "segundos", + "trend": "down" + } + }, + { + "id": 3, + "type": "list", + "title": "Agentes Disponibles", + "position": { "x": 9, "y": 0, "w": 3, "h": 4 }, + "config": { + "items": ["maria.garcia", "carlos.rodriguez", "juan.perez"] + } + } + ], + "recent_activity": [ + { + "id": 1, + "timestamp": "2025-11-18T12:45:00Z", + "type": "call_completed", + "description": "Llamada completada por maria.garcia" + }, + { + "id": 2, + "timestamp": "2025-11-18T12:40:00Z", + "type": "agent_login", + "description": "carlos.rodriguez inició sesión" + } + ] +} diff --git a/ui/src/mocks/dora.json b/ui/src/mocks/dora.json new file mode 100644 index 00000000..eded4c09 --- /dev/null +++ b/ui/src/mocks/dora.json @@ -0,0 +1,129 @@ +{ + "metrics": { + "deployment_frequency": { + "value": 8.5, + "unit": "deploys_per_week", + "trend": "up", + "change_percent": 12.5, + "level": "high", + "description": "Frecuencia de despliegues a producción", + "last_updated": "2025-11-18T12:00:00Z" + }, + "lead_time_for_changes": { + "value": 4.2, + "unit": "hours", + "trend": "down", + "change_percent": -8.3, + "level": "elite", + "description": "Tiempo desde commit hasta producción", + "last_updated": "2025-11-18T12:00:00Z" + }, + "time_to_restore_service": { + "value": 35, + "unit": "minutes", + "trend": "down", + "change_percent": -15.2, + "level": "high", + "description": "Tiempo de restauración de servicio", + "last_updated": "2025-11-18T12:00:00Z" + }, + "change_failure_rate": { + "value": 8.5, + "unit": "percent", + "trend": "stable", + "change_percent": 0.2, + "level": "high", + "description": "Tasa de fallos en cambios", + "last_updated": "2025-11-18T12:00:00Z" + } + }, + "historical_data": [ + { + "date": "2025-11-11", + "deployment_frequency": 7.5, + "lead_time_for_changes": 4.8, + "time_to_restore_service": 42, + "change_failure_rate": 9.1 + }, + { + "date": "2025-11-04", + "deployment_frequency": 7.8, + "lead_time_for_changes": 5.2, + "time_to_restore_service": 38, + "change_failure_rate": 8.7 + }, + { + "date": "2025-10-28", + "deployment_frequency": 7.2, + "lead_time_for_changes": 4.5, + "time_to_restore_service": 40, + "change_failure_rate": 8.3 + } + ], + "recent_deployments": [ + { + "id": 1, + "version": "v1.15.3", + "environment": "production", + "deployed_at": "2025-11-18T10:30:00Z", + "deployed_by": "ci-cd-pipeline", + "status": "success", + "duration_minutes": 8, + "changes_count": 12 + }, + { + "id": 2, + "version": "v1.15.2", + "environment": "production", + "deployed_at": "2025-11-17T15:45:00Z", + "deployed_by": "ci-cd-pipeline", + "status": "success", + "duration_minutes": 7, + "changes_count": 5 + }, + { + "id": 3, + "version": "v1.15.1", + "environment": "production", + "deployed_at": "2025-11-16T09:20:00Z", + "deployed_by": "ci-cd-pipeline", + "status": "failed", + "duration_minutes": 3, + "changes_count": 8, + "rollback_duration_minutes": 5 + } + ], + "performance_levels": { + "deployment_frequency": { + "elite": ">= 7 per week", + "high": "1-7 per week", + "medium": "1-4 per month", + "low": "< 1 per month" + }, + "lead_time": { + "elite": "< 1 hour", + "high": "1-24 hours", + "medium": "1-7 days", + "low": "> 7 days" + }, + "mttr": { + "elite": "< 1 hour", + "high": "1-24 hours", + "medium": "1-7 days", + "low": "> 7 days" + }, + "change_failure_rate": { + "elite": "< 5%", + "high": "5-15%", + "medium": "15-30%", + "low": "> 30%" + } + }, + "team_summary": { + "total_developers": 8, + "active_repositories": 5, + "total_commits_last_week": 127, + "pull_requests_merged": 23, + "code_review_time_avg_hours": 2.5 + } +} diff --git a/ui/src/mocks/etl.json b/ui/src/mocks/etl.json new file mode 100644 index 00000000..ed9994b1 --- /dev/null +++ b/ui/src/mocks/etl.json @@ -0,0 +1,146 @@ +{ + "jobs": [ + { + "id": 1, + "name": "sync_calls_daily", + "description": "Sincronización diaria de llamadas", + "type": "import", + "source": "call_center_db", + "target": "analytics_db", + "status": "success", + "started_at": "2025-11-18T02:00:00Z", + "completed_at": "2025-11-18T02:15:30Z", + "duration_seconds": 930, + "records_processed": 1250, + "records_success": 1248, + "records_failed": 2, + "last_run": "2025-11-18T02:00:00Z" + }, + { + "id": 2, + "name": "sync_users_hourly", + "description": "Sincronización horaria de usuarios", + "type": "import", + "source": "user_management_db", + "target": "analytics_db", + "status": "success", + "started_at": "2025-11-18T11:00:00Z", + "completed_at": "2025-11-18T11:02:15Z", + "duration_seconds": 135, + "records_processed": 45, + "records_success": 45, + "records_failed": 0, + "last_run": "2025-11-18T11:00:00Z" + }, + { + "id": 3, + "name": "export_reports_weekly", + "description": "Exportación semanal de reportes", + "type": "export", + "source": "analytics_db", + "target": "data_warehouse", + "status": "running", + "started_at": "2025-11-18T12:00:00Z", + "completed_at": null, + "duration_seconds": null, + "records_processed": 520, + "records_success": 520, + "records_failed": 0, + "last_run": "2025-11-11T12:00:00Z" + }, + { + "id": 4, + "name": "sync_policies_daily", + "description": "Sincronización diaria de políticas", + "type": "import", + "source": "compliance_db", + "target": "analytics_db", + "status": "failed", + "started_at": "2025-11-18T03:00:00Z", + "completed_at": "2025-11-18T03:05:45Z", + "duration_seconds": 345, + "records_processed": 150, + "records_success": 100, + "records_failed": 50, + "last_run": "2025-11-18T03:00:00Z" + }, + { + "id": 5, + "name": "cleanup_old_logs", + "description": "Limpieza de logs antiguos", + "type": "maintenance", + "source": "all_dbs", + "target": "archive", + "status": "success", + "started_at": "2025-11-18T01:00:00Z", + "completed_at": "2025-11-18T01:30:00Z", + "duration_seconds": 1800, + "records_processed": 50000, + "records_success": 50000, + "records_failed": 0, + "last_run": "2025-11-18T01:00:00Z" + } + ], + "errors": [ + { + "id": 1, + "job_id": 4, + "job_name": "sync_policies_daily", + "error_type": "connection_error", + "severity": "high", + "message": "Connection timeout to compliance_db", + "details": "Database server at compliance_db:5432 did not respond within 30 seconds", + "occurred_at": "2025-11-18T03:02:30Z", + "retry_count": 3, + "resolved": false + }, + { + "id": 2, + "job_id": 4, + "job_name": "sync_policies_daily", + "error_type": "data_validation_error", + "severity": "medium", + "message": "Invalid policy format", + "details": "50 records failed schema validation: missing required field 'vigencia_inicio'", + "occurred_at": "2025-11-18T03:04:15Z", + "retry_count": 0, + "resolved": false + }, + { + "id": 3, + "job_id": 1, + "job_name": "sync_calls_daily", + "error_type": "duplicate_key_error", + "severity": "low", + "message": "Duplicate record detected", + "details": "2 records with duplicate call_id were skipped", + "occurred_at": "2025-11-18T02:12:00Z", + "retry_count": 0, + "resolved": true + }, + { + "id": 4, + "job_id": 2, + "job_name": "sync_users_hourly", + "error_type": "permission_error", + "severity": "critical", + "message": "Access denied to source database", + "details": "Credentials for user 'etl_service' have expired", + "occurred_at": "2025-11-17T22:00:00Z", + "retry_count": 5, + "resolved": true + } + ], + "stats": { + "total_jobs": 5, + "jobs_running": 1, + "jobs_success": 3, + "jobs_failed": 1, + "total_errors": 4, + "errors_unresolved": 2, + "last_update": "2025-11-18T12:30:00Z" + }, + "job_types": ["import", "export", "transform", "maintenance"], + "job_statuses": ["pending", "running", "success", "failed", "cancelled"], + "error_severities": ["low", "medium", "high", "critical"] +} diff --git a/ui/src/mocks/excepciones.json b/ui/src/mocks/excepciones.json new file mode 100644 index 00000000..c8509ab1 --- /dev/null +++ b/ui/src/mocks/excepciones.json @@ -0,0 +1,93 @@ +{ + "excepciones": [ + { + "id": 1, + "codigo": "EXC-2025-001", + "titulo": "Excepción de Descuento Especial", + "descripcion": "Cliente VIP solicita descuento adicional fuera de política", + "tipo": "descuento", + "monto": 5000.00, + "moneda": "MXN", + "cliente_id": "CLI-12345", + "cliente_nombre": "Juan Pérez Empresarial S.A.", + "solicitante": "maria.garcia", + "fecha_solicitud": "2025-11-15T10:00:00Z", + "aprobador": "supervisor", + "fecha_aprobacion": "2025-11-15T14:30:00Z", + "estado": "aprobada", + "justificacion": "Cliente con historial de pagos excelente y alto volumen de operaciones", + "politica_relacionada": "POL-005", + "vigencia_inicio": "2025-11-15", + "vigencia_fin": "2025-12-31" + }, + { + "id": 2, + "codigo": "EXC-2025-002", + "titulo": "Excepción de Plazo de Pago", + "descripcion": "Extensión de plazo de pago por situación extraordinaria", + "tipo": "plazo", + "monto": 0.00, + "moneda": "MXN", + "cliente_id": "CLI-67890", + "cliente_nombre": "María López", + "solicitante": "carlos.rodriguez", + "fecha_solicitud": "2025-11-16T09:00:00Z", + "aprobador": null, + "fecha_aprobacion": null, + "estado": "pendiente", + "justificacion": "Cliente afectado por situación de emergencia familiar", + "politica_relacionada": "POL-006", + "vigencia_inicio": null, + "vigencia_fin": null + }, + { + "id": 3, + "codigo": "EXC-2025-003", + "titulo": "Excepción de Requisitos", + "descripcion": "Omisión de documentación por caso especial", + "tipo": "requisitos", + "monto": 0.00, + "moneda": "MXN", + "cliente_id": "CLI-11111", + "cliente_nombre": "Empresa Tech Solutions", + "solicitante": "ana.martinez", + "fecha_solicitud": "2025-11-10T11:00:00Z", + "aprobador": "director", + "fecha_aprobacion": "2025-11-12T16:00:00Z", + "estado": "rechazada", + "justificacion": "Documentación incompleta y sin justificación válida", + "politica_relacionada": "POL-007", + "vigencia_inicio": null, + "vigencia_fin": null + }, + { + "id": 4, + "codigo": "EXC-2025-004", + "titulo": "Excepción de Horario", + "descripcion": "Atención fuera de horario estándar", + "tipo": "horario", + "monto": 0.00, + "moneda": "MXN", + "cliente_id": "CLI-22222", + "cliente_nombre": "Global Services Inc", + "solicitante": "juan.perez", + "fecha_solicitud": "2025-11-17T15:00:00Z", + "aprobador": "supervisor", + "fecha_aprobacion": "2025-11-17T16:00:00Z", + "estado": "aprobada", + "justificacion": "Cliente internacional con diferencia horaria significativa", + "politica_relacionada": "POL-008", + "vigencia_inicio": "2025-11-18", + "vigencia_fin": "2026-11-18" + } + ], + "estados": ["pendiente", "aprobada", "rechazada", "vencida"], + "tipos": ["descuento", "plazo", "requisitos", "horario", "limite_credito", "otro"], + "estadisticas": { + "total_excepciones": 4, + "aprobadas": 2, + "rechazadas": 1, + "pendientes": 1, + "monto_total_aprobado": 5000.00 + } +} diff --git a/ui/src/mocks/health.json b/ui/src/mocks/health.json new file mode 100644 index 00000000..b14210df --- /dev/null +++ b/ui/src/mocks/health.json @@ -0,0 +1,8 @@ +{ + "status": "degraded", + "checkedAt": "2025-11-14T00:00:00Z", + "services": [ + { "name": "callcentersite", "status": "down" } + ], + "details": "Backend no disponible, mostrando mocks locales" +} diff --git a/ui/src/mocks/metadata.js b/ui/src/mocks/metadata.js index a6a4d360..b4aad159 100644 --- a/ui/src/mocks/metadata.js +++ b/ui/src/mocks/metadata.js @@ -17,4 +17,76 @@ export const MOCK_METADATA = { lastUpdated: '2025-11-09', description: 'Escenario de llamadas y catalogos para pruebas funcionales', }, + health: { + id: 'health', + source: 'manual: estrategia_integracion_backend', + lastUpdated: '2025-11-14', + description: 'Health check simulado para habilitar degradacion controlada en UI', + }, + users: { + id: 'users', + source: 'manual: gestion_usuarios', + lastUpdated: '2025-11-18', + description: 'Usuarios y grupos del sistema para administracion de accesos', + }, + dashboard: { + id: 'dashboard', + source: 'manual: visualizacion_metricas', + lastUpdated: '2025-11-18', + description: 'Dashboard con widgets y metricas operativas del call center', + }, + configuracion: { + id: 'configuracion', + source: 'manual: parametros_sistema', + lastUpdated: '2025-11-18', + description: 'Parametros de configuracion del sistema legacy', + }, + configuration: { + id: 'configuration', + source: 'manual: settings_sistema', + lastUpdated: '2025-11-18', + description: 'Settings de configuracion del sistema moderno', + }, + presupuestos: { + id: 'presupuestos', + source: 'manual: gestion_financiera', + lastUpdated: '2025-11-18', + description: 'Presupuestos y control de gastos del call center', + }, + politicas: { + id: 'politicas', + source: 'manual: cumplimiento_normativo', + lastUpdated: '2025-11-18', + description: 'Politicas y procedimientos del call center', + }, + excepciones: { + id: 'excepciones', + source: 'manual: gestion_excepciones', + lastUpdated: '2025-11-18', + description: 'Excepciones y casos especiales que requieren aprobacion', + }, + reportes: { + id: 'reportes', + source: 'manual: analytics_reportes', + lastUpdated: '2025-11-18', + description: 'Reportes de IVR y metricas operativas del call center', + }, + notifications: { + id: 'notifications', + source: 'manual: sistema_notificaciones', + lastUpdated: '2025-11-18', + description: 'Notificaciones y mensajes del sistema', + }, + etl: { + id: 'etl', + source: 'manual: procesos_etl', + lastUpdated: '2025-11-18', + description: 'Jobs y errores de procesos ETL', + }, + dora: { + id: 'dora', + source: 'manual: metricas_devops', + lastUpdated: '2025-11-18', + description: 'Metricas DORA para seguimiento de entrega de software', + }, }; diff --git a/ui/src/mocks/notifications.json b/ui/src/mocks/notifications.json new file mode 100644 index 00000000..41f2a2d1 --- /dev/null +++ b/ui/src/mocks/notifications.json @@ -0,0 +1,80 @@ +{ + "messages": [ + { + "id": 1, + "title": "Nueva política publicada", + "body": "Se ha publicado la política POL-002: Política de Privacidad de Datos", + "type": "info", + "priority": "high", + "read": false, + "created_at": "2025-11-18T10:30:00Z", + "related_object_type": "policy", + "related_object_id": 2, + "action_url": "/politicas/2" + }, + { + "id": 2, + "title": "Presupuesto aprobado", + "body": "El presupuesto PRES-2025-001 ha sido aprobado", + "type": "success", + "priority": "medium", + "read": true, + "created_at": "2025-11-17T14:20:00Z", + "related_object_type": "budget", + "related_object_id": 1, + "action_url": "/presupuestos/1" + }, + { + "id": 3, + "title": "Excepción pendiente de aprobación", + "body": "La excepción EXC-2025-002 requiere su aprobación", + "type": "warning", + "priority": "high", + "read": false, + "created_at": "2025-11-17T09:15:00Z", + "related_object_type": "exception", + "related_object_id": 2, + "action_url": "/excepciones/2" + }, + { + "id": 4, + "title": "Error en proceso ETL", + "body": "Fallo en la sincronización de datos de llamadas", + "type": "error", + "priority": "critical", + "read": false, + "created_at": "2025-11-18T02:00:00Z", + "related_object_type": "etl_job", + "related_object_id": 15, + "action_url": "/etl/jobs/15" + }, + { + "id": 5, + "title": "Nuevo reporte disponible", + "body": "El reporte de navegación IVR de noviembre está listo", + "type": "info", + "priority": "low", + "read": true, + "created_at": "2025-11-18T08:05:00Z", + "related_object_type": "report", + "related_object_id": 1, + "action_url": "/reportes/1" + }, + { + "id": 6, + "title": "Usuario suspendido", + "body": "El usuario ana.martinez ha sido suspendido por inactividad", + "type": "warning", + "priority": "medium", + "read": true, + "created_at": "2025-11-16T16:00:00Z", + "related_object_type": "user", + "related_object_id": 4, + "action_url": "/usuarios/4" + } + ], + "unread_count": 3, + "total_count": 6, + "types": ["info", "success", "warning", "error"], + "priorities": ["low", "medium", "high", "critical"] +} diff --git a/ui/src/mocks/permissions.json b/ui/src/mocks/permissions.json index 634c5803..6cbab9a7 100644 --- a/ui/src/mocks/permissions.json +++ b/ui/src/mocks/permissions.json @@ -7,12 +7,14 @@ { "id": 1, "codigo": "atencion_cliente", - "nombre_display": "Atencion al Cliente" + "nombre_display": "Atencion al Cliente", + "descripcion": "Atiende consultas y gestiona interacciones con clientes" }, { "id": 2, "codigo": "visualizacion_metricas", - "nombre_display": "Visualizacion de Metricas" + "nombre_display": "Visualizacion de Metricas", + "descripcion": "Accede a reportes y analisis de desempeno" } ] }, @@ -32,7 +34,6 @@ "nombre": "dashboards", "nombre_completo": "sistema.vistas.dashboards", "dominio": "vistas", - "icono": "dashboard", "orden_menu": 10 }, { @@ -40,7 +41,6 @@ "nombre": "llamadas", "nombre_completo": "sistema.operaciones.llamadas", "dominio": "operaciones", - "icono": "phone", "orden_menu": 20 }, { @@ -48,7 +48,6 @@ "nombre": "tickets", "nombre_completo": "sistema.operaciones.tickets", "dominio": "operaciones", - "icono": "ticket", "orden_menu": 30 }, { @@ -56,7 +55,6 @@ "nombre": "clientes", "nombre_completo": "sistema.operaciones.clientes", "dominio": "operaciones", - "icono": "people", "orden_menu": 40 }, { @@ -64,8 +62,13 @@ "nombre": "metricas", "nombre_completo": "sistema.analisis.metricas", "dominio": "analisis", - "icono": "chart", "orden_menu": 60 } - ] + ], + "permisos_excepcionales": [], + "auditoria_ultimo_acceso": { + "timestamp": "2025-11-18T12:30:00Z", + "capacidad": "sistema.vistas.dashboards.ver", + "resultado": "permitido" + } } diff --git a/ui/src/mocks/politicas.json b/ui/src/mocks/politicas.json new file mode 100644 index 00000000..7008731a --- /dev/null +++ b/ui/src/mocks/politicas.json @@ -0,0 +1,74 @@ +{ + "politicas": [ + { + "id": 1, + "codigo": "POL-001", + "titulo": "Política de Atención al Cliente", + "descripcion": "Lineamientos para la atención de llamadas entrantes", + "contenido": "1. Saludar cordialmente\n2. Identificar al cliente\n3. Escuchar activamente\n4. Resolver en primera llamada\n5. Confirmar satisfacción", + "version": 3, + "estado": "publicada", + "categoria": "atencion_cliente", + "vigencia_inicio": "2025-01-01", + "vigencia_fin": null, + "creado_por": "admin", + "fecha_creacion": "2024-11-15T10:00:00Z", + "publicado_por": "admin", + "fecha_publicacion": "2025-01-01T00:00:00Z", + "aprobadores": ["supervisor", "director"] + }, + { + "id": 2, + "codigo": "POL-002", + "titulo": "Política de Privacidad de Datos", + "descripcion": "Manejo y protección de información del cliente", + "contenido": "Todos los datos personales deben ser protegidos según la LFPDPPP...", + "version": 2, + "estado": "publicada", + "categoria": "seguridad", + "vigencia_inicio": "2025-02-01", + "vigencia_fin": null, + "creado_por": "legal", + "fecha_creacion": "2025-01-10T09:00:00Z", + "publicado_por": "director", + "fecha_publicacion": "2025-02-01T00:00:00Z", + "aprobadores": ["legal", "ti", "director"] + }, + { + "id": 3, + "codigo": "POL-003", + "titulo": "Política de Escalamiento de Casos", + "descripcion": "Procedimiento para escalar casos complejos", + "contenido": "Niveles de escalamiento:\nNivel 1: Agente\nNivel 2: Supervisor\nNivel 3: Manager", + "version": 1, + "estado": "borrador", + "categoria": "operaciones", + "vigencia_inicio": null, + "vigencia_fin": null, + "creado_por": "supervisor", + "fecha_creacion": "2025-11-01T14:00:00Z", + "publicado_por": null, + "fecha_publicacion": null, + "aprobadores": [] + }, + { + "id": 4, + "codigo": "POL-001", + "titulo": "Política de Atención al Cliente", + "descripcion": "Lineamientos para la atención de llamadas entrantes", + "contenido": "Version anterior archivada", + "version": 2, + "estado": "archivada", + "categoria": "atencion_cliente", + "vigencia_inicio": "2024-06-01", + "vigencia_fin": "2024-12-31", + "creado_por": "admin", + "fecha_creacion": "2024-05-01T10:00:00Z", + "publicado_por": "admin", + "fecha_publicacion": "2024-06-01T00:00:00Z", + "aprobadores": ["supervisor"] + } + ], + "estados": ["borrador", "en_revision", "publicada", "archivada"], + "categorias": ["atencion_cliente", "seguridad", "operaciones", "cumplimiento", "recursos_humanos"] +} diff --git a/ui/src/mocks/presupuestos.json b/ui/src/mocks/presupuestos.json new file mode 100644 index 00000000..035a7fbf --- /dev/null +++ b/ui/src/mocks/presupuestos.json @@ -0,0 +1,113 @@ +{ + "presupuestos": [ + { + "id": 1, + "codigo": "PRES-2025-001", + "descripcion": "Presupuesto de Call Center Q1 2025", + "monto_total": 250000.00, + "moneda": "MXN", + "periodo_inicio": "2025-01-01", + "periodo_fin": "2025-03-31", + "estado": "aprobado", + "creado_por": "admin", + "fecha_creacion": "2024-12-01T10:00:00Z", + "aprobado_por": "director", + "fecha_aprobacion": "2024-12-15T14:30:00Z", + "lineas": [ + { + "id": 1, + "concepto": "Nómina Agentes", + "monto": 150000.00, + "porcentaje": 60.0 + }, + { + "id": 2, + "concepto": "Tecnología y Software", + "monto": 50000.00, + "porcentaje": 20.0 + }, + { + "id": 3, + "concepto": "Infraestructura", + "monto": 30000.00, + "porcentaje": 12.0 + }, + { + "id": 4, + "concepto": "Capacitación", + "monto": 20000.00, + "porcentaje": 8.0 + } + ] + }, + { + "id": 2, + "codigo": "PRES-2025-002", + "descripcion": "Presupuesto de Call Center Q2 2025", + "monto_total": 280000.00, + "moneda": "MXN", + "periodo_inicio": "2025-04-01", + "periodo_fin": "2025-06-30", + "estado": "pendiente", + "creado_por": "admin", + "fecha_creacion": "2025-02-15T09:00:00Z", + "aprobado_por": null, + "fecha_aprobacion": null, + "lineas": [ + { + "id": 5, + "concepto": "Nómina Agentes", + "monto": 170000.00, + "porcentaje": 60.7 + }, + { + "id": 6, + "concepto": "Tecnología y Software", + "monto": 60000.00, + "porcentaje": 21.4 + }, + { + "id": 7, + "concepto": "Infraestructura", + "monto": 30000.00, + "porcentaje": 10.7 + }, + { + "id": 8, + "concepto": "Capacitación", + "monto": 20000.00, + "porcentaje": 7.1 + } + ] + }, + { + "id": 3, + "codigo": "PRES-2025-003", + "descripcion": "Presupuesto Extraordinario - Expansión", + "monto_total": 100000.00, + "moneda": "MXN", + "periodo_inicio": "2025-05-01", + "periodo_fin": "2025-05-31", + "estado": "rechazado", + "creado_por": "supervisor", + "fecha_creacion": "2025-04-01T11:00:00Z", + "aprobado_por": "director", + "fecha_aprobacion": "2025-04-10T16:00:00Z", + "lineas": [ + { + "id": 9, + "concepto": "Contratación Nueva Sede", + "monto": 100000.00, + "porcentaje": 100.0 + } + ] + } + ], + "estados": ["pendiente", "aprobado", "rechazado", "en_revision"], + "estadisticas": { + "total_presupuestos": 3, + "monto_total_aprobado": 250000.00, + "monto_total_pendiente": 280000.00, + "monto_total_rechazado": 100000.00 + } +} diff --git a/ui/src/mocks/registry.js b/ui/src/mocks/registry.js index 15a94b98..4f36506e 100644 --- a/ui/src/mocks/registry.js +++ b/ui/src/mocks/registry.js @@ -1,19 +1,71 @@ import configMock from './config.json'; import permissionsMock from './permissions.json'; import callsMock from './llamadas.json'; +import healthMock from './health.json'; +import usuariosMock from './usuarios.json'; +import dashboardMock from './dashboard.json'; +import configuracionMock from './configuracion.json'; +import configurationMock from './configuration.json'; +import presupuestosMock from './presupuestos.json'; +import politicasMock from './politicas.json'; +import excepcionesMock from './excepciones.json'; +import reportesMock from './reportes.json'; +import notificationsMock from './notifications.json'; +import etlMock from './etl.json'; +import doraMock from './dora.json'; import { MOCK_METADATA } from './metadata'; -import { validateConfigMock, validatePermissionsMock, validateCallsMock } from './schemas'; +import { + validateConfigMock, + validatePermissionsMock, + validateCallsMock, + validateHealthMock, + validateUsersMock, + validateDashboardMock, + validateConfiguracionMock, + validateConfigurationMock, + validatePresupuestosMock, + validatePoliticasMock, + validateExcepcionesMock, + validateReportesMock, + validateNotificationsMock, + validateETLMock, + validateDORAMock, +} from './schemas'; const DATA_BY_KEY = { config: configMock, permissions: permissionsMock, calls: callsMock, + health: healthMock, + users: usuariosMock, + dashboard: dashboardMock, + configuracion: configuracionMock, + configuration: configurationMock, + presupuestos: presupuestosMock, + politicas: politicasMock, + excepciones: excepcionesMock, + reportes: reportesMock, + notifications: notificationsMock, + etl: etlMock, + dora: doraMock, }; const VALIDATORS = { config: validateConfigMock, permissions: validatePermissionsMock, calls: validateCallsMock, + health: validateHealthMock, + users: validateUsersMock, + dashboard: validateDashboardMock, + configuracion: validateConfiguracionMock, + configuration: validateConfigurationMock, + presupuestos: validatePresupuestosMock, + politicas: validatePoliticasMock, + excepciones: validateExcepcionesMock, + reportes: validateReportesMock, + notifications: validateNotificationsMock, + etl: validateETLMock, + dora: validateDORAMock, }; export const validateMock = (key, data) => { diff --git a/ui/src/mocks/reportes.json b/ui/src/mocks/reportes.json new file mode 100644 index 00000000..bed4f460 --- /dev/null +++ b/ui/src/mocks/reportes.json @@ -0,0 +1,103 @@ +{ + "reportes": [ + { + "id": 1, + "tipo": "ivr_navigation", + "nombre": "Reporte de Navegación IVR - Noviembre 2025", + "descripcion": "Análisis de navegación de usuarios en el sistema IVR", + "fecha_generacion": "2025-11-18T08:00:00Z", + "generado_por": "admin", + "periodo_inicio": "2025-11-01T00:00:00Z", + "periodo_fin": "2025-11-17T23:59:59Z", + "formato": "json", + "estado": "completado", + "datos": { + "total_interacciones": 5420, + "opciones_mas_usadas": [ + { "opcion": "1", "descripcion": "Consulta de Saldo", "cantidad": 2150 }, + { "opcion": "2", "descripcion": "Transferencia", "cantidad": 1800 }, + { "opcion": "3", "descripcion": "Hablar con Agente", "cantidad": 1470 } + ], + "tiempo_promedio_navegacion": 45.3, + "tasa_abandono": 8.5 + } + }, + { + "id": 2, + "tipo": "call_volume", + "nombre": "Reporte de Volumen de Llamadas - Semanal", + "descripcion": "Análisis semanal del volumen de llamadas", + "fecha_generacion": "2025-11-18T07:00:00Z", + "generado_por": "supervisor", + "periodo_inicio": "2025-11-11T00:00:00Z", + "periodo_fin": "2025-11-17T23:59:59Z", + "formato": "pdf", + "estado": "completado", + "datos": { + "total_llamadas": 1250, + "promedio_diario": 178.6, + "pico_maximo": { "fecha": "2025-11-15", "cantidad": 245 }, + "distribucion_horaria": { + "08-12": 450, + "12-16": 520, + "16-20": 280 + } + } + }, + { + "id": 3, + "tipo": "agent_performance", + "nombre": "Reporte de Desempeño de Agentes - Q4 2025", + "descripcion": "Evaluación trimestral del desempeño de agentes", + "fecha_generacion": "2025-11-18T06:00:00Z", + "generado_por": "manager", + "periodo_inicio": "2025-10-01T00:00:00Z", + "periodo_fin": "2025-11-17T23:59:59Z", + "formato": "excel", + "estado": "en_proceso", + "datos": { + "total_agentes": 15, + "promedio_satisfaccion": 4.2, + "promedio_tiempo_llamada": 325.4, + "top_performers": [ + { "agente": "maria.garcia", "score": 4.8 }, + { "agente": "carlos.rodriguez", "score": 4.6 } + ] + } + }, + { + "id": 4, + "tipo": "customer_satisfaction", + "nombre": "Reporte de Satisfacción del Cliente - Mensual", + "descripcion": "Encuestas de satisfacción post-llamada", + "fecha_generacion": "2025-11-01T09:00:00Z", + "generado_por": "quality", + "periodo_inicio": "2025-10-01T00:00:00Z", + "periodo_fin": "2025-10-31T23:59:59Z", + "formato": "json", + "estado": "completado", + "datos": { + "total_encuestas": 850, + "tasa_respuesta": 68.0, + "promedio_calificacion": 4.3, + "distribucion": { + "5_estrellas": 420, + "4_estrellas": 280, + "3_estrellas": 100, + "2_estrellas": 35, + "1_estrella": 15 + } + } + } + ], + "tipos_disponibles": [ + "ivr_navigation", + "call_volume", + "agent_performance", + "customer_satisfaction", + "quality_assurance", + "operational_metrics" + ], + "formatos_exportacion": ["json", "pdf", "excel", "csv"], + "estados": ["en_proceso", "completado", "fallido"] +} diff --git a/ui/src/mocks/schemas.js b/ui/src/mocks/schemas.js index 59437c84..c8e8f376 100644 --- a/ui/src/mocks/schemas.js +++ b/ui/src/mocks/schemas.js @@ -106,8 +106,107 @@ const validateCallsMock = (data) => { return data; }; +const validateHealthMock = (data) => { + assert(isObject(data), 'health debe ser objeto'); + assert(typeof data.status === 'string', 'health.status debe ser string'); + assert(typeof data.checkedAt === 'string', 'health.checkedAt debe ser string'); + + if (data.services) { + assert(Array.isArray(data.services), 'health.services debe ser arreglo cuando existe'); + data.services.forEach((svc) => { + assert(isObject(svc), 'health.services[x] debe ser objeto'); + assert(typeof svc.name === 'string', 'health.services[x].name debe ser string'); + assert(typeof svc.status === 'string', 'health.services[x].status debe ser string'); + }); + } + + return data; +}; + +const validateUsersMock = (data) => { + assert(isObject(data), 'users debe ser objeto'); + assert(Array.isArray(data.usuarios), 'users.usuarios debe ser arreglo'); + assert(Array.isArray(data.grupos), 'users.grupos debe ser arreglo'); + return data; +}; + +const validateDashboardMock = (data) => { + assert(isObject(data), 'dashboard debe ser objeto'); + assert(isObject(data.overview), 'dashboard.overview debe ser objeto'); + assert(Array.isArray(data.widgets), 'dashboard.widgets debe ser arreglo'); + return data; +}; + +const validateConfiguracionMock = (data) => { + assert(isObject(data), 'configuracion debe ser objeto'); + assert(Array.isArray(data.parametros), 'configuracion.parametros debe ser arreglo'); + return data; +}; + +const validateConfigurationMock = (data) => { + assert(isObject(data), 'configuration debe ser objeto'); + assert(Array.isArray(data.settings), 'configuration.settings debe ser arreglo'); + return data; +}; + +const validatePresupuestosMock = (data) => { + assert(isObject(data), 'presupuestos debe ser objeto'); + assert(Array.isArray(data.presupuestos), 'presupuestos.presupuestos debe ser arreglo'); + return data; +}; + +const validatePoliticasMock = (data) => { + assert(isObject(data), 'politicas debe ser objeto'); + assert(Array.isArray(data.politicas), 'politicas.politicas debe ser arreglo'); + return data; +}; + +const validateExcepcionesMock = (data) => { + assert(isObject(data), 'excepciones debe ser objeto'); + assert(Array.isArray(data.excepciones), 'excepciones.excepciones debe ser arreglo'); + return data; +}; + +const validateReportesMock = (data) => { + assert(isObject(data), 'reportes debe ser objeto'); + assert(Array.isArray(data.reportes), 'reportes.reportes debe ser arreglo'); + return data; +}; + +const validateNotificationsMock = (data) => { + assert(isObject(data), 'notifications debe ser objeto'); + assert(Array.isArray(data.messages), 'notifications.messages debe ser arreglo'); + assert(typeof data.unread_count === 'number', 'notifications.unread_count debe ser numero'); + return data; +}; + +const validateETLMock = (data) => { + assert(isObject(data), 'etl debe ser objeto'); + assert(Array.isArray(data.jobs), 'etl.jobs debe ser arreglo'); + assert(Array.isArray(data.errors), 'etl.errors debe ser arreglo'); + return data; +}; + +const validateDORAMock = (data) => { + assert(isObject(data), 'dora debe ser objeto'); + assert(isObject(data.metrics), 'dora.metrics debe ser objeto'); + return data; +}; + module.exports = { validateConfigMock, validatePermissionsMock, validateCallsMock, + validateHealthMock, + validateUsersMock, + validateDashboardMock, + validateConfiguracionMock, + validateConfigurationMock, + validatePresupuestosMock, + validatePoliticasMock, + validateExcepcionesMock, + validateReportesMock, + validateNotificationsMock, + validateETLMock, + validateDORAMock, }; diff --git a/ui/src/mocks/usuarios.json b/ui/src/mocks/usuarios.json new file mode 100644 index 00000000..f4c2805d --- /dev/null +++ b/ui/src/mocks/usuarios.json @@ -0,0 +1,216 @@ +{ + "usuarios": [ + { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "first_name": "Administrador", + "last_name": "Sistema", + "is_active": true, + "is_staff": true, + "is_superuser": false, + "date_joined": "2025-01-01T00:00:00Z", + "last_login": "2025-11-18T10:00:00Z", + "grupos": [ + { + "id": 1, + "codigo": "gestion_sistema", + "nombre_display": "Gestion de Sistema", + "descripcion": "Configura parametros tecnicos y administra usuarios" + }, + { + "id": 5, + "codigo": "supervision_operaciones", + "nombre_display": "Supervision de Operaciones", + "descripcion": "Monitorea metricas y gestiona equipos de trabajo" + } + ] + }, + { + "id": 2, + "username": "maria.garcia", + "email": "maria.garcia@example.com", + "first_name": "Maria", + "last_name": "Garcia", + "is_active": true, + "is_staff": false, + "is_superuser": false, + "date_joined": "2025-02-15T00:00:00Z", + "last_login": "2025-11-18T09:30:00Z", + "grupos": [ + { + "id": 2, + "codigo": "atencion_cliente", + "nombre_display": "Atencion al Cliente", + "descripcion": "Atiende consultas y gestiona interacciones con clientes" + } + ] + }, + { + "id": 3, + "username": "carlos.rodriguez", + "email": "carlos.rodriguez@example.com", + "first_name": "Carlos", + "last_name": "Rodriguez", + "is_active": true, + "is_staff": false, + "is_superuser": false, + "date_joined": "2025-03-10T00:00:00Z", + "last_login": "2025-11-18T08:45:00Z", + "grupos": [ + { + "id": 2, + "codigo": "atencion_cliente", + "nombre_display": "Atencion al Cliente", + "descripcion": "Atiende consultas y gestiona interacciones con clientes" + }, + { + "id": 3, + "codigo": "gestion_tickets", + "nombre_display": "Gestion de Tickets", + "descripcion": "Crea y resuelve tickets de soporte" + } + ] + }, + { + "id": 4, + "username": "ana.martinez", + "email": "ana.martinez@example.com", + "first_name": "Ana", + "last_name": "Martinez", + "is_active": false, + "is_staff": false, + "is_superuser": false, + "date_joined": "2025-04-20T00:00:00Z", + "last_login": "2025-10-15T14:20:00Z", + "grupos": [] + } + ], + "grupos": [ + { + "id": 1, + "codigo": "gestion_sistema", + "nombre_display": "Gestion de Sistema", + "descripcion": "Configura parametros tecnicos y administra usuarios", + "capacidades": ["sistema.usuarios.crear", "sistema.usuarios.editar", "sistema.usuarios.suspender", "sistema.configuracion.modificar"] + }, + { + "id": 2, + "codigo": "atencion_cliente", + "nombre_display": "Atencion al Cliente", + "descripcion": "Atiende consultas y gestiona interacciones con clientes", + "capacidades": ["sistema.llamadas.ver", "sistema.llamadas.realizar", "sistema.clientes.ver"] + }, + { + "id": 3, + "codigo": "gestion_tickets", + "nombre_display": "Gestion de Tickets", + "descripcion": "Crea y resuelve tickets de soporte", + "capacidades": ["sistema.tickets.ver", "sistema.tickets.crear", "sistema.tickets.editar"] + }, + { + "id": 4, + "codigo": "analisis_reportes", + "nombre_display": "Analisis de Reportes", + "descripcion": "Consulta reportes y exporta datos", + "capacidades": ["sistema.reportes.ver", "sistema.reportes.exportar", "sistema.metricas.ver"] + }, + { + "id": 5, + "codigo": "supervision_operaciones", + "nombre_display": "Supervision de Operaciones", + "descripcion": "Monitorea metricas y gestiona equipos de trabajo", + "capacidades": ["sistema.dashboard.ver", "sistema.metricas.ver", "sistema.equipos.gestionar", "sistema.reportes.ver"] + } + ], + "funciones": [ + { + "id": 1, + "recurso": "dashboards", + "descripcion": "Visualizacion de paneles de control" + }, + { + "id": 2, + "recurso": "usuarios", + "descripcion": "Administracion de usuarios del sistema" + }, + { + "id": 3, + "recurso": "llamadas", + "descripcion": "Gestion de llamadas y operaciones" + }, + { + "id": 4, + "recurso": "tickets", + "descripcion": "Gestion de tickets de soporte" + }, + { + "id": 5, + "recurso": "reportes", + "descripcion": "Reportes y analisis" + } + ], + "capacidades": [ + { + "id": 1, + "nombre": "sistema.usuarios.ver", + "accion": "ver", + "funcion_id": 2, + "descripcion": "Ver listado de usuarios" + }, + { + "id": 2, + "nombre": "sistema.usuarios.crear", + "accion": "crear", + "funcion_id": 2, + "descripcion": "Crear nuevos usuarios" + }, + { + "id": 3, + "nombre": "sistema.usuarios.editar", + "accion": "editar", + "funcion_id": 2, + "descripcion": "Modificar datos de usuarios" + }, + { + "id": 4, + "nombre": "sistema.usuarios.suspender", + "accion": "suspender", + "funcion_id": 2, + "descripcion": "Suspender o reactivar usuarios" + } + ], + "permisos_excepcionales": [ + { + "id": 1, + "usuario_id": 2, + "capacidad": "sistema.reportes.exportar", + "motivo": "Acceso temporal para cierre mensual", + "fecha_inicio": "2025-11-01T00:00:00Z", + "fecha_fin": "2025-11-30T23:59:59Z", + "es_permanente": false, + "otorgado_por": 1, + "fecha_creacion": "2025-11-01T08:00:00Z" + } + ], + "auditoria": [ + { + "id": 1, + "usuario_id": 2, + "capacidad": "sistema.llamadas.ver", + "accion": "acceso", + "timestamp": "2025-11-18T09:30:15Z", + "resultado": "permitido", + "ip_origen": "192.168.1.100" + }, + { + "id": 2, + "usuario_id": 3, + "capacidad": "sistema.usuarios.editar", + "accion": "intento_acceso", + "timestamp": "2025-11-18T10:15:30Z", + "resultado": "denegado", + "ip_origen": "192.168.1.101" + } + ] +} diff --git a/ui/src/services/health/HealthService.js b/ui/src/services/health/HealthService.js new file mode 100644 index 00000000..e0b07abb --- /dev/null +++ b/ui/src/services/health/HealthService.js @@ -0,0 +1,33 @@ +import { createResilientService } from '@services/createResilientService'; +import { loadMock } from '@mocks/registry'; +import { shouldUseMockForDomain } from '@services/flags/backendIntegrity'; + +const HEALTH_ENDPOINT = '/health/'; +const { data: healthMock } = loadMock('health'); + +const baseService = createResilientService({ + id: 'health', + endpoint: HEALTH_ENDPOINT, + mockDataLoader: () => Promise.resolve(healthMock), + shouldUseMock: () => shouldUseMockForDomain('health'), + errorMessage: 'No fue posible obtener el estado del backend', + isPayloadValid: (payload) => Boolean(payload && typeof payload.status === 'string'), +}); + +export class HealthService { + static async getStatus(options = {}) { + return baseService.fetch(options); + } + + static async fetchFromApi(options = {}) { + return baseService.fetchFromApi(options); + } + + static async fetchFromMock() { + return baseService.fetchFromMock(); + } + + static shouldUseMock() { + return shouldUseMockForDomain('health'); + } +} diff --git a/ui/src/services/health/HealthService.test.js b/ui/src/services/health/HealthService.test.js new file mode 100644 index 00000000..9881c73e --- /dev/null +++ b/ui/src/services/health/HealthService.test.js @@ -0,0 +1,56 @@ +import healthMock from '@mocks/health.json'; +import { HealthService } from './HealthService'; +import { getMockUsageMetrics, resetMockUsageMetrics } from '@services/utils/mockUsageTracker'; + +describe('HealthService', () => { + let originalEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + resetMockUsageMetrics(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('uses mock data when backend source is forced to mock', async () => { + process.env.UI_BACKEND_HEALTH_SOURCE = 'mock'; + const fetchImpl = jest.fn(); + + const result = await HealthService.getStatus({ fetchImpl }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.source).toBe('mock'); + expect(result.data).toEqual(healthMock); + expect(getMockUsageMetrics()).toEqual({ health: { api: 0, mock: 1 } }); + }); + + it('returns api payload when endpoint succeeds', async () => { + process.env.UI_BACKEND_HEALTH_SOURCE = 'api'; + const apiPayload = { status: 'ok', checkedAt: '2025-11-14T10:00:00Z' }; + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(apiPayload), + }); + + const result = await HealthService.getStatus({ fetchImpl }); + + expect(fetchImpl).toHaveBeenCalledWith('/health/', { signal: undefined }); + expect(result.source).toBe('api'); + expect(result.data).toEqual(apiPayload); + expect(getMockUsageMetrics()).toEqual({ health: { api: 1, mock: 0 } }); + }); + + it('falls back to mock data when api fails', async () => { + process.env.UI_BACKEND_HEALTH_SOURCE = 'api'; + const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable' }); + + const result = await HealthService.getStatus({ fetchImpl }); + + expect(result.source).toBe('mock'); + expect(result.data).toEqual(healthMock); + expect(result.error).toBeInstanceOf(Error); + expect(getMockUsageMetrics()).toEqual({ health: { api: 0, mock: 1 } }); + }); +}); diff --git a/ui/src/services/utils/mockUsageTracker.js b/ui/src/services/utils/mockUsageTracker.js index 64dd4d9c..cde74fca 100644 --- a/ui/src/services/utils/mockUsageTracker.js +++ b/ui/src/services/utils/mockUsageTracker.js @@ -1,4 +1,5 @@ const metrics = {}; +const subscribers = new Set(); const ensureDomain = (domain) => { if (!metrics[domain]) { @@ -21,6 +22,8 @@ export const recordMockUsage = (domain, source) => { const entry = ensureDomain(domain); entry[normalizedSource] += 1; + + notifySubscribers(); }; export const getMockUsageMetrics = () => { @@ -35,4 +38,24 @@ export const resetMockUsageMetrics = () => { Object.keys(metrics).forEach((domain) => { delete metrics[domain]; }); + + notifySubscribers(); +}; + +const notifySubscribers = () => { + const snapshot = getMockUsageMetrics(); + subscribers.forEach((listener) => listener(snapshot)); +}; + +export const subscribeMockUsage = (listener) => { + if (typeof listener !== 'function') { + return () => {}; + } + + listener(getMockUsageMetrics()); + subscribers.add(listener); + + return () => { + subscribers.delete(listener); + }; }; diff --git a/ui/src/services/utils/mockUsageTracker.test.js b/ui/src/services/utils/mockUsageTracker.test.js index 74a9d475..f02d90a4 100644 --- a/ui/src/services/utils/mockUsageTracker.test.js +++ b/ui/src/services/utils/mockUsageTracker.test.js @@ -1,4 +1,31 @@ -import { recordMockUsage, getMockUsageMetrics, resetMockUsageMetrics } from './mockUsageTracker'; +import { + recordMockUsage, + getMockUsageMetrics, + resetMockUsageMetrics, + subscribeMockUsage, +} from './mockUsageTracker'; + +describe('mockUsageTracker subscriptions', () => { + beforeEach(() => { + resetMockUsageMetrics(); + }); + + it('notifies subscribers when metrics change', () => { + const events = []; + const unsubscribe = subscribeMockUsage((data) => events.push(data)); + + recordMockUsage('calls', 'mock'); + recordMockUsage('config', 'api'); + + expect(events).toEqual([ + {}, + { calls: { api: 0, mock: 1 } }, + { calls: { api: 0, mock: 1 }, config: { api: 1, mock: 0 } }, + ]); + + unsubscribe(); + }); +}); describe('mockUsageTracker', () => { beforeEach(() => { diff --git a/ui/src/state/slices/healthSlice.js b/ui/src/state/slices/healthSlice.js new file mode 100644 index 00000000..1948899e --- /dev/null +++ b/ui/src/state/slices/healthSlice.js @@ -0,0 +1,43 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + status: 'unknown', + isChecking: false, + lastChecked: null, + source: 'unknown', + error: null, +}; + +const healthSlice = createSlice({ + name: 'observability', + initialState, + reducers: { + setChecking: (state, action) => { + state.isChecking = action.payload; + if (action.payload) { + state.error = null; + } + }, + setResult: (state, action) => { + state.status = action.payload.status ?? 'unknown'; + state.lastChecked = action.payload.checkedAt ?? null; + state.source = action.payload.source ?? 'unknown'; + state.error = action.payload.errorMessage ?? null; + state.isChecking = false; + }, + setError: (state, action) => { + state.error = action.payload; + state.isChecking = false; + }, + }, +}); + +export const { setChecking, setResult, setError } = healthSlice.actions; + +export const selectHealthStatus = (state) => state.observability.status; +export const selectHealthSource = (state) => state.observability.source; +export const selectLastChecked = (state) => state.observability.lastChecked; +export const selectHealthError = (state) => state.observability.error; +export const selectIsCheckingHealth = (state) => state.observability.isChecking; + +export default healthSlice.reducer; diff --git a/ui/src/state/slices/healthSlice.test.js b/ui/src/state/slices/healthSlice.test.js new file mode 100644 index 00000000..bed35aa3 --- /dev/null +++ b/ui/src/state/slices/healthSlice.test.js @@ -0,0 +1,70 @@ +import reducer, { + setChecking, + setResult, + setError, + selectHealthStatus, + selectHealthSource, + selectLastChecked, + selectHealthError, +} from './healthSlice'; + +describe('healthSlice', () => { + it('provides initial state', () => { + const state = reducer(undefined, { type: '@@INIT' }); + + expect(state).toEqual({ + status: 'unknown', + isChecking: false, + lastChecked: null, + source: 'unknown', + error: null, + }); + }); + + it('sets loading state', () => { + const state = reducer(undefined, setChecking(true)); + + expect(state.isChecking).toBe(true); + expect(state.error).toBeNull(); + }); + + it('stores health result and metadata', () => { + const result = { + status: 'ok', + checkedAt: '2025-11-14T12:00:00Z', + source: 'api', + errorMessage: null, + }; + + const state = reducer(undefined, setResult(result)); + + expect(state.status).toBe('ok'); + expect(state.lastChecked).toBe(result.checkedAt); + expect(state.source).toBe('api'); + expect(state.error).toBeNull(); + }); + + it('stores error state', () => { + const state = reducer(undefined, setError('fallo backend')); + + expect(state.error).toBe('fallo backend'); + expect(state.isChecking).toBe(false); + }); + + it('exposes selectors', () => { + const state = { + observability: { + status: 'degraded', + isChecking: false, + lastChecked: '2025-11-14T00:00:00Z', + source: 'mock', + error: null, + }, + }; + + expect(selectHealthStatus(state)).toBe('degraded'); + expect(selectHealthSource(state)).toBe('mock'); + expect(selectLastChecked(state)).toBe('2025-11-14T00:00:00Z'); + expect(selectHealthError(state)).toBeNull(); + }); +}); diff --git a/ui/src/state/store.js b/ui/src/state/store.js index 55c54620..10265359 100644 --- a/ui/src/state/store.js +++ b/ui/src/state/store.js @@ -1,11 +1,13 @@ import { configureStore } from '@reduxjs/toolkit'; import appConfigReducer from './slices/appConfigSlice'; import homeReducer from '@modules/home/state/homeSlice'; +import healthReducer from './slices/healthSlice'; export const store = configureStore({ reducer: { appConfig: appConfigReducer, home: homeReducer, + observability: healthReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/ui/src/state/store.test.js b/ui/src/state/store.test.js index a58d0d50..6382b693 100644 --- a/ui/src/state/store.test.js +++ b/ui/src/state/store.test.js @@ -7,6 +7,7 @@ describe('store', () => { expect(state.appConfig).toBeDefined(); expect(state.home).toBeDefined(); + expect(state.observability).toBeDefined(); }); it('dispatches actions without crashing', () => {