feat: Version 1.11.0 - Mejoras en auditoría, SLA, frontend y correcciones de sincronización

- Refactorización de endpoints de auditoría y helpers
- Mejoras en esquemas de auditoría (audit.py)
- Correcciones en endpoint SLA
- Actualizaciones en múltiples rutas del frontend interno:
  layout, tickets, usuarios, tenants, categorías, sistemas,
  SLA (at-risk, violations), auditoría (main + security), login, perfil
- Actualización de tailwind.config.js
- Eliminación de docs de versiones anteriores (CAMBIOS_v1.10.0, v1.8.0, OPTIMIZACIONES)
- Nuevos scripts de prueba: generate_security_test_data.py, generate_sla_test_data.py
- Script de prueba de sincronización crítica (test_critical_sync.ps1)
- README actualizado en scripts/
This commit is contained in:
2026-02-20 10:53:53 -07:00
parent 517297e89a
commit ceea67eb2b
32 changed files with 5715 additions and 3324 deletions

View File

@@ -1,726 +0,0 @@
# ServiceManagerWeb — Versión 1.10.0
## Reporte Técnico de Cambios y Mejoras
---
**Proyecto:** ServiceManagerWeb Mesa de Ayuda B2B Multi-tenant
**Versión:** 1.10.0
**Versión base:** 1.8.0 (commit `e644039`) / 1.9.0 (commit `16d795e`)
**Fecha:** 19 de Febrero de 2026
**Estado:** Sistema Funcional — Producción MVP
**Empresa:** Aduanasoft
**Autor:** Equipo de Desarrollo
---
## Resumen Ejecutivo
La versión 1.10.0 representa una fase de refactorización técnica profunda, optimización de rendimiento, y mejoras significativas en la interfaz de usuario. Los cambios abarcan el ciclo completo del sistema: backend (Python/FastAPI), frontend interno (SvelteKit), frontend cliente (SvelteKit) y la capa de infraestructura (Docker).
### Métricas Globales de esta Versión
| Indicador | Valor |
|---|---|
| Archivos modificados | 31 archivos |
| Líneas añadidas (total) | ~3,250 líneas |
| Líneas eliminadas (total) | ~3,440 líneas |
| Reducción neta de código | ~190 líneas (refactorización limpia) |
| Archivos nuevos creados | 14 archivos |
| Archivos eliminados | 3 scripts de prueba temporales |
| Scripts reorganizados | 3 (movidos a `backend/scripts/`) |
---
## 1. Backend — Python / FastAPI
### 1.1 `backend/app/api/v1/endpoints/audit.py`
**Cambios:** +166 líneas añadidas / 1,119 líneas eliminadas
**Balance neto:** 953 líneas (reducción del 74% del archivo)
#### Problema detectado
El archivo `audit.py` tenía 1,285 líneas en la versión 1.8.0. Toda la lógica de detección de amenazas, análisis de seguridad y transformación de datos estaba inline dentro de las funciones de cada endpoint, generando duplicación masiva y dificultando el mantenimiento.
#### Cambios realizados
**a) Extracción de lógica a módulo auxiliar**
Se creó el archivo `backend/app/api/v1/audit_helpers.py` (nuevo, ver sección 1.8) con las siguientes funciones extraídas del archivo original:
```python
# ANTES — en audit.py líneas 420-580 (inline)
# Toda la lógica de detección de amenazas vivía dentro de la función
# get_security_analysis() sin separación alguna
# DESPUÉS — importado desde audit_helpers.py
from app.api.v1.audit_helpers import (
audit_log_to_dict,
apply_tenant_filter,
get_count_stat,
get_top_items,
detect_mass_deletions,
detect_brute_force,
detect_privilege_escalation
)
```
**b) Docstrings compactados**
Los docstrings multilínea extensos se compactaron a una sola línea donde el nombre era autoexplicativo:
```python
# ANTES (líneas 1-7 del archivo original)
"""
Audit Endpoints - ServiceManagerWeb
Endpoints para consulta de logs de auditoría.
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
"""
# DESPUÉS (línea 1)
"""Audit Endpoints - ServiceManagerWeb"""
```
**c) Firma de función require_auditor_role refactorizada**
```python
# ANTES (líneas 32-47) — 16 líneas
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
"""
Dependency que verifica que el usuario tenga rol de auditor.
Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría.
"""
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR..."
)
return current_user
# DESPUÉS (líneas 20-24) — 5 líneas
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
"""Verifica que el usuario tenga rol de auditor"""
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder")
return current_user
```
**d) Firma del endpoint `get_audit_logs` compactada**
```python
# ANTES (líneas 49-72) — 24 líneas de parámetros separados
# DESPUÉS — parámetros agrupados en 6 líneas
async def get_audit_logs(
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=100),
user_id: Optional[uuid.UUID] = Query(None),
action: Optional[str] = Query(None),
...
```
**e) Corrección del schema `SecurityAnalysisResponse`**
Se añadieron todos los campos requeridos que causaban error 500 al serializar la respuesta. Los campos faltantes eran:
- `analysis_period_hours`
- `total_threats_detected`
- `suspicious_ips_count`
- `critical_actions_count`
---
### 1.2 `backend/app/api/v1/endpoints/auth.py`
**Cambios:** +369 líneas añadidas / 40 líneas eliminadas
**Balance neto:** +329 líneas
#### Cambios realizados
Se amplió la cobertura de autenticación con:
- Manejo robusto de tokens de refresco
- Validación mejorada de credenciales con mensajes de error específicos
- Soporte para recuperación de contraseña por email
- Integración con el servicio de email (`app/core/email.py`)
- Logging estructurado con `structlog` en todos los endpoints críticos
---
### 1.3 `backend/app/api/v1/endpoints/tickets.py`
**Cambios:** +217 líneas añadidas / 872 líneas eliminadas
**Balance neto:** 655 líneas (reducción del 60%)
#### Problema detectado
Igual que `audit.py`, el archivo de tickets tenía lógica de negocio repetida y funciones helper inline.
#### Cambios realizados
**a) Extracción a `backend/app/api/v1/helpers.py`**
Se creó un módulo auxiliar general (nuevo, ver sección 1.9) con funciones reutilizables como:
- `build_ticket_query()` — construye queries SQLAlchemy con filtros dinámicos
- `paginate_query()` — paginación genérica reutilizable
- `format_ticket_response()` — serialización consistente
**b) Corrección del error de `sla_breached`**
```python
# ANTES — causaba AttributeError / 500 en producción
response.sla_breached = ticket.sla_breached # ← propiedad no existía
# DESPUÉS — removido, calculado dinámicamente
# sla_breached se calcula desde sla_deadline vs datetime.utcnow()
```
---
### 1.4 `backend/app/api/schemas/__init__.py`
**Cambios:** +54 líneas añadidas / 4 líneas eliminadas
Se reorganizaron los schemas en módulos separados:
```
# ANTES — un archivo monolítico schemas/__init__.py con todo
# DESPUÉS — módulos independientes por dominio:
backend/app/api/schemas/
├── __init__.py (re-exports, 58 líneas totales)
├── auth.py (NUEVO — schemas de autenticación)
├── category.py (NUEVO — schemas de categorías)
├── system.py (NUEVO — schemas de sistemas)
├── tenant.py (NUEVO — schemas de tenants)
├── ticket.py (NUEVO — schemas de tickets)
└── user.py (NUEVO — schemas de usuarios)
```
---
### 1.5 `backend/app/middleware/tenant.py`
**Cambios:** +103 líneas añadidas / 37 líneas eliminadas
**Balance neto:** +66 líneas
#### Cambios realizados
- Mejora del middleware de contexto multi-tenant con mejor manejo de headers `X-Tenant-ID`
- Logging detallado de tenant context para debugging
- Validación más robusta del tenant activo
- Soporte para tenant bypass en endpoints de health/docs
---
### 1.6 `backend/app/main.py`
**Cambios:** +7 líneas añadidas / 0 líneas eliminadas
```python
# AÑADIDO — registro de nuevos routers
from app.api.v1.endpoints import profile # router de perfil de usuario
app.include_router(profile.router, prefix="/api/v1/profile", tags=["profile"])
```
---
### 1.7 `backend/app/core/database.py`
**Cambios:** +3 líneas / 2 líneas
```python
# ANTES
engine = create_async_engine(settings.DATABASE_URL, echo=False)
# DESPUÉS — pool tuning para mayor concurrencia
engine = create_async_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=300
)
```
---
### 1.8 `backend/app/api/v1/audit_helpers.py` (ARCHIVO NUEVO)
**Líneas:** ~120 líneas
Módulo auxiliar extraído de `audit.py`. Contiene:
| Función | Descripción |
|---|---|
| `audit_log_to_dict(log)` | Serializa un AuditLog a dict |
| `apply_tenant_filter(query, user, tenant, all_tenants)` | Aplica filtro multi-tenant |
| `get_count_stat(db, model, filters)` | Cuenta registros con filtros |
| `get_top_items(db, field, limit)` | Top N elementos de un campo |
| `detect_mass_deletions(logs)` | Detecta patrones de eliminación masiva |
| `detect_brute_force(logs)` | Detecta intentos de brute force |
| `detect_privilege_escalation(logs)` | Detecta escalada de privilegios |
---
### 1.9 `backend/app/api/v1/helpers.py` (ARCHIVO NUEVO)
**Líneas:** ~80 líneas
Helper general para tickets y recursos compartidos.
---
### 1.10 `backend/app/core/email.py` (ARCHIVO NUEVO)
Módulo de envío de email para notificaciones y recuperación de contraseña, integrado con Celery workers.
---
### 1.11 `backend/app/core/cache.py` (ARCHIVO NUEVO)
Módulo de caché con Redis:
- `cache_get(key)` / `cache_set(key, value, ttl)`
- Decorador `@cached(ttl=300)` para funciones async
- Invalidación por patrón de claves
---
### 1.12 `backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py`
**Cambios:** +57 líneas / 1 línea
Migration completada: se añadió la tabla `security_incidents` con campos:
- `id UUID PRIMARY KEY`
- `title VARCHAR(255)`
- `description TEXT`
- `severity ENUM(low, medium, high, critical)`
- `status ENUM(active, investigating, resolved)`
- `tenant_id UUID FK`
- `created_at TIMESTAMP`
- `updated_at TIMESTAMP`
---
### 1.13 `backend/tests/conftest.py`
**Cambios:** +151 líneas / 13 líneas
Se amplió el fixture base para pruebas de integración:
- Fixtures para multi-tenant testing
- Fixtures para usuario con rol AUDITOR
- Data factories para tickets, incidentes y audit logs
---
### 1.14 Reorganización de Scripts
```
# ANTES — en raíz de backend/
backend/check_tenants.py
backend/create_test_user.py
backend/set_test_password.py
# DESPUÉS — carpeta dedicada
backend/scripts/check_tenants.py
backend/scripts/create_test_user.py
backend/scripts/set_test_password.py
```
---
### 1.15 Nuevos Tests Unitarios (archivos nuevos)
```
backend/tests/unit/
├── test_audit_service.py (cobertura del AuditService)
├── test_config.py (validación de settings)
├── test_middleware.py (pruebas del middleware tenant)
├── test_schemas.py (validación de schemas Pydantic)
└── test_security.py (pruebas de JWT y hashing)
```
---
## 2. Frontend Interno — SvelteKit / TypeScript
### 2.1 `frontend-internal/src/routes/audit/+page.svelte`
**Cambios:** +1,253 líneas añadidas / 554 líneas eliminadas
**Líneas totales finales:** 2,050 líneas
Este es el archivo con más cambios de toda la versión. Se realizó una refactorización completa de la página de auditoría.
#### 2.1.1 Tipado TypeScript — Corrección de Warnings
```typescript
// ANTES (líneas 9-17) — tipos implícitos, generaba warnings
let logs = [];
let stats = null;
let users = [];
let incidents = [];
let securityAnalysis = null;
let selectedLog = null;
let selectedIncident = null;
// DESPUÉS — tipos explícitos
let logs: any[] = [];
let stats: any = null;
let users: any[] = [];
let incidents: any[] = [];
let securityAnalysis: any = null;
let selectedLog: any = null;
let selectedIncident: any = null;
```
#### 2.1.2 Responses de API tipadas
```typescript
// ANTES — response sin tipo, causaba errores
const response = await api.get('/audit/security/incidents', params);
incidents = response.incidents || []; // TS error: 'response' is of type 'unknown'
// DESPUÉS — response con tipo explícito
const response: any = await api.get('/audit/security/incidents', params);
incidents = response.incidents || [];
```
#### 2.1.3 Catch blocks tipados (5 bloques corregidos)
```typescript
// ANTES — 5 bloques con e sin tipo
} catch (e) {
console.error('Error:', e);
// DESPUÉS — todos tipados
} catch (e: any) {
console.error('Error:', e);
```
#### 2.1.4 Botones de período refactorizados con array tipado
```typescript
// ANTES — 5 bloques <button> repetidos, ~40 líneas
<button on:click={() => changePeriod('today')} class="...">Hoy</button>
<button on:click={() => changePeriod('yesterday')} class="...">Ayer</button>
<button on:click={() => changePeriod('last7days')} class="...">Últimos 7 días</button>
<button on:click={() => changePeriod('last30days')} class="...">Últimos 30 días</button>
<button on:click={() => changePeriod('custom')} class="...">Personalizado</button>
// DESPUÉS — array con tipo estricto + loop, ~15 líneas
const periodButtons: Array<{
id: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom',
label: string,
icon?: boolean
}> = [
{ id: 'today', label: 'Hoy' },
{ id: 'yesterday', label: 'Ayer' },
{ id: 'last7days', label: 'Últimos 7 días' },
{ id: 'last30days', label: 'Últimos 30 días' },
{ id: 'custom', label: 'Personalizado', icon: true }
];
{#each periodButtons as btn}
<button on:click={() => changePeriod(btn.id)} class="...">
{btn.label}
</button>
{/each}
```
#### 2.1.5 Tarjetas estadísticas refactorizadas con array reactivo
```typescript
// ANTES — 4 bloques <div> idénticos con ~25 líneas cada uno (~100 líneas totales)
// Total del Acciones — bloque completo
<div class="bg-white rounded-lg ...">
<div class="..."><svg .../><span>Total de Registros</span></div>
<div class="text-3xl ...">{stats.total_actions.toLocaleString()}</div>
...
</div>
// Acciones Hoy — bloque completo (repetido)
// Esta Semana — bloque completo (repetido)
// Incidentes Críticos — bloque completo (repetido)
// DESPUÉS — array reactivo + loop, ~40 líneas totales
$: statsCards = [
{ label: 'Total de Registros', value: stats?.total_actions, icon: 'clipboard', color: 'gray', desc: '...' },
{ label: 'Actividad Hoy', value: stats?.actions_today, icon: 'zap', color: 'blue', desc: '...' },
{ label: 'Esta Semana', value: stats?.actions_this_week, icon: 'calendar', color: 'indigo', desc: '...' },
{ label: 'Incidentes Críticos', value: stats?.critical_actions_today || 0, icon: 'alert', color: 'red', desc: '...', action: true }
];
{#each statsCards as card}
<div class="bg-white rounded-lg shadow-sm border border-{card.color}-200 p-5 ...">
...
</div>
{/each}
```
#### 2.1.6 Sección de Análisis de Seguridad reemplazada por enlace
```svelte
<!-- ANTES — sección extensa de ~150 líneas con amenazas, acciones recomendadas
y métricas desplegadas inline en la página principal -->
<!-- DESPUÉS — tarjeta compacta (~50 líneas) con enlace a página dedicada -->
<a href="/audit/security" class="block bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg ...">
<!-- Resumen de 3 métricas clave -->
<!-- Indicador visual del nivel de riesgo -->
<!-- Enlace "Ir al análisis detallado" -->
</a>
```
Esta decisión separa la responsabilidad: la página `/audit` muestra el **resumen de actividad**, mientras que `/audit/security` muestra el **análisis detallado de amenazas**.
#### 2.1.7 Mejoras de espaciado y layout
- **Contenedor principal:** `px-4 sm:px-6 lg:px-8 py-8` — márgenes responsivos
- **Encabezado de página:** añadido con `h1` + descripción
- **Separación entre secciones:** `mb-8` uniforme (antes `mb-6` variable)
- **Etiquetas de sección:** añadidos `<h2>` para "Resumen de Actividad", "Incidentes de Seguridad", "Registros de Auditoría"
- **Tarjetas con headers descriptivos:** añadidos `<h3>` en toggles y controles
---
### 2.2 `frontend-internal/src/routes/tickets/+page.svelte`
**Cambios:** +731 líneas añadidas / 338 líneas eliminadas
#### Cambios realizados
- Corrección de ortografía en 11 etiquetas de texto (ej: "priorida" → "prioridad")
- Mejora del filtro de estado y prioridad con selects correctamente bound a variables reactivas
- Vista de tabla compacta con rows más ajustados (`py-2` en lugar de `py-4`)
- Indicadores de color para prioridad (urgente=rojo, alto=naranja, medio=amarillo, bajo=azul)
- Modal de detalle de ticket con información de SLA sin acceder a propiedades no existentes
---
### 2.3 `frontend-internal/src/lib/components/Sidebar.svelte`
**Cambios:** +17 líneas / 6 líneas
```svelte
<!-- ANTES — enlace a Reportes ausente o comentado -->
<!-- DESPUÉS — enlace restaurado y activo -->
<a href="/reports" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md ..."
class:bg-indigo-700={$page.url.pathname.startsWith('/reports')}>
Reportes
</a>
```
---
### 2.4 `frontend-internal/vite.config.js`
**Cambios:** +20 líneas / 16 líneas
```javascript
// ANTES — proxy incorrecto durante desarrollo
proxy: {
'/api': 'http://localhost:8000' // ← fallaba dentro de Docker
}
// DESPUÉS — proxy correcto para red Docker
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
```
---
### 2.5 Nuevos Utilitarios Frontend (archivos nuevos)
#### `frontend-internal/src/lib/utils/colorUtils.ts` (NUEVO, 74 líneas)
```typescript
// Centraliza todos los mapas de colores del sistema
type ColorType = 'severity' | 'status' | 'action' | 'priority';
export function getColorClass(value: string, type: ColorType = 'status'): string
export function getStatusIcon(status: string): string
```
#### `frontend-internal/src/lib/utils/dateFormats.ts` (NUEVO, 78 líneas)
```typescript
// Centraliza el formateo de fechas
export function formatDate(dateString: string, format: DateFormat = 'full'): string
export function getRelativeTime(dateString: string): string
export function getDateRangeForPeriod(period: string, from?: string, to?: string): DateRange
```
---
## 3. Frontend Cliente — SvelteKit / TypeScript
### 3.1 `frontend-client/src/routes/profile/+page.svelte`
**Cambios:** +194 líneas / 56 líneas
Nueva funcionalidad de perfil de usuario con:
- Visualización de datos personales del cliente
- Formulario de edición de nombre y contacto
- Cambio de contraseña con validación de fortaleza
- Indicador visual del tipo de cuenta
---
### 3.2 `frontend-client/vite.config.js`
**Cambios:** +4 líneas / 0 líneas
```javascript
// AÑADIDO — proxy para comunicación con backend
server: {
proxy: {
'/api': { target: 'http://backend:8000', ... }
}
}
```
---
### 3.3 Nuevas Rutas Frontend Cliente (archivos nuevos)
```
frontend-client/src/routes/
├── forgot-password/ (NUEVO — flujo de recuperación de contraseña)
├── reset-password/ (NUEVO — formulario de nueva contraseña con token)
└── organization/ (NUEVO — vista de datos de la organización del cliente)
```
---
### 3.4 `frontend-client/src/lib/components/Header.svelte`
**Cambios:** +8 líneas / 2 líneas
- Añadido enlace a perfil de usuario en el dropdown del header
- Enlace a "Mi Organización" visible para `CLIENT_ADMIN`
---
## 4. Infraestructura y DevOps
### 4.1 `docker/Dockerfile.backend`
**Cambios:** +3 líneas / 1 línea
```dockerfile
# AÑADIDO — dependencias del sistema para compilar bcrypt
RUN apt-get install -y build-essential libffi-dev
```
---
### 4.2 `frontend-internal/package.json`
**Cambios:** +1 línea / 1 línea
```json
// ACTUALIZADO — versión de @sveltejs/kit para fix de routing
"@sveltejs/kit": "^1.27.0" // antes ^1.6.0
```
---
## 5. Archivos Eliminados
| Archivo | Razón |
|---|---|
| `test_frontend_integration.ps1` (174 líneas) | Script de prueba temporal — funcionalidad absorbida por suite de tests |
| `test_manual.ps1` (142 líneas) | Script de prueba manual obsoleto |
| `test_tenant_update.ps1` (101 líneas) | Script específico para prueba puntual, ya no necesario |
**Total eliminado:** 417 líneas de código temporal/obsoleto
---
## 6. Nuevos Archivos Creados
| Archivo | Líneas | Propósito |
|---|---|---|
| `backend/app/api/v1/audit_helpers.py` | ~120 | Helpers de auditoría extraídos de audit.py |
| `backend/app/api/v1/helpers.py` | ~80 | Helpers generales de tickets y queries |
| `backend/app/api/schemas/auth.py` | ~60 | Schemas Pydantic para autenticación |
| `backend/app/api/schemas/category.py` | ~30 | Schemas de categorías |
| `backend/app/api/schemas/system.py` | ~30 | Schemas de sistemas |
| `backend/app/api/schemas/tenant.py` | ~40 | Schemas de tenants |
| `backend/app/api/schemas/ticket.py` | ~80 | Schemas de tickets |
| `backend/app/api/schemas/user.py` | ~50 | Schemas de usuarios |
| `backend/app/core/email.py` | ~90 | Servicio de envío de email |
| `backend/app/core/cache.py` | ~70 | Módulo de caché Redis |
| `backend/tests/unit/test_audit_service.py` | ~100 | Tests del servicio de auditoría |
| `backend/tests/unit/test_config.py` | ~50 | Tests de configuración |
| `backend/tests/unit/test_middleware.py` | ~80 | Tests del middleware tenant |
| `backend/tests/unit/test_schemas.py` | ~70 | Tests de validación de schemas |
| `backend/tests/unit/test_security.py` | ~60 | Tests de seguridad JWT |
| `frontend-internal/src/lib/utils/colorUtils.ts` | 74 | Centralización de colores |
| `frontend-internal/src/lib/utils/dateFormats.ts` | 78 | Centralización de formatos de fecha |
| `frontend-client/src/routes/forgot-password/` | ~80 | Flujo de recuperación de contraseña |
| `frontend-client/src/routes/reset-password/` | ~90 | Formulario reset con token |
| `frontend-client/src/routes/organization/` | ~120 | Vista de organización del cliente |
| `frontend-internal/src/routes/profile/` | ~150 | Perfil del usuario interno |
| `OPTIMIZACIONES_RENDIMIENTO.md` | 344 | Guía técnica de optimizaciones futuras |
---
## 7. Correcciones de Bugs
### Bug #1 — Error 500 en `/audit/security/analysis`
**Causa:** El schema `SecurityAnalysisResponse` de Pydantic no incluía los campos `analysis_period_hours`, `total_threats_detected`, `suspicious_ips_count`, `critical_actions_count`. Al intentar serializar la respuesta, Pydantic lanzaba `ValidationError`.
**Archivo:** `backend/app/api/v1/endpoints/audit.py`
**Fix:** Se añadieron los campos faltantes al schema de respuesta en `backend/app/api/schemas/__init__.py`.
### Bug #2 — Error 500 en detalle de ticket (`/tickets/{id}`)
**Causa:** El endpoint accedía a `ticket.sla_breached` que no es una columna de la tabla, sino un cálculo derivado.
**Archivo:** `backend/app/api/v1/endpoints/tickets.py`
**Fix:** Se eliminó la referencia a `ticket.sla_breached` y se calcula dinámicamente: `sla_breached = ticket.sla_deadline < datetime.utcnow() if ticket.sla_deadline else False`
### Bug #3 — Proxy 404 en desarrollo con Docker
**Causa:** `vite.config.js` apuntaba a `localhost:8000` en lugar del hostname Docker `backend:8000`.
**Archivos:** `frontend-internal/vite.config.js`, `frontend-client/vite.config.js`
**Fix:** Se actualizó el target del proxy a `http://backend:8000` con `changeOrigin: true`.
### Bug #4 — Filtros de tickets no aplicaban
**Causa:** Los parámetros `status` y `priority` del frontend construían query strings con nombres incorrectos (`status_filter` en vez de `status`).
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
**Fix:** Corregidos los nombres de parámetros para coincidir con los Query params del backend.
### Bug #5 — Archivos con prefijo `+` causaban error de SvelteKit
**Causa:** Durante el desarrollo se crearon archivos de respaldo con nombres `+page.svelte.backup` y `+page.svelte.tmp`. SvelteKit interpreta cualquier archivo con `+` como una ruta especial.
**Fix:** Se eliminaron todos los archivos de respaldo con formato `+*.tmp`.
---
## 8. Correcciones Ortográficas (frontend-internal)
En `frontend-internal/src/routes/tickets/+page.svelte` se corrigieron 11 errores ortográficos:
| Línea aprox. | Antes | Después |
|---|---|---|
| ~145 | `priorida` | `prioridad` |
| ~189 | `Estad` | `Estado` |
| ~234 | `Accionnes` | `Acciones` |
| ~267 | `Assigado` | `Asignado` |
| ~310 | `Fecah` | `Fecha` |
| ~345 | `Prioiridad` | `Prioridad` |
| ~389 | `Ticktes` | `Tickets` |
| ~412 | `Resolucion` | `Resolución` |
| ~456 | `Sataus` | `Status` |
| ~478 | `Critcio` | `Crítico` |
| ~501 | `Asignar` → etiqueta incorrecta | Texto corregido contextualmente |
---
## 9. Notas de Migración
Para actualizar de v1.8.0 / v1.9.0 a v1.10.0:
```bash
# 1. Actualizar código
git pull origin main
git checkout version-1.10.0
# 2. Aplicar migraciones de base de datos
docker-compose exec backend alembic upgrade head
# 3. Reconstruir imágenes (cambios en Dockerfile)
docker-compose build --no-cache backend
# 4. Reiniciar todos los servicios
docker-compose up -d
# 5. Verificar salud
curl http://localhost:8000/health
```
---
## 10. Estado del Sistema tras v1.10.0
| Componente | Estado | Notas |
|---|---|---|
| Backend FastAPI | ✅ Funcional | 0 errores 500 en endpoints principales |
| Frontend Interno | ✅ Funcional | Proxy Docker correcto |
| Frontend Cliente | ✅ Funcional | Nuevas rutas de perfil y organización |
| Base de Datos | ✅ Migrada | Tabla security_incidents disponible |
| Celery Workers | ✅ Funcional | Integrado con email service |
| Redis Cache | ✅ Funcional | Módulo cache.py implementado |
| Tests Unitarios | ✅ Nuevos | 5 nuevos archivos de tests |
| Docker Compose | ✅ Funcional | Todos los servicios healthy |
---
*Documento generado: 19 de Febrero de 2026*
*Versión del documento: 1.0*
*ServiceManagerWeb — Aduanasoft*

View File

@@ -1,847 +0,0 @@
# ServiceManagerWeb - Versión 1.8.0
## Reporte Técnico de Cambios y Mejoras
---
**Proyecto:** ServiceManagerWeb - Mesa de Ayuda B2B Multi-tenant
**Versión:** 1.8.0
**Fecha:** 17 de Febrero de 2026
**Estado:** Sistema Funcional para Producción MVP
**Empresa:** Aduanasoft
---
## 📋 Resumen Ejecutivo
La versión 1.8.0 representa un hito importante en el desarrollo del sistema, consolidando la funcionalidad completa del módulo de tickets con un sistema de filtros operativo, optimizaciones significativas en la interfaz de usuario, y correcciones críticas en el backend. Esta versión está lista para despliegue en ambiente de producción MVP.
### Indicadores de Mejora
- **Densidad de información:** +50% más registros visibles por pantalla
- **Tiempo de respuesta UI:** Reducción de ~200ms en renderizado de tablas
- **Cobertura de filtros:** 100% funcional (estado y prioridad)
- **Correcciones backend:** 3 endpoints críticos corregidos
- **Archivos modificados:** 8 archivos (245 inserciones, 1633 eliminaciones)
---
## 🎯 Objetivos Alcanzados
### 1. Sistema de Filtros Funcional
**Problema:** Los filtros en el módulo de tickets no funcionaban correctamente, mostrando todos los registros sin importar los criterios seleccionados.
**Solución Implementada:**
- Rediseño completo del sistema de filtros frontend/backend
- Implementación correcta de construcción de query strings
- Validación de parámetros en backend con mensajes de error descriptivos
**Resultado:** Filtrado 100% funcional por estado y prioridad con actualización automática.
### 2. Optimización de Interfaz de Usuario
**Problema:** Las tablas ocupaban demasiado espacio vertical, reduciendo la cantidad de información visible.
**Solución Implementada:**
- Adopción del estilo compacto del módulo de auditoría
- Reducción de padding y tamaños de fuente
- Eliminación de columnas redundantes
**Resultado:** 50% más contenido visible sin sacrificar legibilidad.
### 3. Correcciones Backend Críticas
**Problema:** Múltiples endpoints presentaban errores 500 en producción.
**Solución Implementada:**
- Corrección de manejo de timezone en comparaciones
- Implementación de eager loading para relaciones
- Generación explícita de UUIDs en creación de perfiles
**Resultado:** 0 errores 500 en endpoints principales.
---
## 🔧 Cambios Técnicos Detallados
### Backend (Python/FastAPI)
#### 1. Endpoint `/v1/tickets/` - Sistema de Filtros
**Archivo:** `backend/app/api/v1/endpoints/tickets.py`
**Cambios realizados:**
```python
# ANTES (no funcional)
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None, # ❌ Nombre inconsistente
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Solo filtro por status, sin prioridad
if status_filter:
query = query.where(Ticket.status == status_filter)
# DESPUÉS (funcional)
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(
skip: int = 0,
limit: int = 100,
status: Optional[str] = None, # ✅ Nombre correcto
priority: Optional[str] = None, # ✅ Filtro agregado
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Filtro por estado con validación
if status:
try:
status_enum = TicketStatus[status.upper()]
query = query.where(Ticket.status == status_enum)
except KeyError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, ..."
)
# Filtro por prioridad con validación
if priority:
try:
priority_enum = TicketPriority[priority.upper()]
query = query.where(Ticket.priority == priority_enum)
except KeyError:
raise HTTPException(
status_code=400,
detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT"
)
```
**Impacto:**
- Frontend y backend ahora usan los mismos nombres de parámetros
- Validación explícita previene errores de datos inválidos
- Soporte completo para filtrado combinado (estado + prioridad)
- Mensajes de error descriptivos facilitan debugging
---
#### 2. Endpoint `/v1/sla/violations` - Corrección de Timezone
**Archivo:** `backend/app/api/v1/endpoints/sla.py`
**Problema identificado:**
```
TypeError: can't compare offset-naive and offset-aware datetimes
```
**Causa raíz:**
El campo `ticket.sla_response_due` viene de la base de datos como timestamp **naive** (sin zona horaria), pero `datetime.now(timezone.utc)` genera un timestamp **aware** (con UTC), causando incompatibilidad en comparaciones.
**Solución implementada:**
```python
# ANTES
if ticket.sla_response_due:
now = datetime.now(timezone.utc)
if now > ticket.sla_response_due: # ❌ Error: comparación incompatible
violated_tickets.append(...)
# DESPUÉS
if ticket.sla_response_due:
now = datetime.now(timezone.utc)
# Convertir timestamp de BD a UTC-aware
sla_due_aware = ticket.sla_response_due.replace(tzinfo=timezone.utc)
if now > sla_due_aware: # ✅ Ambos son UTC-aware
violated_tickets.append(...)
```
**Mejora adicional:** Eager Loading
```python
# ANTES: N+1 queries problem
result = await db.execute(query)
tickets = result.scalars().all()
for ticket in tickets:
user_email = ticket.created_by_user.email # ❌ Query adicional por cada ticket
# DESPUÉS: Single query con JOIN
from sqlalchemy.orm import selectinload
query = query.options(
selectinload(Ticket.created_by_user),
selectinload(Ticket.assigned_to_user),
selectinload(Ticket.category)
)
result = await db.execute(query)
tickets = result.scalars().all()
# ✅ Todas las relaciones cargadas en una sola consulta
```
**Impacto:**
- Eliminación de errores de comparación de timezone
- Reducción de queries a BD de O(n) a O(1)
- Mejora de rendimiento en listados grandes
---
#### 3. Endpoint `/v1/client-profile/` - Generación de UUID
**Archivo:** `backend/app/api/v1/endpoints/client_profile.py`
**Problema:**
```
IntegrityError: null value in column "id" violates not-null constraint
IntegrityError: null value in column "created_at" violates not-null constraint
```
**Causa raíz:**
SQLAlchemy esperaba que la base de datos generara el UUID automáticamente, pero la columna no tenía `DEFAULT` en PostgreSQL.
**Solución implementada:**
1. **Código de aplicación:**
```python
# ANTES
db_profile = ClientProfile(
tenant_id=current_user.tenant_id,
user_id=current_user.id
# ❌ Falta id y created_at
)
# DESPUÉS
import uuid
db_profile = ClientProfile(
id=uuid.uuid4(), # ✅ Generación explícita
tenant_id=current_user.tenant_id,
user_id=current_user.id
)
```
2. **Migración de base de datos:**
```python
# Archivo: backend/migrations/versions/fix_client_profiles_timestamps.py
def upgrade():
op.alter_column('client_profiles', 'created_at',
server_default=sa.text('now()'))
op.alter_column('client_profiles', 'updated_at',
server_default=sa.text('now()'))
def downgrade():
op.alter_column('client_profiles', 'created_at',
server_default=None)
op.alter_column('client_profiles', 'updated_at',
server_default=None)
```
**Impacto:**
- Eliminación de errores 500 al crear perfiles vacíos
- Base de datos con defaults consistentes
- Código más robusto y predecible
---
### Frontend (SvelteKit/TypeScript)
#### 1. Módulo de Tickets - Sistema de Filtros
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
**Arquitectura del cambio:**
```typescript
// ANTES: Parámetros incorrectamente estructurados
async function loadData() {
const params: Record<string, string> = {};
if (filterStatus) params.status = filterStatus;
if (filterPriority) params.priority = filterPriority;
// ❌ El helper api.get() no construía correctamente la URL con params objeto
const data = await api.get('/tickets/', params);
}
// DESPUÉS: Query string explícito
async function loadData() {
// Usar URLSearchParams para construcción correcta
const queryParams = new URLSearchParams();
queryParams.append('skip', '0');
queryParams.append('limit', '100');
if (filterStatus) {
queryParams.append('status', filterStatus);
}
if (filterPriority) {
queryParams.append('priority', filterPriority);
}
// ✅ URL completa con query string bien formado
const endpoint = `/tickets/?${queryParams.toString()}`;
const data = await api.get(endpoint);
}
```
**Layout de filtros optimizado:**
```svelte
<!-- ANTES: 3 columnas con botón actualizar manual -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label class="block text-sm font-medium">Estado</label>
<select bind:value={filterStatus} on:change={applyFilters}
class="mt-1 block w-full border p-2">
<option value="">Todos</option>
<!-- ... -->
</select>
</div>
<div><!-- Prioridad --></div>
<div class="flex items-end">
<button on:click={loadData}>Actualizar</button>
</div>
</div>
<!-- DESPUÉS: 2 columnas con auto-actualización -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs font-medium mb-1">Estado</label>
<select bind:value={filterStatus} on:change={loadData}
class="block w-full border p-1.5 text-sm">
<option value="">Todos los estados</option>
<!-- ... -->
</select>
</div>
<div><!-- Prioridad con mismo patrón --></div>
</div>
```
**Beneficios:**
- Menor espacio vertical ocupado por filtros
- Actualización inmediata al cambiar criterios
- Interfaz más limpia sin botones innecesarios
- Labels más pequeños pero legibles
---
#### 2. Tabla de Tickets - Diseño Compacto
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
**Comparación de estilos:**
| Elemento | Antes (v1.7.1) | Después (v1.8.0) | Reducción |
|----------|----------------|------------------|-----------|
| **Header padding** | `py-2` (8px) | `py-1.5` (6px) | -25% |
| **Cell padding** | `px-2 py-2` | `px-3 py-2` | 0% (optimizado) |
| **Font size header** | `text-xs font-semibold` | `text-xs font-medium uppercase` | Mejor jerarquía |
| **Font size body** | `text-xs` | `text-xs` | Mantenido |
| **Badge padding** | `px-2 py-0.5` | `px-2 py-1` | Mejor legibilidad |
| **Columnas totales** | 9 (inc. SLA) | 8 (sin SLA) | -11% ancho |
**Estructura HTML mejorada:**
```html
<!-- ANTES -->
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th class="py-2 pl-4 pr-2 text-xs font-semibold text-gray-900">Ticket</th>
<th class="px-2 py-2 text-xs font-semibold">Asunto</th>
<!-- ... 7 columnas más incluyendo SLA -->
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr class="hover:bg-gray-50 cursor-pointer">
<td class="whitespace-nowrap py-2 pl-4 pr-2">...</td>
<!-- ... -->
</tr>
</tbody>
</table>
<!-- DESPUÉS -->
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0 z-10">
<tr>
<th class="px-3 py-1.5 text-xs font-medium text-gray-500 uppercase tracking-wider">
Ticket
</th>
<th class="px-3 py-1.5 text-xs font-medium uppercase">Asunto</th>
<!-- ... 6 columnas más, SLA eliminado -->
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr class="hover:bg-gray-50 cursor-pointer transition-colors">
<td class="px-3 py-2 whitespace-nowrap text-xs font-medium">...</td>
<!-- ... -->
</tr>
</tbody>
</table>
```
**Mejoras visuales:**
- **Sticky header:** `sticky top-0 z-10` - encabezados fijos al hacer scroll
- **Transitions:** `transition-colors` en hover para mejor UX
- **Consistency:** Mismo padding `px-3` en todo el ancho
- **Typography:** `uppercase tracking-wider` en headers para mejor escaneado
- **Dividers:** Cambio de `divide-gray-300` a `divide-gray-200` (más sutil)
**Badges optimizados:**
```svelte
<!-- ANTES: Inline badges con tamaños variables -->
<span class="inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold leading-4
bg-{getStatusBadge(ticket.status).color}-100">
{getStatusBadge(ticket.status).label}
</span>
<!-- DESPUÉS: Badges uniformes con mejor padding -->
<span class="px-2 py-1 text-xs font-medium rounded-full
bg-{getStatusBadge(ticket.status).color}-100
text-{getStatusBadge(ticket.status).color}-800">
{getStatusBadge(ticket.status).label}
</span>
```
**Acciones con separador visual:**
```svelte
<!-- ANTES: Botones sin separación clara -->
<td class="space-x-1">
<button class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button class="text-red-600 hover:text-red-900">Eliminar</button>
</td>
<!-- DESPUÉS: Separador visual con transiciones -->
<td class="px-3 py-2 whitespace-nowrap text-right text-xs">
<button class="text-indigo-600 hover:text-indigo-900 font-medium transition-colors">
Editar
</button>
<span class="text-gray-300 mx-1">|</span>
<button class="text-red-600 hover:text-red-900 font-medium transition-colors">
Eliminar
</button>
</td>
```
---
#### 3. Gestión de Tenants - Toggle de Estado
**Archivo:** `frontend-internal/src/routes/tenants/+page.svelte`
**Funcionalidad agregada:** Toggle switch para activar/desactivar tenants
**Implementación:**
```svelte
<script>
async function toggleTenantStatus(tenant: any) {
try {
const newStatus = tenant.status === 'active' ? 'inactive' : 'active';
await api.patch(`/tenants/${tenant.id}`, { status: newStatus });
// Actualizar estado local con reactividad forzada
tenant.status = newStatus;
tenants = [...tenants]; // ✅ Spread operator fuerza re-render
toast.success(`Tenant ${newStatus === 'active' ? 'activado' : 'desactivado'}`);
} catch (e) {
toast.error('Error al cambiar estado: ' + e.message);
}
}
</script>
<!-- Toggle switch estilizado -->
<button
on:click|stopPropagation={() => toggleTenantStatus(tenant)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors
{tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-200'}"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform
{tenant.status === 'active' ? 'translate-x-6' : 'translate-x-1'}">
</span>
</button>
<!-- Reactividad con keyed loop -->
{#each tenants as tenant (tenant.id)}
<!-- ✅ Key binding asegura updates correctos -->
{/each}
```
**Conceptos aplicados:**
- **Svelte Reactivity:** Uso de spread operator `[...tenants]` para forzar re-render
- **Keyed loops:** `{#each tenants as tenant (tenant.id)}` previene bugs de reordenamiento
- **Event modifiers:** `on:click|stopPropagation` previene navegación accidental
- **CSS Transitions:** Animación suave en cambio de estado
---
## 📊 Análisis de Impacto
### Rendimiento
| Métrica | v1.7.1 | v1.8.0 | Mejora |
|---------|--------|--------|--------|
| **Queries por listado de tickets** | 21 (1 + 20*1 N+1) | 1 (eager loading) | 95% ↓ |
| **Tiempo de render tabla** | ~350ms | ~150ms | 57% ↓ |
| **Registros visibles** | 6-7 tickets | 12-14 tickets | 100% ↑ |
| **Filtros funcionales** | 0% | 100% | ∞ ↑ |
| **Errores 500 endpoints** | 3 endpoints | 0 endpoints | 100% ↓ |
### Calidad de Código
```
Archivos modificados: 8
Líneas agregadas: +245
Líneas eliminadas: -1,633
Ratio de limpieza: 6.7:1 (eliminamos más código del que agregamos)
```
**Archivos principales:**
1. `backend/app/api/v1/endpoints/tickets.py` - Sistema de filtros
2. `backend/app/api/v1/endpoints/sla.py` - Corrección timezone
3. `backend/app/api/v1/endpoints/client_profile.py` - UUID explicit
4. `frontend-internal/src/routes/tickets/+page.svelte` - UI optimizada
5. `frontend-internal/src/routes/tenants/+page.svelte` - Toggle status
6. `backend/migrations/versions/fix_client_profiles_timestamps.py` - Nueva migración
### Deuda Técnica
**Eliminada:**
- ✅ N+1 queries en endpoint de SLA violations
- ✅ Comparaciones timezone incompatibles
- ✅ Filtros no funcionales en tickets
- ✅ Código duplicado en tablas (archivos .backup eliminados)
**Pendiente (no crítica):**
- ⚠️ Paginación en frontend (actualmente limit 100)
- ⚠️ Tests automatizados para nuevos endpoints
- ⚠️ Caché de categorías/sistemas/usuarios (cargados en cada request)
---
## 🧪 Testing y Validación
### Tests Realizados
#### 1. Sistema de Filtros
```
✅ Filtro por estado "NEW" → Solo tickets nuevos
✅ Filtro por prioridad "HIGH" → Solo tickets alta prioridad
✅ Filtro combinado (NEW + HIGH) → Intersección correcta
✅ Limpieza de filtros → Todos los tickets visibles
✅ Estados inválidos → Error 400 con mensaje descriptivo
```
#### 2. Endpoints Backend
```
✅ GET /v1/tickets/?status=NEW → 200 OK
✅ GET /v1/tickets/?priority=URGENT → 200 OK
✅ GET /v1/tickets/?status=INVALID → 400 Bad Request
✅ GET /v1/sla/violations → 200 OK (sin error timezone)
✅ POST /v1/client-profile/ → 201 Created (con UUID)
```
#### 3. UI/UX
```
✅ Tabla responsiva con overflow-x-auto
✅ Sticky headers funcionan en scroll vertical
✅ Hover effects con transiciones suaves
✅ Badges con colores semánticos correctos
✅ Toggle de tenants actualiza UI instantáneamente
```
### Casos de Prueba Manual
**Escenario 1: Usuario filtra tickets urgentes**
1. Usuario accede a módulo de tickets
2. Selecciona prioridad "Urgente" en dropdown
3. Sistema recarga automáticamente
4. Solo se muestran tickets con prioridad URGENT
5. URL refleja filtro: `/tickets/?skip=0&limit=100&priority=URGENT`
**Resultado:** ✅ Exitoso
**Escenario 2: Administrador desactiva tenant**
1. Admin accede a gestión de tenants
2. Hace clic en toggle de un tenant activo
3. Toggle cambia a gris, estado actualiza a "inactive"
4. Toast muestra "Tenant desactivado"
5. Cambio persiste en base de datos
**Resultado:** ✅ Exitoso
---
## 🔄 Migraciones de Base de Datos
### Migración: `fix_client_profiles_timestamps`
**Propósito:** Agregar defaults de PostgreSQL para campos temporales
**SQL generado:**
```sql
-- Upgrade
ALTER TABLE client_profiles
ALTER COLUMN created_at SET DEFAULT now();
ALTER TABLE client_profiles
ALTER COLUMN updated_at SET DEFAULT now();
-- Downgrade (rollback)
ALTER TABLE client_profiles
ALTER COLUMN created_at DROP DEFAULT;
ALTER TABLE client_profiles
ALTER COLUMN updated_at DROP DEFAULT;
```
**Ejecución:**
```bash
# Aplicar migración
docker-compose exec backend alembic upgrade head
# Verificar
docker-compose exec backend alembic current
# Output: fix_client_timestamps (head)
```
**Impacto:** 0 downtime, no modifica datos existentes
---
## 📦 Despliegue
### Pasos para Producción
1. **Backup de base de datos:**
```bash
docker-compose exec postgres pg_dump -U postgres servicemanager > backup_pre_v1.8.0.sql
```
2. **Pull del código:**
```bash
git fetch --tags
git checkout v1.8.0
```
3. **Rebuild de servicios modificados:**
```bash
docker-compose build backend frontend-internal
```
4. **Aplicar migraciones:**
```bash
docker-compose exec backend alembic upgrade head
```
5. **Restart de servicios:**
```bash
docker-compose restart backend frontend-internal
```
6. **Verificar health checks:**
```bash
curl http://localhost:8000/health
# Expected: {"status": "healthy"}
```
### Rollback Plan
En caso de problemas críticos:
```bash
# 1. Volver al código anterior
git checkout v1.7.1
# 2. Rollback de migración
docker-compose exec backend alembic downgrade -1
# 3. Rebuild y restart
docker-compose build backend frontend-internal
docker-compose restart backend frontend-internal
# 4. Restaurar backup si es necesario
docker-compose exec -T postgres psql -U postgres servicemanager < backup_pre_v1.8.0.sql
```
**Tiempo estimado de rollback:** < 5 minutos
---
## 🎓 Lecciones Aprendidas
### 1. Timezone Handling
**Problema:** Comparaciones entre timestamps naive y aware causan TypeError.
**Solución:** Siempre usar `datetime.now(timezone.utc)` y convertir timestamps de BD con `.replace(tzinfo=timezone.utc)`.
**Best Practice:**
```python
# ❌ EVITAR
now = datetime.now() # Naive, depende de servidor
# ✅ USAR
now = datetime.now(timezone.utc) # Aware, consistente
```
### 2. SQLAlchemy Eager Loading
**Problema:** N+1 queries degradan rendimiento significativamente.
**Solución:** Usar `selectinload()` para cargar relaciones en una sola query.
**Best Practice:**
```python
# ❌ EVITAR
tickets = await db.execute(select(Ticket))
for ticket in tickets:
print(ticket.user.email) # Query por cada ticket
# ✅ USAR
query = select(Ticket).options(selectinload(Ticket.user))
tickets = await db.execute(query)
```
### 3. Svelte Reactivity
**Problema:** Cambios en objetos dentro de arrays no disparan re-render.
**Solución:** Usar spread operator para crear nuevo array referencia.
**Best Practice:**
```javascript
// ❌ EVITAR
tenant.status = 'active';
// No re-render
// ✅ USAR
tenant.status = 'active';
tenants = [...tenants]; // Crea nueva referencia
```
### 4. API Query String Construction
**Problema:** Construcción manual de URLs puede causar codificación incorrecta.
**Solución:** Usar `URLSearchParams` nativo de JavaScript.
**Best Practice:**
```javascript
// ❌ EVITAR
let url = '/tickets/?status=' + status + '&priority=' + priority;
// ✅ USAR
const params = new URLSearchParams();
if (status) params.append('status', status);
if (priority) params.append('priority', priority);
const url = `/tickets/?${params.toString()}`;
```
---
## 📚 Documentación Actualizada
### Nuevos Parámetros de API
**Endpoint:** `GET /v1/tickets/`
**Parámetros query:**
- `skip` (int): Offset para paginación (default: 0)
- `limit` (int): Cantidad máxima de resultados (default: 100)
- `status` (string, optional): Filtrar por estado
- Valores válidos: `NEW`, `IN_PROGRESS`, `WAITING_CUSTOMER`, `RESOLVED`, `CLOSED`, `REOPENED`
- `priority` (string, optional): Filtrar por prioridad
- Valores válidos: `LOW`, `MEDIUM`, `HIGH`, `URGENT`
**Ejemplo de uso:**
```bash
# Tickets nuevos de alta prioridad
GET /v1/tickets/?status=NEW&priority=HIGH
# Solo tickets urgentes
GET /v1/tickets/?priority=URGENT
# Tickets en progreso (paginados)
GET /v1/tickets/?status=IN_PROGRESS&skip=20&limit=20
```
**Respuestas:**
- `200 OK`: Lista de tickets filtrados
- `400 Bad Request`: Parámetro inválido
- `401 Unauthorized`: Token expirado/inválido
---
## 🔐 Consideraciones de Seguridad
### Validación de Inputs
**Implementado:** Todos los filtros validan contra enums definidos.
```python
# Previene SQL injection y valores arbitrarios
try:
status_enum = TicketStatus[status.upper()]
except KeyError:
raise HTTPException(status_code=400, detail="Invalid status")
```
### Multi-tenancy
**Mantenido:** Todos los endpoints filtran por `tenant_id`.
```python
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
```
### RBAC (Role-Based Access Control)
**Preservado:** Clientes solo ven sus propios tickets.
```python
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
```
---
## 📈 Próximos Pasos (v1.9.0)
### Funcionalidades Planificadas
1. **Paginación completa:**
- Botones prev/next en frontend
- Indicador de página actual
- Total de registros
2. **Filtros adicionales:**
- Búsqueda por texto (subject/description)
- Filtro por rango de fechas
- Filtro por categoría
3. **Exportación de datos:**
- Exportar tickets a CSV
- Exportar a PDF con filtros aplicados
4. **Optimizaciones:**
- Caché de categorías/sistemas en localStorage
- Lazy loading de imágenes/avatares
- Debounce en búsquedas de texto
### Mejoras Técnicas
1. Tests automatizados (pytest + Svelte Testing Library)
2. Documentación OpenAPI más completa
3. Metrics con Prometheus
4. Logging estructurado mejorado
---
## 👥 Créditos
**Desarrollador:** Equipo de Desarrollo Aduanasoft
**Revisión Técnica:** GitHub Copilot
**QA:** Testing manual interno
**Arquitectura:** Clean Architecture + Domain-Driven Design
---
## 📞 Soporte
Para reportar issues o consultas sobre esta versión:
- **Email:** dev@aduanasoft.com
- **Sistema:** ServiceManagerWeb Internal
- **Versión:** 1.8.0
- **Fecha de release:** 17/02/2026
---
## 🏁 Conclusión
La versión 1.8.0 consolida el sistema como **MVP production-ready**, con:
- Sistema de filtros totalmente funcional
- UI optimizada para mayor densidad de información
- 0 errores críticos en endpoints principales
- Codebase más limpio (-1633 líneas)
- Mejor rendimiento en queries (95% reducción)
**Estado del proyecto:** Listo para despliegue en producción.
---
*Documento generado automáticamente para ServiceManagerWeb v1.8.0*
*© 2026 Aduanasoft - Todos los derechos reservados*

View File

@@ -1,343 +0,0 @@
# Optimizaciones de Rendimiento - ServiceManagerWeb
## 🎯 Estado Actual
El sistema funciona correctamente, pero podemos implementar mejoras para hacerlo más rápido.
## 🚀 Optimizaciones Implementables
### 1. **Backend - Base de Datos** (ALTO IMPACTO)
#### A. Aumentar Pool de Conexiones
**Archivo**: `backend/app/core/database.py`
```python
# Actual
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=5, # ← Aumentar a 20
max_overflow=10, # ← Aumentar a 30
pool_pre_ping=True,
)
# Optimizado
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=20, # Más conexiones concurrentes
max_overflow=30, # Más overflow para picos
pool_pre_ping=True,
pool_recycle=3600,
)
```
**Impacto**: ⚡ 30-50% más rápido en endpoints con DB
---
#### B. Agregar Índices Faltantes
**Ejecutar migrations**:
```sql
-- Índices para queries frecuentes
CREATE INDEX CONCURRENTLY idx_tickets_status_tenant ON tickets(status, tenant_id);
CREATE INDEX CONCURRENTLY idx_tickets_assigned_to ON tickets(assigned_to);
CREATE INDEX CONCURRENTLY idx_tickets_created_at ON tickets(created_at DESC);
CREATE INDEX CONCURRENTLY idx_users_email_tenant ON users(email, tenant_id);
CREATE INDEX CONCURRENTLY idx_audit_logs_tenant_created ON audit_logs(tenant_id, created_at DESC);
```
**Impacto**: ⚡ 40-70% más rápido en listados y búsquedas
---
### 2. **Backend - Caché con Redis** (ALTO IMPACTO)
#### Crear servicio de caché
**Nuevo archivo**: `backend/app/core/cache.py`
```python
"""Redis caching service"""
from redis import asyncio as aioredis
from typing import Optional, Any
import json
from app.core.config import get_settings
settings = get_settings()
class CacheService:
def __init__(self):
self.redis = None
async def connect(self):
self.redis = await aioredis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True
)
async def get(self, key: str) -> Optional[Any]:
if not self.redis:
await self.connect()
value = await self.redis.get(key)
return json.loads(value) if value else None
async def set(self, key: str, value: Any, ttl: int = 300):
if not self.redis:
await self.connect()
await self.redis.setex(key, ttl, json.dumps(value))
async def delete(self, key: str):
if not self.redis:
await self.connect()
await self.redis.delete(key)
cache = CacheService()
```
#### Usar en endpoints frecuentes:
```python
# Ejemplo: Cachear listado de categorías
@router.get("/categories")
async def list_categories(db: AsyncSession = Depends(get_db)):
cache_key = f"categories:tenant:{tenant_id}"
# Intentar cache
cached = await cache.get(cache_key)
if cached:
return cached
# Si no hay cache, query DB
result = await db.execute(select(Category))
categories = result.scalars().all()
# Guardar en cache por 5 minutos
await cache.set(cache_key, categories, ttl=300)
return categories
```
**Impacto**: ⚡ 80-95% más rápido en datos que no cambian frecuentemente
---
### 3. **Backend - Uvicorn Workers** (MEDIO IMPACTO)
#### Actualizar Dockerfile
**Archivo**: `docker/Dockerfile.backend`
```dockerfile
# Cambiar la última línea de:
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# A modo producción:
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
```
**Nota**: Quitar `--reload` en producción (consume recursos).
**Impacto**: ⚡ 2-4x más throughput (requests por segundo)
---
### 4. **Frontend - Code Splitting y Lazy Loading** (MEDIO IMPACTO)
#### Configurar lazy loading en rutas
**Archivo**: `frontend-internal/src/routes/+layout.svelte`
```typescript
// En lugar de importar todo:
import HeavyComponent from '$lib/components/HeavyComponent.svelte';
// Usar dynamic imports:
const HeavyComponent = () => import('$lib/components/HeavyComponent.svelte');
```
#### Optimizar build de Vite
**Archivo**: `frontend-internal/vite.config.js`
```javascript
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor': ['svelte', 'svelte/store'],
'charts': ['chart.js'], // Si usas charts
}
}
}
}
}
```
**Impacto**: ⚡ 40-60% más rápido el load inicial del frontend
---
### 5. **Queries SQL - Eager Loading** (ALTO IMPACTO)
#### Usar selectinload para relaciones
**Ejemplo en endpoints de tickets**:
```python
# Antes (N+1 queries)
query = select(Ticket).where(Ticket.tenant_id == tenant_id)
# Después (1 query con joins)
query = select(Ticket).options(
selectinload(Ticket.category),
selectinload(Ticket.assigned_user),
selectinload(Ticket.comments)
).where(Ticket.tenant_id == tenant_id)
```
**Impacto**: ⚡ 50-80% más rápido al traer relaciones
---
### 6. **Logging en Producción** (MEDIO IMPACTO)
#### Reducir logging en producción
**Archivo**: `.env`
```bash
# Development
DEBUG=true
LOG_LEVEL=INFO
# Production (cambiar a)
DEBUG=false
LOG_LEVEL=WARNING
```
**Impacto**: ⚡ 10-15% menos overhead
---
### 7. **Docker - Recursos** (BAJO IMPACTO)
#### Asignar más recursos en docker-compose
**Archivo**: `docker-compose.yml`
```yaml
backend:
# ... config existente
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '1.0'
memory: 512M
postgres:
# ... config existente
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
```
---
## 📊 Prioridades de Implementación
### **Fase 1 - Quick Wins** (1-2 horas)
1. ✅ Aumentar pool de DB
2. ✅ Quitar `--reload` en producción
3. ✅ Reducir logging (LOG_LEVEL=WARNING)
**Ganancia esperada**: 30-40% mejora general
---
### **Fase 2 - Optimizaciones Importantes** (2-4 horas)
1. ✅ Agregar índices de DB
2. ✅ Implementar caché con Redis
3. ✅ Eager loading en queries complejas
**Ganancia esperada**: 50-70% mejora en endpoints cacheables
---
### **Fase 3 - Optimizaciones Avanzadas** (4-8 horas)
1. ✅ Uvicorn workers múltiples
2. ✅ Frontend code splitting
3. ✅ Optimización de queries lentas
**Ganancia esperada**: 2-3x mejora en throughput total
---
## 🔧 Comandos Rápidos
### Implementar Fase 1 (copiar y ejecutar):
```bash
# 1. Editar database.py (aumentar pools)
# Ver sección 1.A arriba
# 2. Editar Dockerfile.backend (quitar reload)
# Ver sección 3 arriba
# 3. Editar .env
echo "DEBUG=false" >> .env
echo "LOG_LEVEL=WARNING" >> .env
# 4. Reiniciar servicios
docker-compose restart backend
```
---
## 📈 Monitorear Mejoras
```bash
# Medir tiempo de respuesta ANTES
curl -w "@-" -o /dev/null -s http://localhost:8000/v1/tickets <<'EOF'
time_total: %{time_total}s\n
EOF
# Implementar optimizaciones...
# Medir tiempo de respuesta DESPUÉS
curl -w "@-" -o /dev/null -s http://localhost:8000/v1/tickets <<'EOF'
time_total: %{time_total}s\n
EOF
```
---
## ⚡ Resultados Esperados
| Métrica | Actual | Optimizado | Mejora |
|---------|--------|------------|--------|
| Login | ~300ms | ~100ms | 3x |
| Listar tickets | ~500ms | ~150ms | 3.3x |
| Crear ticket | ~400ms | ~200ms | 2x |
| Dashboard SLA | ~800ms | ~200ms | 4x (con cache) |
| Load frontend | ~2s | ~800ms | 2.5x |
---
## 🎓 Mejores Prácticas Adicionales
1. **Paginación siempre**: Nunca devolver listados sin límite
2. **Índices compuestos**: Para queries con múltiples WHERE
3. **Redis para sesiones**: Mover JWT refresh tokens a Redis
4. **CDN para assets**: Servir JS/CSS desde CDN en producción
5. **HTTP/2**: Configurar Nginx con HTTP/2
---
## 📝 Notas Importantes
- **Redis ya está corriendo**: Solo falta implementar CacheService
- **No optimizar prematuramente**: Medir primero, optimizar después
- **Testing**: Probar cada optimización para evitar regresiones
- **Monitoring**: Agregar métricas con Prometheus/Grafana (opcional)
---
¿Quieres que implemente alguna de estas optimizaciones ahora?

View File

@@ -57,6 +57,7 @@ class AuditLogResponse(AuditLogBase):
class SecurityThreatPattern(BaseModel):
"""Patrón de amenaza detectado."""
id: str = Field(description="ID único de la amenaza (pattern_id)")
type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)")
severity: str = Field(description="Severidad: low, medium, high, critical")
description: str = Field(description="Descripción de la amenaza")
@@ -65,7 +66,7 @@ class SecurityThreatPattern(BaseModel):
affected_users: list[str] = Field(default=[], description="Usuarios afectados")
first_seen: datetime = Field(description="Primera ocurrencia")
last_seen: datetime = Field(description="Última ocurrencia")
recommendations: list[str] = Field(default=[], description="Recomendaciones de acción")
recommended_action: str = Field(default="", description="Acción recomendada")
class SecurityAnalysisResponse(BaseModel):

View File

@@ -123,7 +123,7 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
"status": status,
"incident_type": "mass_deletion",
"affected_user": group['user'],
"source_ip": group['logs'][0].ip_address,
"source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None,
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
"metadata": {
"total_deletions": group['count'],
@@ -207,7 +207,7 @@ def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
"status": "investigating",
"incident_type": "privilege_escalation",
"affected_user": log.user.email,
"source_ip": log.ip_address,
"source_ip": str(log.ip_address) if log.ip_address else None,
"evidence": [f"Cambio de rol: {old_role}{new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
"metadata": {
"old_role": old_role,

View File

@@ -152,38 +152,47 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user:
threat_patterns = []
if failed_logins >= 5:
affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address]
threat_patterns.append(SecurityThreatPattern(
pattern_id="brute_force_attempt",
id="brute_force_attempt",
type="brute_force",
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
severity="high" if failed_logins >= 20 else "medium",
occurrences=failed_logins,
first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
affected_resources=[str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address][:5],
affected_ips=list(set(affected_ips_list))[:5],
affected_users=[],
recommended_action="Considerar bloquear IPs con múltiples fallos"
))
if mass_deletions >= 10:
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
threat_patterns.append(SecurityThreatPattern(
pattern_id="mass_deletion",
id="mass_deletion",
type="mass_deletion",
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
severity="critical" if mass_deletions >= 50 else "high",
occurrences=mass_deletions,
first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now),
last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now),
affected_resources=[log.resource_type for log in logs if '.delete' in log.action][:5],
affected_ips=[],
affected_users=list(set(deleting_users))[:5],
recommended_action="Revisar qué usuarios están eliminando recursos"
))
if privilege_changes >= 3:
affected_users_list = [log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values]
threat_patterns.append(SecurityThreatPattern(
pattern_id="suspicious_privilege_changes",
id="suspicious_privilege_changes",
type="privilege_escalation",
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
severity="high",
occurrences=privilege_changes,
first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
affected_resources=[log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values][:5],
affected_ips=[],
affected_users=list(set(affected_users_list))[:5],
recommended_action="Auditar cambios de roles recientes"
))

File diff suppressed because it is too large Load Diff

View File

@@ -91,7 +91,7 @@ async def get_sla_dashboard(
days=days
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
period_start = now - timedelta(days=days)
# Usar func.now() para comparaciones en SQL (evita timezone issues)
@@ -357,7 +357,7 @@ async def get_sla_violations(
sla_type=sla_type
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
db_now = func.now()
# Base query con carga de relaciones
@@ -417,18 +417,16 @@ async def get_sla_violations(
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Aplicar paginación
query = query.order_by(desc(Ticket.created_at)).offset(skip).limit(limit)
# Obtener todos los tickets sin paginación primero (los ordenaremos por tiempo vencido después)
result = await db.execute(query)
tickets = result.scalars().all()
# Formatear response
violations = []
for ticket in tickets:
# Asegurar que los datetimes de BD sean timezone-aware
sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due
sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due
# Todos los campos son timezone-naive (TIMESTAMP WITHOUT TIME ZONE)
sla_response_due = ticket.sla_response_due
sla_resolution_due = ticket.sla_resolution_due
# Determinar tipo de violación
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
@@ -479,10 +477,16 @@ async def get_sla_violations(
resolved_at=ticket.resolved_at
))
# Ordenar por tiempo vencido (de mayor a menor)
violations.sort(key=lambda v: v.hours_overdue, reverse=True)
# Aplicar paginación en Python
paginated_violations = violations[skip:skip + limit]
total_pages = (total + limit - 1) // limit
return SLAViolationsListResponse(
violations=violations,
violations=paginated_violations,
total=total,
page=(skip // limit) + 1,
per_page=limit,
@@ -515,13 +519,16 @@ async def get_tickets_at_risk(
threshold=threshold
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
db_now = func.now()
threshold_decimal = threshold / 100.0
# Query para tickets en riesgo
# Query para tickets en riesgo con relaciones precargadas
# Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold
query = select(Ticket).where(
query = select(Ticket).options(
selectinload(Ticket.assigned_to_user),
selectinload(Ticket.category)
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
@@ -576,14 +583,15 @@ async def get_tickets_at_risk(
else:
continue
# Normalizar created_at a timezone-naive para evitar errores de comparación
created_at = ticket.created_at.replace(tzinfo=None) if ticket.created_at.tzinfo else ticket.created_at
time_remaining = (due_at - now).total_seconds() / 3600
total_time = (due_at - ticket.created_at).total_seconds() / 3600
total_time = (due_at - created_at).total_seconds() / 3600
elapsed_time = total_time - time_remaining
risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0
# Cargar relaciones
await db.refresh(ticket, ['assigned_to', 'category'])
# Las relaciones ya están cargadas por selectinload
at_risk_tickets.append(SLATicketAtRisk(
ticket=TicketBasicInfo(
id=ticket.id,
@@ -599,11 +607,11 @@ async def get_tickets_at_risk(
sla_resolution_hours=ticket.category.sla_resolution_hours
) if ticket.category else None,
assigned_to=UserBasicInfo(
id=ticket.assigned_to.id,
first_name=ticket.assigned_to.first_name,
last_name=ticket.assigned_to.last_name,
email=ticket.assigned_to.email
) if ticket.assigned_to else None,
id=ticket.assigned_to_user.id,
first_name=ticket.assigned_to_user.first_name,
last_name=ticket.assigned_to_user.last_name,
email=ticket.assigned_to_user.email
) if ticket.assigned_to_user else None,
sla_type=sla_type,
sla_due_at=due_at,
time_remaining_hours=time_remaining,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
# Scripts de Prueba de Seguridad
Scripts para generar datos de prueba para el análisis de seguridad.
## 📋 Scripts Disponibles
### 1. `generate_security_test_data.py`
Genera un conjunto completo de logs de auditoría para probar todas las funcionalidades del análisis de seguridad.
#### Uso
```bash
# Asegúrate de estar en el entorno virtual
cd backend
python scripts/generate_security_test_data.py
```
#### Qué Genera
- **25 intentos fallidos de login** → Amenaza HIGH de fuerza bruta
- **55 eliminaciones masivas** → Amenaza CRITICAL
- **5 cambios de privilegios** → Amenaza HIGH de escalación de privilegios
- **20 logs normales** → Actividad regular para contexto
#### Limpiar Datos de Prueba
```bash
python scripts/generate_security_test_data.py cleanup
```
Esto eliminará **TODOS** los logs de auditoría de las últimas 24 horas.
---
## 🎯 Escenarios de Prueba
### Escenario 1: Sistema Limpio (Sin Amenazas)
```bash
# Limpiar todos los logs
python scripts/generate_security_test_data.py cleanup
```
**Resultado esperado:**
- Dashboard con todos los contadores en 0
- Tab "Crítico" vacío
- Mensaje: "Sistema Seguro"
---
### Escenario 2: Solo Amenazas Leves
Modifica el script para generar solo 6 intentos fallidos (MEDIUM severity):
```python
# En generate_security_test_data.py, línea ~70
for i in range(6): # Cambiar de 25 a 6
```
**Resultado esperado:**
- 1 amenaza MEDIUM en tab correspondiente
- Tab "Crítico" vacío
- Nivel de riesgo: LOW o MEDIUM
---
### Escenario 3: Amenazas Críticas
Ejecuta el script completo:
```bash
python scripts/generate_security_test_data.py
```
**Resultado esperado:**
- 1 amenaza CRÍTICA (eliminaciones masivas)
- 2 amenazas HIGH (login fallidos + privilegios)
- Tab "Crítico" con 1 amenaza
- Nivel de riesgo: CRITICAL
---
## 🔒 Umbrales de Detección
| Tipo de Amenaza | Umbral Detección | Severidades |
|-----------------|------------------|-------------|
| **Fuerza Bruta** | ≥5 intentos fallidos | MEDIUM (5-19), HIGH (≥20) |
| **Eliminaciones Masivas** | ≥10 eliminaciones | HIGH (10-49), **CRITICAL (≥50)** |
| **Cambios de Privilegios** | ≥3 cambios de rol | HIGH (siempre) |
---
## 🧪 Verificar Resultados
1. **Accede al panel de auditoría**: http://localhost:3001/audit/security
2. **Verifica los contadores del dashboard:**
- Nivel de Riesgo
- Amenazas Detectadas
- Intentos Fallidos
- IPs Sospechosas
- Acciones Críticas
3. **Prueba los tabs:**
- Todas: Debe mostrar amenazas activas
- Crítico: Solo amenazas critical (si hay)
- High: Amenazas de alta severidad
- Medium: Amenazas de severidad media
- Low: Amenazas de baja severidad
- Resueltas: Amenazas marcadas como resueltas
4. **Prueba la búsqueda:**
- Busca por IP: `192.168.1.100`
- Busca por descripción: `intentos fallidos`
- Busca por tipo: `brute_force`
5. **Prueba los filtros:**
- Filtra por tipo de amenaza
- Combina búsqueda + filtro
6. **Prueba las acciones:**
- Selecciona múltiples amenazas
- Resuelve en batch
- Marca como resuelta individualmente
- Reabre amenazas resueltas
---
## ⚠️ Advertencias
- **NO ejecutar en producción**: Estos scripts son SOLO para desarrollo/testing
- **Los datos son ficticios**: IPs, usuarios y acciones son simulados
- **Cleanup elimina TODO**: El comando cleanup elimina TODOS los logs de las últimas 24h, no solo los de prueba
---
## 🐛 Troubleshooting
### Error: "No se encontró ningún tenant"
```bash
# Ejecuta las migraciones
cd backend
alembic upgrade head
```
### Error: "No se encontró ningún usuario"
```bash
# Crea un usuario de prueba
python scripts/create_test_user.py
```
### La página no muestra amenazas
- Verifica que el backend esté corriendo: `uvicorn app.main:app --reload`
- Revisa la consola del navegador para errores
- Verifica que los logs se crearon: `SELECT COUNT(*) FROM audit_logs WHERE created_at >= NOW() - INTERVAL '24 hours';`
### Las fechas no son de hoy
- Los logs se crean con timestamps aleatorios en las últimas 24h
- Si todos tienen la misma fecha, es porque se generaron en el mismo segundo (normal)
---
## 📝 Personalizar Generación
Para crear escenarios personalizados, edita `generate_security_test_data.py`:
```python
# Cambiar cantidad de intentos fallidos
for i in range(50): # Más intentos = mayor severidad
# Cambiar IPs sospechosas
suspicious_ips = ["1.2.3.4", "5.6.7.8"]
# Cambiar período temporal
time_offset = timedelta(hours=12) # Todos en las últimas 12h
# Agregar más tipos de amenazas
# Agrega nuevos bloques de generación siguiendo el patrón
```
---
## 🚀 Flujo Recomendado de Prueba
1. **Limpia el sistema**: `python scripts/generate_security_test_data.py cleanup`
2. **Verifica sistema limpio**: Accede a la página, debe estar vacía
3. **Genera datos completos**: `python scripts/generate_security_test_data.py`
4. **Prueba todas las funcionalidades**: tabs, filtros, búsqueda, acciones
5. **Marca algunas como resueltas**: Prueba el flujo de resolución
6. **Verifica tab "Resueltas"**: Confirma que aparecen ahí
7. **Reabre algunas**: Prueba el flujo de reapertura
8. **Limpia al finalizar**: `python scripts/generate_security_test_data.py cleanup`

View File

@@ -0,0 +1,240 @@
"""
Script para generar datos de prueba de seguridad en logs de auditoría.
Esto permite probar la funcionalidad de análisis de seguridad con diferentes tipos de amenazas.
"""
import asyncio
import sys
from pathlib import Path
from datetime import datetime, timedelta, timezone
import uuid
import random
# Agregar el directorio raíz al path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.audit import AuditLog
from app.models.user import User
from app.models.tenant import Tenant
async def generate_test_data():
"""Genera logs de auditoría de prueba para análisis de seguridad."""
async with AsyncSessionLocal() as db:
# Obtener tenant y usuarios de prueba
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
return
user_result = await db.execute(select(User).where(User.tenant_id == tenant.id).limit(1))
user = user_result.scalar_one_or_none()
if not user:
print("❌ No se encontró ningún usuario. Crea un usuario primero.")
return
print(f"✅ Usando tenant: {tenant.name}")
print(f"✅ Usando usuario: {user.email}")
print()
now = datetime.now(timezone.utc)
# IPs de prueba
suspicious_ips = [
"192.168.1.100",
"10.0.0.50",
"172.16.0.10",
"203.0.113.42",
"198.51.100.88"
]
logs_created = 0
# ============================================
# 1. GENERAR INTENTOS FALLIDOS DE LOGIN (Fuerza Bruta)
# ============================================
print("🔐 Generando intentos fallidos de login...")
# Generar 25 intentos fallidos (esto hará que sea HIGH severity)
for i in range(25):
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
log = AuditLog(
id=uuid.uuid4(),
tenant_id=tenant.id,
user_id=user.id,
action="user.login_failed",
resource_type="auth",
resource_id=None,
ip_address=random.choice(suspicious_ips),
user_agent="Mozilla/5.0 (Test Browser)",
metadata={"reason": "invalid_credentials", "username": f"test_user_{i}"},
created_at=now - time_offset
)
db.add(log)
logs_created += 1
print(f" ✓ Creados {25} intentos fallidos de login (HIGH severity)")
# ============================================
# 2. GENERAR ELIMINACIONES MASIVAS (CRITICAL)
# ============================================
print("🗑️ Generando eliminaciones masivas...")
resources = ["ticket", "comment", "attachment", "category", "user"]
# Generar 55 eliminaciones (esto hará que sea CRITICAL severity)
for i in range(55):
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
resource = random.choice(resources)
log = AuditLog(
id=uuid.uuid4(),
tenant_id=tenant.id,
user_id=user.id,
action=f"{resource}.delete",
resource_type=resource,
resource_id=uuid.uuid4(),
ip_address=random.choice(suspicious_ips),
user_agent="Mozilla/5.0 (Test Browser)",
metadata={"deleted_by": user.email},
created_at=now - time_offset
)
db.add(log)
logs_created += 1
print(f" ✓ Creadas {55} eliminaciones masivas (CRITICAL severity)")
# ============================================
# 3. GENERAR CAMBIOS DE PRIVILEGIOS (HIGH)
# ============================================
print("👤 Generando cambios de privilegios...")
roles = ["AGENT", "CLIENT_USER", "AUDITOR", "SUPPORT_MANAGER", "ADMIN"]
# Generar 5 cambios de rol (esto hará que sea HIGH severity)
for i in range(5):
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
old_role = random.choice(roles)
new_role = random.choice([r for r in roles if r != old_role])
log = AuditLog(
id=uuid.uuid4(),
tenant_id=tenant.id,
user_id=user.id,
action="user.update",
resource_type="user",
resource_id=uuid.uuid4(),
ip_address=random.choice(suspicious_ips),
user_agent="Mozilla/5.0 (Test Browser)",
old_values={"role": old_role},
new_values={"role": new_role},
metadata={"changed_by": user.email},
created_at=now - time_offset
)
db.add(log)
logs_created += 1
print(f" ✓ Creados {5} cambios de privilegios (HIGH severity)")
# ============================================
# 4. GENERAR LOGS NORMALES (para dar contexto)
# ============================================
print("📋 Generando logs de actividad normal...")
normal_actions = [
"ticket.create",
"ticket.update",
"comment.create",
"user.login",
"ticket.view",
]
for i in range(20):
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
action = random.choice(normal_actions)
log = AuditLog(
id=uuid.uuid4(),
tenant_id=tenant.id,
user_id=user.id,
action=action,
resource_type=action.split('.')[0],
resource_id=uuid.uuid4(),
ip_address=random.choice(suspicious_ips),
user_agent="Mozilla/5.0 (Test Browser)",
metadata={"action": "normal_activity"},
created_at=now - time_offset
)
db.add(log)
logs_created += 1
print(f" ✓ Creados {20} logs de actividad normal")
# Guardar todo
await db.commit()
print()
print("=" * 60)
print(f"✅ GENERACIÓN COMPLETADA")
print(f" Total de logs creados: {logs_created}")
print()
print("📊 Amenazas esperadas en el análisis:")
print(" 🔴 1 amenaza CRÍTICA: 55 eliminaciones masivas")
print(" 🟠 1 amenaza HIGH: 25 intentos fallidos de login")
print(" 🟠 1 amenaza HIGH: 5 cambios de privilegios")
print()
print("🌐 Accede a la página de seguridad para ver el análisis")
print("=" * 60)
async def cleanup_test_data():
"""Elimina los logs de auditoría de prueba."""
async with AsyncSessionLocal() as db:
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró ningún tenant.")
return
# Eliminar logs de las últimas 24 horas
now = datetime.now(timezone.utc)
cutoff = now - timedelta(hours=24)
result = await db.execute(
select(AuditLog).where(
AuditLog.tenant_id == tenant.id,
AuditLog.created_at >= cutoff
)
)
logs = result.scalars().all()
if not logs:
print(" No hay logs de prueba para eliminar.")
return
for log in logs:
await db.delete(log)
await db.commit()
print(f"✅ Eliminados {len(logs)} logs de prueba de las últimas 24 horas")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
print("🧹 Limpiando datos de prueba...")
asyncio.run(cleanup_test_data())
else:
print("🚀 Generando datos de prueba para análisis de seguridad...")
print()
asyncio.run(generate_test_data())
print()
print("💡 Para limpiar estos datos de prueba, ejecuta:")
print(" python scripts/generate_security_test_data.py cleanup")

View File

@@ -0,0 +1,399 @@
"""
Script para generar datos de prueba de SLA Management.
Crea tickets con diferentes estados de SLA para probar el dashboard.
"""
import asyncio
import sys
from pathlib import Path
from datetime import datetime, timedelta, timezone
import uuid
import random
# Agregar el directorio raíz al path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.models.user import User
from app.models.tenant import Tenant
from app.models.system import System
async def generate_sla_test_data():
"""Genera tickets de prueba con diferentes estados de SLA."""
async with AsyncSessionLocal() as db:
# Obtener tenant y usuarios
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
return
# Obtener usuarios
users_result = await db.execute(
select(User).where(User.tenant_id == tenant.id).limit(5)
)
users = list(users_result.scalars().all())
if not users:
print("❌ No se encontraron usuarios. Crea usuarios primero.")
return
creator = users[0]
agents = users if len(users) > 1 else [creator]
# Obtener o crear categorías
categories_result = await db.execute(
select(Category).where(Category.tenant_id == tenant.id)
)
categories = list(categories_result.scalars().all())
if not categories:
print("📁 Creando categorías de prueba...")
category_data = [
{"name": "Soporte Técnico", "sla_response_hours": 2, "sla_resolution_hours": 24, "color": "#3B82F6"},
{"name": "Facturación", "sla_response_hours": 4, "sla_resolution_hours": 48, "color": "#10B981"},
{"name": "Incidente Crítico", "sla_response_hours": 1, "sla_resolution_hours": 8, "color": "#EF4444"},
{"name": "Consulta General", "sla_response_hours": 8, "sla_resolution_hours": 72, "color": "#6B7280"},
]
for cat_data in category_data:
category = Category(
id=uuid.uuid4(),
tenant_id=tenant.id,
name=cat_data["name"],
description=f"Categoría de {cat_data['name']}",
color=cat_data["color"],
sla_response_hours=cat_data["sla_response_hours"],
sla_resolution_hours=cat_data["sla_resolution_hours"],
is_active=True
)
db.add(category)
categories.append(category)
await db.commit()
print(f" ✓ Creadas {len(categories)} categorías")
# Obtener o crear sistemas afectados
systems_result = await db.execute(
select(System).where(System.tenant_id == tenant.id)
)
systems = list(systems_result.scalars().all())
if not systems:
print("🖥️ Creando sistemas de prueba...")
system_names = ["Portal Web", "API REST", "Base de Datos", "Sistema de Pagos"]
for sys_name in system_names:
system = System(
id=uuid.uuid4(),
tenant_id=tenant.id,
name=sys_name,
description=f"Sistema {sys_name}",
is_active=True
)
db.add(system)
systems.append(system)
await db.commit()
print(f" ✓ Creados {len(systems)} sistemas")
print(f"✅ Usando tenant: {tenant.name}")
print(f"✅ Usuarios disponibles: {len(users)}")
print(f"✅ Categorías disponibles: {len(categories)}")
print()
now = datetime.now(timezone.utc)
tickets_created = 0
# Función auxiliar para crear ticket
def create_ticket(
subject: str,
description: str,
priority: TicketPriority,
status: TicketStatus,
category: Category,
created_hours_ago: int,
first_response_hours_after: int = None,
resolved_hours_after: int = None,
assigned: bool = True
):
nonlocal tickets_created
ticket_id = uuid.uuid4()
created_at = now - timedelta(hours=created_hours_ago)
# Calcular SLA deadlines basados en la categoría (sin timezone para la BD)
sla_response_due = (created_at + timedelta(hours=category.sla_response_hours)).replace(tzinfo=None)
sla_resolution_due = (created_at + timedelta(hours=category.sla_resolution_hours)).replace(tzinfo=None)
# Primera respuesta (si aplica)
first_response_at = None
if first_response_hours_after is not None:
first_response_at = (created_at + timedelta(hours=first_response_hours_after)).replace(tzinfo=None)
# Resolución (si aplica)
resolved_at = None
if resolved_hours_after is not None:
resolved_at = (created_at + timedelta(hours=resolved_hours_after)).replace(tzinfo=None)
ticket = Ticket(
id=ticket_id,
tenant_id=tenant.id,
ticket_number=f"TKT-{1000 + tickets_created}",
subject=subject,
description=description,
status=status,
priority=priority,
created_by=creator.id,
assigned_to=random.choice(agents).id if assigned else None,
category_id=category.id,
affected_system_id=random.choice(systems).id if systems else None,
sla_response_due=sla_response_due,
sla_resolution_due=sla_resolution_due,
first_response_at=first_response_at,
resolved_at=resolved_at,
created_at=created_at,
updated_at=resolved_at or first_response_at or created_at
)
db.add(ticket)
tickets_created += 1
return ticket
# ============================================
# 1. TICKETS CUMPLIENDO SLA RESPONSE (Verde)
# ============================================
print("✅ Generando tickets CUMPLIENDO Response SLA...")
for i in range(15):
category = random.choice(categories)
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
# Creado hace X horas, respondido ANTES del deadline
created_hours_ago = random.randint(24, 120)
response_time = random.uniform(0.5, category.sla_response_hours * 0.7) # 70% del SLA
status = random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER])
create_ticket(
subject=f"Ticket con respuesta a tiempo #{i+1}",
description=f"Este ticket fue respondido dentro del SLA de {category.name}",
priority=priority,
status=status,
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=response_time,
assigned=True
)
print(f" ✓ Creados 15 tickets cumpliendo Response SLA")
# ============================================
# 2. TICKETS VIOLANDO SLA RESPONSE (Rojo)
# ============================================
print("🔴 Generando tickets VIOLANDO Response SLA...")
for i in range(8):
category = random.choice(categories)
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
# Creado hace más tiempo que el SLA, SIN respuesta
created_hours_ago = category.sla_response_hours + random.randint(1, 10)
create_ticket(
subject=f"Ticket SIN respuesta - VIOLACIÓN #{i+1}",
description=f"Este ticket lleva {created_hours_ago}h sin respuesta (SLA: {category.sla_response_hours}h)",
priority=priority,
status=random.choice([TicketStatus.NEW, TicketStatus.TRIAGE]),
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=None, # Sin respuesta!
assigned=random.choice([True, False])
)
print(f" ✓ Creados 8 tickets VIOLANDO Response SLA")
# ============================================
# 3. TICKETS EN RIESGO Response (Amarillo)
# ============================================
print("⚠️ Generando tickets EN RIESGO Response SLA...")
for i in range(10):
category = random.choice(categories)
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.URGENT])
# Creado hace tiempo, cerca del deadline (80-95% consumido)
sla_hours = category.sla_response_hours
time_consumed = random.uniform(0.8, 0.95) * sla_hours
created_hours_ago = time_consumed
create_ticket(
subject=f"Ticket cerca de vencer respuesta #{i+1}",
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de respuesta",
priority=priority,
status=random.choice([TicketStatus.TRIAGE, TicketStatus.NEW]),
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=None, # Aún sin respuesta
assigned=True
)
print(f" ✓ Creados 10 tickets EN RIESGO Response SLA")
# ============================================
# 4. TICKETS CUMPLIENDO SLA RESOLUTION
# ============================================
print("✅ Generando tickets CUMPLIENDO Resolution SLA...")
for i in range(20):
category = random.choice(categories)
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
# Creado, respondido y resuelto dentro del SLA
created_hours_ago = random.randint(72, 240)
response_time = random.uniform(1, category.sla_response_hours * 0.5)
resolution_time = random.uniform(
response_time + 1,
category.sla_resolution_hours * 0.8
)
create_ticket(
subject=f"Ticket resuelto a tiempo #{i+1}",
description=f"Este ticket fue resuelto dentro del SLA de {category.name}",
priority=priority,
status=random.choice([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=response_time,
resolved_hours_after=resolution_time,
assigned=True
)
print(f" ✓ Creados 20 tickets cumpliendo Resolution SLA")
# ============================================
# 5. TICKETS VIOLANDO SLA RESOLUTION
# ============================================
print("🔴 Generando tickets VIOLANDO Resolution SLA...")
for i in range(6):
category = random.choice(categories)
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
# Creado hace más del SLA de resolución, con respuesta pero sin resolver
created_hours_ago = category.sla_resolution_hours + random.randint(5, 48)
response_time = random.uniform(1, category.sla_response_hours * 0.5)
create_ticket(
subject=f"Ticket sin resolver - VIOLACIÓN #{i+1}",
description=f"Ticket lleva {created_hours_ago}h sin resolver (SLA: {category.sla_resolution_hours}h)",
priority=priority,
status=random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER]),
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=response_time,
resolved_hours_after=None, # Sin resolver!
assigned=True
)
print(f" ✓ Creados 6 tickets VIOLANDO Resolution SLA")
# ============================================
# 6. TICKETS EN RIESGO Resolution
# ============================================
print("⚠️ Generando tickets EN RIESGO Resolution SLA...")
for i in range(12):
category = random.choice(categories)
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH])
# Con respuesta, cerca del deadline de resolución
sla_hours = category.sla_resolution_hours
time_consumed = random.uniform(0.75, 0.95) * sla_hours
created_hours_ago = time_consumed
response_time = random.uniform(0.5, category.sla_response_hours * 0.5)
create_ticket(
subject=f"Ticket cerca de vencer resolución #{i+1}",
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de resolución",
priority=priority,
status=TicketStatus.IN_PROGRESS,
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=response_time,
resolved_hours_after=None,
assigned=True
)
print(f" ✓ Creados 12 tickets EN RIESGO Resolution SLA")
# Guardar todos los tickets
await db.commit()
print()
print("=" * 70)
print("✅ GENERACIÓN DE DATOS SLA COMPLETADA")
print(f" Total de tickets creados: {tickets_created}")
print()
print("📊 Distribución esperada:")
print(" ✅ Response cumplidos: 15 tickets")
print(" 🔴 Response violados: 8 tickets")
print(" ⚠️ Response en riesgo: 10 tickets")
print(" ✅ Resolution cumplidos: 20 tickets")
print(" 🔴 Resolution violados: 6 tickets")
print(" ⚠️ Resolution en riesgo: 12 tickets")
print()
print("🌐 Ve los resultados en:")
print(" Dashboard SLA: http://localhost:3001/sla")
print("=" * 70)
async def cleanup_sla_test_data():
"""Elimina tickets de prueba."""
async with AsyncSessionLocal() as db:
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró ningún tenant.")
return
# Eliminar tickets que empiezan con TKT-
result = await db.execute(
select(Ticket).where(
Ticket.tenant_id == tenant.id,
Ticket.ticket_number.like('TKT-%')
)
)
tickets = result.scalars().all()
if not tickets:
print(" No hay tickets de prueba para eliminar.")
return
for ticket in tickets:
await db.delete(ticket)
await db.commit()
print(f"✅ Eliminados {len(tickets)} tickets de prueba")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
print("🧹 Limpiando datos de prueba de SLA...")
print()
asyncio.run(cleanup_sla_test_data())
else:
print("🚀 Generando datos de prueba para SLA Management...")
print()
asyncio.run(generate_sla_test_data())
print()
print("💡 Para limpiar estos datos de prueba, ejecuta:")
print(" python scripts/generate_sla_test_data.py cleanup")

View File

@@ -50,4 +50,4 @@
on:dismiss={() => toast.dismiss(toastMessage.id)}
/>
{/each}
</div>
</div>

View File

@@ -16,28 +16,28 @@
description: 'Gestión de organizaciones y tenants',
icon: 'users',
href: '/tenants',
color: 'bg-blue-500'
color: 'bg-blue-600'
},
{
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'user-plus',
href: '/users',
color: 'bg-green-500'
color: 'bg-green-600'
},
{
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'server',
href: '/systems',
color: 'bg-purple-500'
color: 'bg-gray-700'
},
{
title: 'Categorías',
description: 'Clasificación de tickets',
icon: 'tag',
href: '/categories',
color: 'bg-orange-500'
color: 'bg-orange-600'
}
];
</script>
@@ -62,32 +62,20 @@
{#each cards as card}
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="{card.color} rounded-md p-3">
<!-- Simple SVG Icon placeholder since Icon component might expect specific names that map to SVGs -->
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="font-medium text-cyan-700 hover:text-cyan-900">
<span class="font-medium text-blue-700 hover:text-blue-900">
Ver detalles
</span>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,19 +7,61 @@
// Estado
let isLoading = false;
let analysis = null;
let analysis: any = null;
let analysisHours = 24;
let selectedThreat = null;
let selectedThreat: any = null;
let showActionModal = false;
let actionType = '';
let actionTarget = '';
let actionReason = '';
let actionDuration = 60;
// Filtros y búsqueda
let activeTab: 'all' | 'critical' | 'high' | 'medium' | 'low' | 'resolved' = 'all';
let searchQuery = '';
let filterType = '';
let showFilters = false;
let selectedThreats = new Set<string>();
// Estado de amenazas resueltas (simulado - idealmente vendría del backend)
let resolvedThreats = new Set<string>();
// Usuario actual
$: currentUser = $auth.user;
$: canExecuteActions = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER');
// Amenazas filtradas
$: filteredThreats = analysis?.threats?.filter((threat: any) => {
const matchesTab =
activeTab === 'all' ? !resolvedThreats.has(threat.id) :
activeTab === 'resolved' ? resolvedThreats.has(threat.id) :
(threat.severity === activeTab && !resolvedThreats.has(threat.id));
const matchesSearch = !searchQuery ||
threat.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
threat.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
threat.affected_ips.some((ip: string) => ip.includes(searchQuery)) ||
threat.affected_users.some((user: string) => user.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesType = !filterType || threat.type === filterType;
return matchesTab && matchesSearch && matchesType;
}) || [];
// Contadores por tab
$: tabCounts = {
all: analysis?.threats?.filter((t: any) => !resolvedThreats.has(t.id)).length || 0,
critical: analysis?.threats?.filter((t: any) => t.severity === 'critical' && !resolvedThreats.has(t.id)).length || 0,
high: analysis?.threats?.filter((t: any) => t.severity === 'high' && !resolvedThreats.has(t.id)).length || 0,
medium: analysis?.threats?.filter((t: any) => t.severity === 'medium' && !resolvedThreats.has(t.id)).length || 0,
low: analysis?.threats?.filter((t: any) => t.severity === 'low' && !resolvedThreats.has(t.id)).length || 0,
resolved: resolvedThreats.size
};
// Tipos únicos de amenazas
$: threatTypes = analysis?.threats ?
[...new Set(analysis.threats.map((t: any) => t.type))] : [];
/**
* Cargar análisis de seguridad
*/
@@ -44,32 +86,77 @@
}
/**
* Obtener color según nivel de riesgo
* Obtener color según nivel de riesgo (neutral)
*/
function getRiskColor(level: string) {
const colors: any = {
safe: 'bg-green-100 text-green-800 border-green-300',
low: 'bg-blue-100 text-blue-800 border-blue-300',
medium: 'bg-yellow-100 text-yellow-800 border-yellow-300',
high: 'bg-orange-100 text-orange-800 border-orange-300',
critical: 'bg-red-100 text-red-800 border-red-300'
safe: 'bg-gray-50 text-green-700 border border-green-200',
low: 'bg-gray-50 text-blue-700 border border-blue-200',
medium: 'bg-gray-50 text-yellow-800 border border-yellow-200',
high: 'bg-gray-50 text-orange-700 border border-orange-200',
critical: 'bg-gray-50 text-red-700 border border-red-200'
};
return colors[level] || colors.low;
}
/**
* Obtener color de severidad de amenaza
* Obtener color de severidad de amenaza (neutral)
*/
function getSeverityColor(severity: string) {
const colors: any = {
low: 'bg-blue-600 text-white',
medium: 'bg-yellow-500 text-white',
high: 'bg-orange-600 text-white',
critical: 'bg-red-600 text-white'
low: 'bg-gray-50 text-blue-700 border border-blue-200',
medium: 'bg-gray-50 text-yellow-800 border border-yellow-200',
high: 'bg-gray-50 text-orange-700 border border-orange-200',
critical: 'bg-gray-50 text-red-700 border border-red-200'
};
return colors[severity] || colors.low;
}
/**
* Marcar amenaza como resuelta
*/
function toggleThreatResolved(threatId: string) {
if (resolvedThreats.has(threatId)) {
resolvedThreats.delete(threatId);
} else {
resolvedThreats.add(threatId);
}
resolvedThreats = resolvedThreats; // Trigger reactivity
toast.success(resolvedThreats.has(threatId) ? 'Amenaza marcada como resuelta' : 'Amenaza marcada como activa');
}
/**
* Seleccionar/deseleccionar amenaza
*/
function toggleThreatSelection(threatId: string) {
if (selectedThreats.has(threatId)) {
selectedThreats.delete(threatId);
} else {
selectedThreats.add(threatId);
}
selectedThreats = selectedThreats;
}
/**
* Resolver amenazas en lote
*/
function resolveSelectedThreats() {
selectedThreats.forEach(id => resolvedThreats.add(id));
resolvedThreats = resolvedThreats;
selectedThreats.clear();
selectedThreats = selectedThreats;
toast.success(`${resolvedThreats.size} amenazas resueltas`);
}
/**
* Limpiar filtros
*/
function clearFilters() {
searchQuery = '';
filterType = '';
activeTab = 'all';
}
/**
* Obtener icono de tipo de amenaza
*/
@@ -174,130 +261,140 @@
</script>
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<!-- Header -->
<!-- Header con Breadcrumb -->
<div class="mb-6">
<nav class="flex mb-3" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li>
<a href="/audit" class="text-gray-500 hover:text-gray-700 text-sm">Auditoría</a>
</li>
<li class="flex items-center">
<span class="text-gray-400 mx-2">/</span>
<span class="text-sm font-medium text-gray-900">Análisis de Seguridad</span>
</li>
</ol>
</nav>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Análisis de Seguridad
</h1>
<p class="mt-1 text-sm text-gray-500">
Detección de amenazas y análisis de vulnerabilidades
</p>
<div class="flex items-center gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900">Análisis de Seguridad</h1>
<p class="text-sm text-gray-500">Detección de amenazas y gestión de incidentes</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
on:click={loadSecurityAnalysis}
class="px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800 transition-colors"
>
Actualizar
</button>
</div>
<button
on:click={() => loadSecurityAnalysis()}
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar
</button>
</div>
</div>
<!-- Selector de Período -->
<div class="bg-white shadow rounded-lg p-4 mb-6">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium text-gray-700">Período de Análisis</span>
<div class="bg-white shadow-sm rounded-lg p-4 mb-6 border border-gray-200">
<div class="flex items-center gap-2 mb-3">
<span class="text-sm font-semibold text-gray-700">Período de Análisis</span>
</div>
{#if analysis}
<span class="text-xs text-gray-500">
Última actualización: {formatDate(analysis.generated_at)}
</span>
{/if}
</div>
<div class="flex flex-wrap gap-2">
<button
on:click={() => changeAnalysisPeriod(24)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
>
Últimas 24 horas
Últimas 24h
</button>
<button
on:click={() => changeAnalysisPeriod(48)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
>
Últimas 48 horas
Últimas 48h
</button>
<button
on:click={() => changeAnalysisPeriod(168)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
>
Última semana
</button>
<button
on:click={() => changeAnalysisPeriod(720)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 720 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
>
Último mes
</button>
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-600"></div>
<div class="flex justify-center items-center py-20">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-700 mx-auto mb-4"></div>
<p class="text-sm text-gray-500">Analizando seguridad...</p>
</div>
</div>
{:else if analysis}
<!-- Resumen de Riesgo -->
<div class="bg-white shadow rounded-lg p-6 mb-6 border-l-4 {getRiskColor(analysis.overall_risk_level)}">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">Nivel de Riesgo General</h3>
<p class="text-sm text-gray-600 mt-1">Análisis de {analysis.analysis_period_hours} horas</p>
<!-- Dashboard KPIs -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<!-- Nivel de Riesgo -->
<div class="bg-white rounded-lg shadow-sm p-5 border border-gray-200 lg:col-span-1">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-gray-600 uppercase">Nivel de Riesgo</span>
</div>
<div class="text-right">
<span class="inline-block px-4 py-2 text-2xl font-bold rounded-lg {getRiskColor(analysis.overall_risk_level)}">
<div class="mt-2">
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-bold {getRiskColor(analysis.overall_risk_level)}">
{analysis.overall_risk_level.toUpperCase()}
</span>
<p class="text-xs text-gray-500 mt-1">Generado: {formatDate(analysis.generated_at)}</p>
</div>
<p class="text-xs text-gray-500 mt-2">{analysis.analysis_period_hours}h análisis</p>
</div>
</div>
<!-- Estadísticas Rápidas -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4">
<!-- Amenazas Detectadas -->
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-red-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Amenazas Detectadas</p>
<p class="text-2xl font-bold text-red-600">{analysis.total_threats_detected}</p>
<div class="flex-1">
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Amenazas</p>
<p class="text-2xl font-bold text-gray-900">{analysis.total_threats_detected}</p>
<p class="text-xs text-gray-500 mt-1">Detectadas</p>
</div>
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<!-- Intentos Fallidos -->
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-orange-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Intentos Fallidos</p>
<p class="text-2xl font-bold text-orange-600">{analysis.failed_login_attempts}</p>
<div class="flex-1">
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Intentos Fallidos</p>
<p class="text-2xl font-bold text-gray-900">{analysis.failed_login_attempts}</p>
<p class="text-xs text-gray-500 mt-1">Logins rechazados</p>
</div>
<svg class="w-10 h-10 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<!-- IPs Sospechosas -->
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-yellow-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">IPs Sospechosas</p>
<p class="text-2xl font-bold text-yellow-600">{analysis.suspicious_ips_count}</p>
<div class="flex-1">
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">IPs Sospechosas</p>
<p class="text-2xl font-bold text-gray-900">{analysis.suspicious_ips_count}</p>
<p class="text-xs text-gray-500 mt-1">En seguimiento</p>
</div>
<svg class="w-10 h-10 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<!-- Acciones Críticas -->
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-blue-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Acciones Críticas</p>
<p class="text-2xl font-bold text-red-600">{analysis.critical_actions_count}</p>
<div class="flex-1">
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Acciones Críticas</p>
<p class="text-2xl font-bold text-gray-900">{analysis.critical_actions_count}</p>
<p class="text-xs text-gray-500 mt-1">Registradas</p>
</div>
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
</div>
@@ -305,162 +402,335 @@
<!-- Recomendaciones Generales -->
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
<ul class="space-y-1">
{#each analysis.recommended_actions as action}
<li class="text-sm text-blue-800 flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{action}
</li>
{/each}
</ul>
</div>
<div>
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
<ul class="space-y-1">
{#each analysis.recommended_actions as action}
<li class="text-sm text-blue-800">{action}</li>
{/each}
</ul>
</div>
</div>
{/if}
<!-- Tabs y Filtros -->
<div class="bg-white shadow-sm rounded-lg mb-6 border border-gray-200 overflow-hidden">
<!-- Tabs -->
<div class="border-b border-gray-200 bg-gray-50">
<div class="flex overflow-x-auto">
<button
on:click={() => activeTab = 'all'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'all' ? 'border-blue-700 text-blue-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Todas
{#if tabCounts.all > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'all' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.all}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'critical'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'critical' ? 'border-red-600 text-red-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Críticas
{#if tabCounts.critical > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'critical' ? 'bg-red-100 text-red-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.critical}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'high'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'high' ? 'border-orange-500 text-orange-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Altas
{#if tabCounts.high > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.high}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'medium'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'medium' ? 'border-yellow-500 text-yellow-800 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Medias
{#if tabCounts.medium > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-200 text-gray-700'}">
{tabCounts.medium}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'low'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'low' ? 'border-blue-500 text-blue-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Bajas
{#if tabCounts.low > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'low' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.low}
</span>
{/if}
</button>
<button
on:click={() => activeTab = 'resolved'}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'resolved' ? 'border-green-500 text-green-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
>
Resueltas
{#if tabCounts.resolved > 0}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'resolved' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-700'}">
{tabCounts.resolved}
</span>
{/if}
</button>
</div>
</div>
<!-- Barra de Búsqueda y Filtros -->
<div class="p-4 bg-white">
<div class="flex flex-col md:flex-row gap-3">
<!-- Búsqueda -->
<div class="flex-1 relative">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar por descripción, IP, usuario..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<!-- Filtro por Tipo -->
<select
bind:value={filterType}
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
>
<option value="">Todos los tipos</option>
{#each threatTypes as type}
<option value={type}>{getThreatTypeText(type)}</option>
{/each}
</select>
<!-- Limpiar -->
{#if searchQuery || filterType}
<button
on:click={clearFilters}
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900"
>
Limpiar
</button>
{/if}
</div>
<!-- Acciones en Lote -->
{#if selectedThreats.size > 0 && canExecuteActions}
<div class="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
<span class="text-sm font-medium text-blue-900">
{selectedThreats.size} amenaza{selectedThreats.size > 1 ? 's' : ''} seleccionada{selectedThreats.size > 1 ? 's' : ''}
</span>
<div class="flex gap-2">
<button
on:click={resolveSelectedThreats}
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 font-medium"
>
Resolver
</button>
<button
on:click={() => { selectedThreats.clear(); selectedThreats = selectedThreats; }}
class="px-3 py-1.5 bg-white border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50"
>
Cancelar
</button>
</div>
</div>
{/if}
</div>
</div>
<!-- Lista de Amenazas -->
{#if analysis.threats && analysis.threats.length > 0}
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
{#each analysis.threats as threat}
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-gray-900' : threat.severity === 'high' ? 'border-gray-600' : threat.severity === 'medium' ? 'border-gray-400' : 'border-gray-200'}">
<!-- Header de Amenaza -->
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-gray-100' : threat.severity === 'high' ? 'bg-gray-100' : threat.severity === 'medium' ? 'bg-gray-100' : 'bg-gray-50'}">
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-gray-900' : threat.severity === 'high' ? 'text-gray-700' : threat.severity === 'medium' ? 'text-gray-600' : 'text-gray-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
</svg>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-lg font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
<span class="px-2 py-1 text-xs font-semibold rounded-full {getSeverityColor(threat.severity)}">
{threat.severity.toUpperCase()}
</span>
{#if filteredThreats.length > 0}
<div class="space-y-3">
{#each filteredThreats as threat}
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden {resolvedThreats.has(threat.id) ? 'opacity-60' : ''}">
<!-- Header Compacto -->
<div class="p-4">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3 flex-1">
<!-- Checkbox de Selección -->
{#if canExecuteActions && !resolvedThreats.has(threat.id)}
<input
type="checkbox"
checked={selectedThreats.has(threat.id)}
on:change={() => toggleThreatSelection(threat.id)}
class="mt-1 h-4 w-4 text-blue-700 border-gray-300 rounded focus:ring-blue-500"
/>
{/if}
<!-- Icono -->
<!-- Información Principal -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<h4 class="text-base font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
<span class="px-2 py-0.5 text-xs font-semibold rounded {getSeverityColor(threat.severity)}">
{threat.severity.toUpperCase()}
</span>
{#if resolvedThreats.has(threat.id)}
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-gray-50 text-green-700 border border-green-200">
RESUELTA
</span>
{/if}
</div>
<p class="text-sm text-gray-700 mb-2">{threat.description}</p>
<!-- Stats Rápidos -->
<div class="flex flex-wrap gap-4 text-xs text-gray-600">
<span><strong>{threat.occurrences}</strong> ocurrencias</span>
{#if threat.affected_ips.length > 0}
<span><strong>{threat.affected_ips.length}</strong> IPs</span>
{/if}
{#if threat.affected_users.length > 0}
<span><strong>{threat.affected_users.length}</strong> usuarios</span>
{/if}
<span class="text-gray-500">|</span>
<span>{formatDate(threat.last_seen)}</span>
</div>
<!-- IPs y Usuarios (Collapsibles) -->
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
<details class="mt-3 group">
<summary class="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-800">
Ver detalles afectados
</summary>
<div class="mt-2 pl-5 space-y-2">
{#if threat.affected_ips.length > 0}
<div>
<span class="text-xs font-medium text-gray-600">IPs:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_ips as ip}
<code class="px-2 py-0.5 bg-gray-100 rounded text-xs font-mono">{ip}</code>
{/each}
</div>
</div>
{/if}
{#if threat.affected_users.length > 0}
<div>
<span class="text-xs font-medium text-gray-600">Usuarios:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_users as user}
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">{user}</span>
{/each}
</div>
</div>
{/if}
</div>
</details>
{/if}
<!-- Recomendaciones (Collapsibles) -->
{#if threat.recommendations && threat.recommendations.length > 0}
<details class="mt-2 group">
<summary class="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-800">
Ver recomendaciones
</summary>
<div class="mt-2 pl-5">
<ul class="space-y-1">
{#each threat.recommendations as rec}
<li class="text-xs text-gray-600">
{rec}
</li>
{/each}
</ul>
</div>
</details>
{/if}
</div>
<p class="text-sm text-gray-700">{threat.description}</p>
</div>
<!-- Acciones Rápidas -->
{#if canExecuteActions}
<div class="flex flex-col gap-2 flex-shrink-0">
{#if !resolvedThreats.has(threat.id)}
<button
on:click={() => toggleThreatResolved(threat.id)}
class="px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 font-medium whitespace-nowrap"
title="Marcar como resuelta"
>
Resolver
</button>
{:else}
<button
on:click={() => toggleThreatResolved(threat.id)}
class="px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded-lg hover:bg-gray-300 font-medium whitespace-nowrap"
title="Marcar como activa"
>
Reoprir
</button>
{/if}
{#if !resolvedThreats.has(threat.id)}
<div class="relative group/actions">
<button class="px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-lg hover:bg-gray-200 font-medium whitespace-nowrap">
Acciones
</button>
<div class="hidden group-hover/actions:block absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
{#if threat.affected_ips.length > 0}
<button
on:click={() => openActionModal(threat, 'block_ip')}
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Bloquear IP
</button>
{/if}
{#if threat.affected_users.length > 0}
<button
on:click={() => openActionModal(threat, 'force_password_reset')}
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Resetear Contraseña
</button>
{/if}
<button
on:click={() => openActionModal(threat, 'notify_admin')}
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Notificar Admin
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Detalles -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 text-sm">
<div>
<span class="font-medium text-gray-600">Ocurrencias:</span>
<span class="ml-2 text-gray-900 font-semibold">{threat.occurrences}</span>
</div>
<div>
<span class="font-medium text-gray-600">Primera detección:</span>
<span class="ml-2 text-gray-900">{formatDate(threat.first_seen)}</span>
</div>
<div>
<span class="font-medium text-gray-600">Última detección:</span>
<span class="ml-2 text-gray-900">{formatDate(threat.last_seen)}</span>
</div>
</div>
<!-- IPs y Usuarios Afectados -->
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
<div class="mb-4 text-sm">
{#if threat.affected_ips.length > 0}
<div class="mb-2">
<span class="font-medium text-gray-600">IPs involucradas:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_ips as ip}
<code class="px-2 py-1 bg-gray-100 rounded text-xs font-mono">{ip}</code>
{/each}
</div>
</div>
{/if}
{#if threat.affected_users.length > 0}
<div>
<span class="font-medium text-gray-600">Usuarios afectados:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_users as user}
<span class="px-2 py-1 bg-gray-100 rounded text-xs">{user}</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Recomendaciones -->
{#if threat.recommendations && threat.recommendations.length > 0}
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<p class="text-xs font-semibold text-gray-700 mb-2">Recomendaciones:</p>
<ul class="space-y-1">
{#each threat.recommendations as rec}
<li class="text-xs text-gray-600 flex items-start gap-2">
<svg class="w-3 h-3 text-gray-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{rec}
</li>
{/each}
</ul>
</div>
{/if}
<!-- Acciones -->
{#if canExecuteActions}
<div class="flex flex-wrap gap-2">
{#if threat.affected_ips.length > 0}
<button
on:click={() => openActionModal(threat, 'block_ip')}
class="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Bloquear IP
</button>
{/if}
{#if threat.affected_users.length > 0}
<button
on:click={() => openActionModal(threat, 'force_password_reset')}
class="px-3 py-1.5 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Resetear Contraseña
</button>
{/if}
<button
on:click={() => openActionModal(threat, 'notify_admin')}
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
Notificar Admin
</button>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<!-- No hay amenazas -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<svg class="w-16 h-16 text-gray-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sistema Seguro</h3>
<p class="text-sm text-gray-600">No se detectaron amenazas en el período analizado</p>
<!-- No hay amenazas filtradas -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{activeTab === 'resolved' ? 'No hay amenazas resueltas' : searchQuery || filterType ? 'No se encontraron resultados' : 'Sistema Seguro'}
</h3>
<p class="text-sm text-gray-600">
{activeTab === 'resolved' ? 'No has resuelto ninguna amenaza aún.' : searchQuery || filterType ? 'Intenta ajustar los filtros de búsqueda.' : 'No se detectaron amenazas en este período.'}
</p>
{#if searchQuery || filterType}
<button
on:click={clearFilters}
class="mt-4 px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800"
>
Limpiar Filtros
</button>
{/if}
</div>
{/if}
{:else}
<!-- Estado vacío inicial -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sin Datos de Análisis</h3>
<p class="text-sm text-gray-600">Carga el análisis de seguridad para ver amenazas detectadas.</p>
</div>
{/if}
</div>
@@ -522,7 +792,7 @@
</button>
<button
on:click={executeSecurityAction}
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Ejecutar Acción
</button>

View File

@@ -102,7 +102,7 @@
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
>
Nueva Categoría
</button>
@@ -145,27 +145,27 @@
</td>
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{category.description || '-'}</td>
<td class="px-3 py-4 text-sm text-center">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
⏱️ {category.sla_response_hours || 24}h
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 border border-blue-200">
{category.sla_response_hours || 24}h
</span>
</td>
<td class="px-3 py-4 text-sm text-center">
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
{category.sla_resolution_hours || 72}h
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
{category.sla_resolution_hours || 72}h
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-blue-100={!category.tenant_id} class:text-blue-800={!category.tenant_id} class:bg-gray-100={category.tenant_id} class:text-gray-800={category.tenant_id} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {!category.tenant_id ? 'bg-gray-100 text-blue-700 border-blue-200' : 'bg-gray-100 text-gray-700 border-gray-200'}">
{getTenantName(category.tenant_id)}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={category.is_active} class:text-green-800={category.is_active} class:bg-red-100={!category.is_active} class:text-red-800={!category.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {category.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{category.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(category)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button on:click={() => openEditModal(category)} class="text-blue-700 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
@@ -182,18 +182,18 @@
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre *</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="color" class="block text-sm font-medium text-gray-700">Color</label>
<input type="color" id="color" bind:value={formData.color} class="mt-1 block w-full h-10 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border">
<input type="color" id="color" bind:value={formData.color} class="mt-1 block w-full h-10 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border">
</div>
</div>
@@ -212,7 +212,7 @@
min="1"
max="168"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
>
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para primera respuesta</p>
</div>
@@ -228,7 +228,7 @@
min="1"
max="720"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
>
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para resolver el ticket</p>
</div>
@@ -247,7 +247,7 @@
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - Específico para un cliente)</label>
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<option value="">-- Global (Para todos) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
@@ -256,15 +256,15 @@
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>

View File

@@ -109,7 +109,7 @@
<div class="max-w-sm mx-auto w-full">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Identifíquese</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
</div>
@@ -208,4 +208,4 @@
</div>
</div>
</div>
</div>
</div>

View File

@@ -179,7 +179,7 @@
{$auth.user?.first_name} {$auth.user?.last_name}
</p>
<p class="text-sm text-gray-500">{$auth.user?.email}</p>
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium bg-gray-100 text-blue-700 rounded-full border border-blue-200">
{roleLabel($auth.user?.role)}
</span>
</div>
@@ -197,7 +197,7 @@
<div>
<dt class="text-gray-500 font-medium">Estado</dt>
<dd class="mt-0.5">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {$auth.user?.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border {$auth.user?.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{$auth.user?.is_active ? 'Activo' : 'Inactivo'}
</span>
</dd>
@@ -219,10 +219,8 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{#if $auth.user?.is_two_factor_enabled}
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center border border-green-200">
<span class="text-xs font-bold text-green-600">2FA</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900">2FA habilitado</p>
@@ -230,9 +228,7 @@
</div>
{:else}
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<span class="text-xs font-bold text-gray-400">2FA</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900">2FA no habilitado</p>
@@ -306,7 +302,7 @@
<!-- Backup codes -->
{#if showBackupCodes && backupCodes.length > 0}
<div class="mt-5 pt-5 border-t border-green-200 bg-green-50 rounded-b-lg -mx-6 -mb-5 px-6 pb-5">
<h4 class="font-semibold text-green-900 mb-1">2FA activado — Guarda tus códigos de respaldo</h4>
<h4 class="font-semibold text-green-900 mb-1">2FA activado — Guarda tus códigos de respaldo</h4>
<p class="text-sm text-green-700 mb-3">
Estos códigos son de <strong>un solo uso</strong>. Guárdalos en un lugar seguro para acceder sin tu dispositivo.
</p>

View File

@@ -55,7 +55,7 @@
<select
bind:value={selectedPeriod}
on:change={loadDashboard}
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value={7}>Últimos 7 días</option>
<option value={30}>Últimos 30 días</option>
@@ -66,7 +66,7 @@
{#if isLoading}
<div class="mt-8 text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-700"></div>
<p class="mt-2 text-sm text-gray-500">Cargando métricas...</p>
</div>
{:else if dashboardData}
@@ -75,106 +75,70 @@
<!-- Response SLA -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Response SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.response_sla.compliance_percentage)}">
{dashboardData.response_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.response_sla)}">
{getTrendIcon(dashboardData.trends.response_sla)} {dashboardData.trends.response_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.response_sla.met_count} / {dashboardData.response_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Response SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.response_sla.compliance_percentage)}">
{dashboardData.response_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.response_sla)}">
{getTrendIcon(dashboardData.trends.response_sla)} {dashboardData.trends.response_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.response_sla.met_count} / {dashboardData.response_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
<!-- Resolution SLA -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Resolution SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.resolution_sla.compliance_percentage)}">
{dashboardData.resolution_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.resolution_sla)}">
{getTrendIcon(dashboardData.trends.resolution_sla)} {dashboardData.trends.resolution_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.resolution_sla.met_count} / {dashboardData.resolution_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Resolution SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.resolution_sla.compliance_percentage)}">
{dashboardData.resolution_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.resolution_sla)}">
{getTrendIcon(dashboardData.trends.resolution_sla)} {dashboardData.trends.resolution_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.resolution_sla.met_count} / {dashboardData.resolution_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
<!-- Active Violations -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Violaciones Activas</dt>
<dd class="text-2xl font-semibold text-red-600">
{dashboardData.active_violations}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/violations" class="text-indigo-600 hover:text-indigo-900">Ver detalles →</a>
</dd>
</dl>
</div>
</div>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Violaciones Activas</dt>
<dd class="text-2xl font-semibold text-red-600">
{dashboardData.active_violations}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/violations" class="text-blue-700 hover:text-blue-900">Ver detalles →</a>
</dd>
</dl>
</div>
</div>
<!-- At Risk Tickets -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Tickets en Riesgo</dt>
<dd class="text-2xl font-semibold text-yellow-600">
{dashboardData.at_risk_tickets}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/at-risk" class="text-indigo-600 hover:text-indigo-900">Ver lista →</a>
</dd>
</dl>
</div>
</div>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Tickets en Riesgo</dt>
<dd class="text-2xl font-semibold text-yellow-600">
{dashboardData.at_risk_tickets}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/at-risk" class="text-blue-700 hover:text-blue-900">Ver lista →</a>
</dd>
</dl>
</div>
</div>
</div>
@@ -257,28 +221,18 @@
href="/sla/violations"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Ver Violaciones
</a>
<a
href="/categories"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurar SLAs
</a>
<a
href="/tickets"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Ver Todos los Tickets
</a>
</div>

View File

@@ -6,12 +6,62 @@
let isLoading = true;
let atRiskTickets: any[] = [];
let threshold = 80;
let page = 1;
let perPage = 10;
let totalTickets = 0;
// Filtros
let filterPriority = 'ALL';
let filterRiskLevel = 'ALL';
let filterSLAType = 'ALL';
let searchQuery = '';
// Tickets filtrados
$: filteredTickets = atRiskTickets.filter(ticket => {
// Filtro por prioridad
if (filterPriority !== 'ALL' && ticket.ticket.priority !== filterPriority) {
return false;
}
// Filtro por nivel de riesgo
if (filterRiskLevel !== 'ALL') {
if (filterRiskLevel === 'CRITICAL' && ticket.risk_percentage < 95) return false;
if (filterRiskLevel === 'HIGH' && (ticket.risk_percentage < 90 || ticket.risk_percentage >= 95)) return false;
if (filterRiskLevel === 'MEDIUM' && (ticket.risk_percentage < 80 || ticket.risk_percentage >= 90)) return false;
}
// Filtro por tipo de SLA
if (filterSLAType !== 'ALL' && ticket.sla_type !== filterSLAType) {
return false;
}
// Búsqueda por número de ticket o asunto
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesNumber = ticket.ticket.ticket_number?.toLowerCase().includes(query);
const matchesSubject = ticket.ticket.subject?.toLowerCase().includes(query);
const matchesCategory = ticket.category?.name?.toLowerCase().includes(query);
return matchesNumber || matchesSubject || matchesCategory;
}
return true;
});
$: totalTickets = filteredTickets.length;
$: paginatedTickets = filteredTickets.slice((page - 1) * perPage, page * perPage);
$: totalPages = Math.ceil(totalTickets / perPage);
// Reset página cuando cambian filtros
$: if (filterPriority || filterRiskLevel || filterSLAType || searchQuery) {
page = 1;
}
async function loadAtRiskTickets() {
isLoading = true;
try {
const data: any = await api.get(`/sla/at-risk?threshold=${threshold}`);
atRiskTickets = data.tickets || [];
page = 1; // Reset a primera página al cambiar filtros
} catch (e: any) {
toast.error(e.message || 'Error cargando tickets en riesgo');
atRiskTickets = [];
@@ -20,6 +70,24 @@
}
}
function clearFilters() {
filterPriority = 'ALL';
filterRiskLevel = 'ALL';
filterSLAType = 'ALL';
searchQuery = '';
}
function getActiveFiltersCount(): number {
let count = 0;
if (filterPriority !== 'ALL') count++;
if (filterRiskLevel !== 'ALL') count++;
if (filterSLAType !== 'ALL') count++;
if (searchQuery) count++;
return count;
}
$: activeFiltersCount = getActiveFiltersCount();
function formatHours(hours: number): string {
if (hours < 1) {
return `${Math.round(hours * 60)} min`;
@@ -33,10 +101,17 @@
}
function getRiskColor(percentage: number): string {
if (percentage >= 95) return 'bg-red-100 text-red-800 border-red-200';
if (percentage >= 90) return 'bg-orange-100 text-orange-800 border-orange-200';
if (percentage >= 80) return 'bg-yellow-100 text-yellow-800 border-yellow-200';
return 'bg-blue-100 text-blue-800 border-blue-200';
if (percentage >= 95) return 'border-red-500';
if (percentage >= 90) return 'border-orange-500';
if (percentage >= 80) return 'border-yellow-500';
return 'border-blue-500';
}
function getRiskBadgeColor(percentage: number): string {
if (percentage >= 95) return 'bg-red-100 text-red-800';
if (percentage >= 90) return 'bg-orange-100 text-orange-800';
if (percentage >= 80) return 'bg-yellow-100 text-yellow-800';
return 'bg-blue-100 text-blue-800';
}
function getRiskLabel(percentage: number): string {
@@ -60,6 +135,20 @@
return type === 'response' ? 'Respuesta' : 'Resolución';
}
function nextPage() {
if (page < totalPages) {
page++;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function prevPage() {
if (page > 1) {
page--;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
onMount(loadAtRiskTickets);
</script>
@@ -76,7 +165,7 @@
<select
bind:value={threshold}
on:change={loadAtRiskTickets}
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value={70}>70% del tiempo</option>
<option value={80}>80% del tiempo</option>
@@ -91,115 +180,209 @@
</div>
</div>
<!-- Info Banner -->
<div class="mt-6 bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<!-- Filtros -->
<div class="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">Filtros</h3>
{#if activeFiltersCount > 0}
<button
on:click={clearFilters}
class="text-xs font-medium text-blue-600 hover:text-blue-800"
>
Limpiar ({activeFiltersCount})
</button>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<!-- Búsqueda -->
<div>
<label for="search" class="block text-xs font-medium text-gray-700 mb-1">Buscar</label>
<input
id="search"
type="text"
bind:value={searchQuery}
placeholder="Ticket, asunto, categoría..."
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
/>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
Mostrando tickets que han consumido {threshold}% o más de su tiempo SLA.
Estos tickets requieren atención prioritaria para evitar violaciones.
</p>
<!-- Filtro por Prioridad -->
<div>
<label for="priority" class="block text-xs font-medium text-gray-700 mb-1">Prioridad</label>
<select
id="priority"
bind:value={filterPriority}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="ALL">Todas</option>
<option value="URGENT">Urgente</option>
<option value="HIGH">Alta</option>
<option value="MEDIUM">Media</option>
<option value="LOW">Baja</option>
</select>
</div>
<!-- Filtro por Nivel de Riesgo -->
<div>
<label for="riskLevel" class="block text-xs font-medium text-gray-700 mb-1">Nivel de Riesgo</label>
<select
id="riskLevel"
bind:value={filterRiskLevel}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="ALL">Todos</option>
<option value="CRITICAL">Crítico (≥95%)</option>
<option value="HIGH">Alto (90-95%)</option>
<option value="MEDIUM">Medio (80-90%)</option>
</select>
</div>
<!-- Filtro por Tipo de SLA -->
<div>
<label for="slaType" class="block text-xs font-medium text-gray-700 mb-1">Tipo de SLA</label>
<select
id="slaType"
bind:value={filterSLAType}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="ALL">Todos</option>
<option value="response">Respuesta</option>
<option value="resolution">Resolución</option>
</select>
</div>
</div>
<!-- Contador de resultados -->
<div class="mt-3 pt-3 border-t border-gray-200">
<p class="text-xs text-gray-600">
Mostrando <span class="font-semibold text-gray-900">{totalTickets}</span>
{totalTickets === 1 ? 'ticket' : 'tickets'}
{#if activeFiltersCount > 0}
de <span class="font-semibold">{atRiskTickets.length}</span> totales
{/if}
</p>
</div>
</div>
<!-- Summary Stats -->
{#if !isLoading && filteredTickets.length > 0}
<div class="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Resumen por Nivel de Riesgo</h3>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="bg-gray-50 rounded-lg p-3 border-l-4 border-gray-400">
<p class="text-xs font-medium text-gray-600">Total en Riesgo</p>
<p class="mt-1 text-2xl font-bold text-gray-900">{totalTickets}</p>
</div>
<div class="bg-red-50 rounded-lg p-3 border-l-4 border-red-500">
<p class="text-xs font-medium text-gray-600">Crítico (≥95%)</p>
<p class="mt-1 text-2xl font-bold text-red-600">
{filteredTickets.filter(t => t.risk_percentage >= 95).length}
</p>
</div>
<div class="bg-orange-50 rounded-lg p-3 border-l-4 border-orange-500">
<p class="text-xs font-medium text-gray-600">Alto (90-95%)</p>
<p class="mt-1 text-2xl font-bold text-orange-600">
{filteredTickets.filter(t => t.risk_percentage >= 90 && t.risk_percentage < 95).length}
</p>
</div>
<div class="bg-yellow-50 rounded-lg p-3 border-l-4 border-yellow-500">
<p class="text-xs font-medium text-gray-600">Medio (80-90%)</p>
<p class="mt-1 text-2xl font-bold text-yellow-600">
{filteredTickets.filter(t => t.risk_percentage >= 80 && t.risk_percentage < 90).length}
</p>
</div>
</div>
</div>
{/if}
<!-- Info Banner -->
<div class="mt-4 bg-amber-50 border-l-4 border-amber-500 p-3 rounded-lg">
<p class="text-xs text-amber-800">
<strong class="font-bold">{totalTickets}</strong> {totalTickets === 1 ? 'ticket' : 'tickets'} consumiendo <strong>{threshold}%</strong> o más del tiempo SLA.
</p>
</div>
<!-- Risk Tickets List -->
<div class="mt-6 space-y-4">
<div class="mt-4 space-y-2">
{#if isLoading}
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<p class="mt-2 text-sm text-gray-500">Cargando tickets en riesgo...</p>
<div class="text-center py-12 bg-white rounded-lg shadow">
<div class="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div>
<p class="mt-3 text-sm text-gray-600">Cargando tickets en riesgo...</p>
</div>
{:else if atRiskTickets.length === 0}
<div class="bg-white shadow rounded-lg text-center py-12">
<svg class="mx-auto h-12 w-12 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-2 text-lg font-medium text-gray-900">¡Todo bajo control!</p>
<p class="mt-2 text-base font-medium text-gray-900">Todo bajo control</p>
<p class="mt-1 text-sm text-gray-500">No hay tickets en riesgo de violar SLA</p>
</div>
{:else if filteredTickets.length === 0}
<div class="bg-white shadow rounded-lg text-center py-12">
<p class="mt-2 text-base font-medium text-gray-900">Sin resultados</p>
<p class="mt-1 text-sm text-gray-500">No se encontraron tickets con los filtros aplicados</p>
<button
on:click={clearFilters}
class="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
>
Limpiar filtros
</button>
</div>
{:else}
{#each atRiskTickets as ticket}
<div class="bg-white shadow rounded-lg overflow-hidden border-l-4 {getRiskColor(ticket.risk_percentage)}">
<div class="px-6 py-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
{#each paginatedTickets as ticket}
<div class="bg-white shadow-sm rounded-lg overflow-hidden border-l-3 {getRiskColor(ticket.risk_percentage)} hover:shadow transition-shadow">
<div class="px-3 py-2">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a
href="/tickets/{ticket.ticket.id}"
class="text-lg font-semibold text-indigo-600 hover:text-indigo-900"
class="text-sm font-semibold text-blue-600 hover:text-blue-800"
>
{ticket.ticket.ticket_number}
</a>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {getPriorityColor(ticket.ticket.priority)}">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {getPriorityColor(ticket.ticket.priority)}">
{ticket.ticket.priority}
</span>
<span class="text-sm text-gray-500">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs bg-gray-100 text-gray-700">
{getSLATypeLabel(ticket.sla_type)}
</span>
</div>
<p class="mt-1 text-sm text-gray-900">{ticket.ticket.subject}</p>
<p class="mt-0.5 text-xs text-gray-700 line-clamp-1">{ticket.ticket.subject}</p>
{#if ticket.category}
<div class="mt-2 text-xs text-gray-500">
📂 {ticket.category.name}
<span class="text-gray-400">
(SLA: {ticket.sla_type === 'response' ? ticket.category.sla_response_hours : ticket.category.sla_resolution_hours}h)
</span>
</div>
{/if}
{#if ticket.assigned_to}
<div class="mt-2 text-xs text-gray-500">
👤 Asignado a: <span class="text-gray-900">{ticket.assigned_to.first_name} {ticket.assigned_to.last_name}</span>
</div>
{/if}
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
{#if ticket.category}
<span class="truncate">{ticket.category.name}</span>
{/if}
{#if ticket.assigned_to}
<span class="truncate">{ticket.assigned_to.first_name} {ticket.assigned_to.last_name}</span>
{/if}
</div>
</div>
<div class="ml-6 flex-shrink-0 text-right">
<div class="text-sm font-medium {getRiskColor(ticket.risk_percentage)} inline-flex items-center px-3 py-1 rounded-full border">
{getRiskLabel(ticket.risk_percentage)}
<div class="flex items-center gap-2">
<div class="text-xs font-semibold {getRiskBadgeColor(ticket.risk_percentage)} px-2 py-0.5 rounded">
{ticket.risk_percentage.toFixed(0)}%
</div>
<div class="mt-2 text-sm">
<span class="font-semibold text-red-600">
Progreso: {ticket.risk_percentage.toFixed(1)}%
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
Quedan: <span class="font-medium text-orange-600">{formatHours(ticket.time_remaining_hours)}</span>
<div class="text-xs text-gray-600">
{formatHours(ticket.time_remaining_hours)}
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="mt-4">
<div class="relative">
<div class="overflow-hidden h-2 text-xs flex rounded bg-gray-200">
<div
style="width: {ticket.risk_percentage}%"
class="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center {ticket.risk_percentage >= 95 ? 'bg-red-500' : ticket.risk_percentage >= 90 ? 'bg-orange-500' : ticket.risk_percentage >= 80 ? 'bg-yellow-500' : 'bg-blue-500'}"
></div>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>0%</span>
<span class="text-orange-600 font-medium">{threshold}% (umbral)</span>
<span>100%</span>
</div>
</div>
</div>
<div class="px-3 pb-2">
<div class="overflow-hidden h-1 rounded-full bg-gray-200">
<div
style="width: {ticket.risk_percentage}%"
class="h-full transition-all {ticket.risk_percentage >= 95 ? 'bg-red-500' : ticket.risk_percentage >= 90 ? 'bg-orange-500' : ticket.risk_percentage >= 80 ? 'bg-yellow-500' : 'bg-blue-500'}"
></div>
</div>
</div>
<div class="bg-gray-50 px-6 py-3 flex justify-end gap-3">
<div class="bg-gray-50 px-3 py-1.5 flex justify-end border-t border-gray-100">
<a
href="/tickets/{ticket.ticket.id}"
class="text-sm font-medium text-indigo-600 hover:text-indigo-900"
class="text-xs font-medium text-blue-600 hover:text-blue-800"
>
Ver ticket
Ver ticket
</a>
</div>
</div>
@@ -207,34 +390,61 @@
{/if}
</div>
<!-- Summary Stats -->
{#if !isLoading && atRiskTickets.length > 0}
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<h3 class="text-sm font-medium text-gray-900 mb-4">Resumen</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 text-center">
<!-- Pagination -->
{#if !isLoading && totalPages > 1}
<div class="mt-6 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg shadow">
<div class="flex flex-1 justify-between sm:hidden">
<button
on:click={prevPage}
disabled={page === 1}
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
on:click={nextPage}
disabled={page === totalPages}
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-500">Total en Riesgo</p>
<p class="text-2xl font-semibold text-gray-900">{atRiskTickets.length}</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Crítico (≥95%)</p>
<p class="text-2xl font-semibold text-red-600">
{atRiskTickets.filter(t => t.risk_percentage >= 95).length}
<p class="text-sm text-gray-700">
Mostrando
<span class="font-medium">{(page - 1) * perPage + 1}</span>
a
<span class="font-medium">{Math.min(page * perPage, totalTickets)}</span>
de
<span class="font-medium">{totalTickets}</span>
resultados
</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Alto (≥90%)</p>
<p class="text-2xl font-semibold text-orange-600">
{atRiskTickets.filter(t => t.risk_percentage >= 90 && t.risk_percentage < 95).length}
</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Medio (≥80%)</p>
<p class="text-2xl font-semibold text-yellow-600">
{atRiskTickets.filter(t => t.risk_percentage >= 80 && t.risk_percentage < 90).length}
</p>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button
on:click={prevPage}
disabled={page === 1}
class="relative inline-flex items-center rounded-l-md px-3 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300">
Página {page} de {totalPages}
</span>
<button
on:click={nextPage}
disabled={page === totalPages}
class="relative inline-flex items-center rounded-r-md px-3 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</nav>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -7,7 +7,7 @@
let violations: any[] = [];
let total = 0;
let page = 1;
let perPage = 20;
let perPage = 10;
let totalPages = 0;
// Filtros
@@ -74,7 +74,7 @@
}
function getSLATypeColor(type: string): string {
return type === 'response' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800';
return type === 'response' ? 'bg-orange-100 text-orange-800' : 'bg-red-100 text-red-800';
}
function handleFilterChange() {
@@ -130,7 +130,7 @@
id="slaType"
bind:value={slaTypeFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Todos</option>
<option value="response">Respuesta</option>
@@ -144,7 +144,7 @@
id="category"
bind:value={categoryFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Todas</option>
{#each categories as cat}
@@ -159,7 +159,7 @@
id="priority"
bind:value={priorityFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Todas</option>
<option value="LOW">Baja</option>
@@ -186,22 +186,14 @@
</div>
<!-- Stats Summary -->
<div class="mt-6 bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">
<strong>{total}</strong> violaciones activas encontradas
<div class="mt-6 bg-red-50 border-l-4 border-red-500 p-4 rounded-lg">
<p class="text-sm text-red-800">
<strong class="font-bold">{total}</strong> {total === 1 ? 'violación activa' : 'violaciones activas'} encontradas
{#if total > 0}
- Requieren atención inmediata
{/if}
</p>
</div>
</div>
</div>
<!-- Violations Table -->
@@ -238,18 +230,16 @@
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr>
<td colspan="7" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<p class="mt-2 text-sm text-gray-500">Cargando violaciones...</p>
<td colspan="7" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div>
<p class="mt-4 text-sm text-gray-600 font-medium">Cargando violaciones...</p>
</td>
</tr>
{:else if violations.length === 0}
<tr>
<td colspan="7" class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-2 text-sm text-gray-500">Excelente! No hay violaciones de SLA activas</p>
<td colspan="7" class="text-center py-12">
<p class="mt-3 text-base font-medium text-gray-900">¡Excelente trabajo!</p>
<p class="mt-1 text-sm text-gray-600">No hay violaciones de SLA activas</p>
</td>
</tr>
{:else}
@@ -259,7 +249,7 @@
<div class="flex flex-col">
<a
href="/tickets/{violation.ticket.id}"
class="font-medium text-indigo-600 hover:text-indigo-900"
class="font-medium text-blue-700 hover:text-blue-900"
>
{violation.ticket.ticket_number}
</a>
@@ -290,11 +280,14 @@
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="font-semibold text-red-600">
{formatHours(violation.hours_overdue)}
</span>
<div class="text-xs text-gray-500">
vencido
<div>
<span class="font-bold text-red-600 text-base">
{formatHours(violation.hours_overdue)}
</span>
<div class="text-xs text-gray-600">
vencido
</div>
</div>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
@@ -314,7 +307,7 @@
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<a
href="/tickets/{violation.ticket.id}"
class="text-indigo-600 hover:text-indigo-900"
class="text-blue-700 hover:text-blue-900"
>
Ver ticket →
</a>

View File

@@ -67,7 +67,7 @@
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
>
Nuevo Sistema
</button>
@@ -100,12 +100,12 @@
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{system.name}</td>
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{system.description || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={system.is_active} class:text-green-800={system.is_active} class:bg-red-100={!system.is_active} class:text-red-800={!system.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {system.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{system.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(system)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button on:click={() => openEditModal(system)} class="text-blue-700 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
@@ -122,24 +122,24 @@
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"></textarea>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>

View File

@@ -101,7 +101,7 @@
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
>
Nuevo Cliente
</button>
@@ -143,7 +143,7 @@
<button
type="button"
on:click={() => toggleTenantStatus(tenant)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
role="switch"
aria-checked={tenant.status === 'active'}
>
@@ -153,13 +153,13 @@
</button>
<!-- Badge de Estado -->
<span class:bg-green-100={tenant.status === 'active'} class:text-green-800={tenant.status === 'active'} class:bg-yellow-100={tenant.status === 'suspended'} class:text-yellow-800={tenant.status === 'suspended'} class:bg-red-100={tenant.status === 'inactive'} class:text-red-800={tenant.status === 'inactive'} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {tenant.status === 'active' ? 'bg-gray-50 text-green-700 border-green-200' : tenant.status === 'suspended' ? 'bg-gray-50 text-orange-700 border-orange-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
</span>
</div>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button on:click={() => openEditModal(tenant)} class="text-blue-700 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
@@ -176,28 +176,28 @@
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700">Slug (Identificador)</label>
<input type="text" id="slug" bind:value={formData.slug} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="text" id="slug" bind:value={formData.slug} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<p class="text-xs text-gray-500 mt-1">Usado en URLs y subdominios.</p>
</div>
<div>
<label for="domain" class="block text-sm font-medium text-gray-700">Dominio Personalizado</label>
<input type="text" id="domain" bind:value={formData.domain} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="text" id="domain" bind:value={formData.domain} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="contact_email" class="block text-sm font-medium text-gray-700">Email de Contacto</label>
<input type="email" id="contact_email" bind:value={formData.contact_email} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="email" id="contact_email" bind:value={formData.contact_email} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="contact_phone" class="block text-sm font-medium text-gray-700">Teléfono de Contacto</label>
<input type="text" id="contact_phone" bind:value={formData.contact_phone} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="text" id="contact_phone" bind:value={formData.contact_phone} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
@@ -208,7 +208,7 @@
<button
type="button"
on:click={() => formData.status = formData.status === 'active' ? 'inactive' : 'active'}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {formData.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {formData.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
role="switch"
aria-checked={formData.status === 'active'}
>
@@ -229,7 +229,7 @@
type="checkbox"
checked={formData.status === 'suspended'}
on:change={(e) => formData.status = e.target.checked ? 'suspended' : 'active'}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 h-4 w-4"
class="rounded border-gray-300 text-blue-700 focus:ring-blue-500 h-4 w-4"
/>
<span class="ml-2 text-sm text-gray-600">Marcar como suspendido temporalmente</span>
</label>
@@ -238,10 +238,10 @@
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
{#if editingTenant}

View File

@@ -312,7 +312,7 @@
<div class="flex gap-5 px-4 py-5 mx-auto max-w-full sm:px-6 lg:px-8">
<aside class="w-60 flex-shrink-0 space-y-4">
<div class="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
<div class="bg-indigo-600 px-4 py-3">
<div class="bg-blue-700 px-4 py-3">
<h2 class="text-sm font-semibold text-white tracking-wide">Organización</h2>
</div>
<ul class="divide-y divide-gray-100">
@@ -320,7 +320,7 @@
<button
class="w-full text-left px-4 py-2.5 flex items-center justify-between text-sm transition-colors
{filterTenantId === ''
? 'bg-indigo-50 text-indigo-700 font-semibold'
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-700 hover:bg-gray-50'}"
on:click={() => {
filterTenantId = '';
@@ -338,7 +338,7 @@
<button
class="w-full text-left px-4 py-2.5 flex items-center justify-between text-sm transition-colors
{filterTenantId === tenant.id
? 'bg-indigo-50 text-indigo-700 font-semibold'
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-700 hover:bg-gray-50'}"
on:click={() => {
filterTenantId = tenant.id;
@@ -347,7 +347,7 @@
>
<span class="truncate pr-1">{tenant.name}</span>
<span
class="text-xs bg-indigo-100 text-indigo-600 rounded-full px-2 py-0.5 font-medium flex-shrink-0"
class="text-xs bg-gray-100 text-blue-700 rounded-full px-2 py-0.5 font-medium flex-shrink-0 border border-gray-200"
>
{tenantCounts[tenant.id] ?? 0}
</span>
@@ -366,7 +366,7 @@
<select
bind:value={filterStatus}
on:change={() => (categoryPages = {})}
class="block w-full rounded-md border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500 border px-2 py-1.5"
class="block w-full rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 border px-2 py-1.5"
>
<option value="">Todos</option>
{#each STATUSES as s}
@@ -380,7 +380,7 @@
<select
bind:value={filterPriority}
on:change={() => (categoryPages = {})}
class="block w-full rounded-md border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500 border px-2 py-1.5"
class="block w-full rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 border px-2 py-1.5"
>
<option value="">Todas</option>
{#each PRIORITIES as p}
@@ -398,7 +398,7 @@
searchQuery = '';
categoryPages = {};
}}
class="w-full text-xs text-indigo-600 hover:text-indigo-800 font-medium text-center pt-1"
class="w-full text-xs text-blue-700 hover:text-blue-800 font-medium text-center pt-1"
>
Limpiar filtros
</button>
@@ -430,107 +430,44 @@
</div>
<button
on:click={openCreateModal}
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md shadow-sm hover:bg-indigo-700 transition-colors"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md shadow-sm hover:bg-blue-800 transition-colors"
>
+ Nuevo Ticket
</button>
</div>
<div class="grid grid-cols-4 gap-3">
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4 flex items-center gap-3">
<div class="bg-gray-100 rounded-lg p-2">
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500 font-medium">Total</p>
<p class="text-2xl font-bold text-gray-900">{kpiTotal}</p>
</div>
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<p class="text-xs text-gray-500 font-medium">Total</p>
<p class="text-2xl font-bold text-gray-900">{kpiTotal}</p>
</div>
<div class="bg-white rounded-xl border border-red-200 shadow-sm p-4 flex items-center gap-3">
<div class="bg-red-50 rounded-lg p-2">
<svg class="h-5 w-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
</div>
<div>
<p class="text-xs text-red-500 font-medium">Urgentes</p>
<p class="text-2xl font-bold text-red-600">{kpiUrgent}</p>
</div>
<div class="bg-white rounded-xl border border-red-200 shadow-sm p-4">
<p class="text-xs text-red-500 font-medium">Urgentes</p>
<p class="text-2xl font-bold text-red-600">{kpiUrgent}</p>
</div>
<div class="bg-white rounded-xl border border-blue-200 shadow-sm p-4 flex items-center gap-3">
<div class="bg-blue-50 rounded-lg p-2">
<svg class="h-5 w-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div>
<p class="text-xs text-blue-500 font-medium">Activos</p>
<p class="text-2xl font-bold text-blue-600">{kpiActive}</p>
</div>
<div class="bg-white rounded-xl border border-blue-200 shadow-sm p-4">
<p class="text-xs text-blue-500 font-medium">Activos</p>
<p class="text-2xl font-bold text-blue-600">{kpiActive}</p>
</div>
<div
class="bg-white rounded-xl border border-green-200 shadow-sm p-4 flex items-center gap-3"
>
<div class="bg-green-50 rounded-lg p-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p class="text-xs text-green-500 font-medium">Resueltos</p>
<p class="text-2xl font-bold text-green-600">{kpiResolved}</p>
</div>
<div class="bg-white rounded-xl border border-green-200 shadow-sm p-4">
<p class="text-xs text-green-500 font-medium">Resueltos</p>
<p class="text-2xl font-bold text-green-600">{kpiResolved}</p>
</div>
</div>
<div class="relative">
<svg
class="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
/>
</svg>
<input
type="text"
placeholder="Buscar por número, asunto, usuario..."
bind:value={searchQuery}
on:input={() => (categoryPages = {})}
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{#if isLoading}
<div class="flex justify-center items-center py-16 text-gray-400 text-sm">
<svg class="animate-spin h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24">
<svg class="animate-spin h-5 w-5 mr-2 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
@@ -568,45 +505,32 @@
/>
<span class="font-semibold text-gray-800 text-sm">{group.name}</span>
<span
class="text-xs bg-indigo-100 text-indigo-700 font-medium rounded-full px-2 py-0.5"
class="text-xs bg-gray-100 text-blue-700 font-medium rounded-full px-2 py-0.5 border border-gray-200"
>
{group.tickets.length}
</span>
{#if group.urgCount > 0}
<span
class="text-xs bg-red-100 text-red-700 font-semibold rounded-full px-2 py-0.5"
class="text-xs bg-gray-50 text-red-700 font-semibold rounded-full px-2 py-0.5 border border-red-200"
>
? {group.urgCount} urgente{group.urgCount > 1 ? 's' : ''}
{group.urgCount} urgente{group.urgCount > 1 ? 's' : ''}
</span>
{/if}
{#if group.highCount > 0}
<span
class="text-xs bg-orange-100 text-orange-700 font-medium rounded-full px-2 py-0.5"
class="text-xs bg-gray-50 text-orange-700 font-medium rounded-full px-2 py-0.5 border border-orange-200"
>
? {group.highCount} alta{group.highCount > 1 ? 's' : ''}
{group.highCount} alta{group.highCount > 1 ? 's' : ''}
</span>
{/if}
{#if group.closedCount > 0}
<span
class="text-xs bg-gray-100 text-gray-500 font-medium rounded-full px-2 py-0.5"
>
? {group.closedCount} cerrado{group.closedCount > 1 ? 's' : ''}
{group.closedCount} cerrado{group.closedCount > 1 ? 's' : ''}
</span>
{/if}
</div>
<svg
class="h-4 w-4 text-gray-400 transition-transform {isCollapsed ? '' : 'rotate-180'}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if !isCollapsed}
@@ -653,7 +577,7 @@
<tr
class="cursor-pointer transition-all {rowBorderClass(ticket)} {isClosed
? 'opacity-55 hover:opacity-80 bg-gray-50/50'
: 'hover:bg-indigo-50/60'}"
: 'hover:bg-blue-50/60'}"
on:click={() => goto(`/tickets/${ticket.id}`)}
>
<td
@@ -709,7 +633,7 @@
<td class="px-4 py-2.5 whitespace-nowrap text-right">
<button
on:click|stopPropagation={() => openEditModal(ticket)}
class="text-indigo-600 hover:text-indigo-900 font-medium mr-2"
class="text-blue-700 hover:text-blue-900 font-medium mr-2"
>Editar</button
>
<button
@@ -745,7 +669,7 @@
on:click={() => setPage(catId, p)}
class="px-2.5 py-1 rounded text-xs border font-medium transition-colors
{p === page
? 'bg-indigo-600 border-indigo-600 text-white'
? 'bg-blue-700 border-blue-700 text-white'
: 'border-gray-300 text-gray-600 hover:bg-gray-100'}">{p}</button
>
{/each}
@@ -776,7 +700,7 @@
type="text"
bind:value={formData.subject}
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
placeholder="Breve descripción del problema"
/>
</div>
@@ -786,7 +710,7 @@
bind:value={formData.description}
required
rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
placeholder="Describe el problema en detalle..."
/>
</div>
@@ -830,7 +754,7 @@
<div class="sm:grid sm:grid-cols-2 sm:gap-3 mt-5">
<button
type="submit"
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-sm font-medium text-white hover:bg-indigo-700 sm:col-start-2"
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-sm font-medium text-white hover:bg-blue-800 sm:col-start-2"
>
Crear Ticket
</button>
@@ -896,7 +820,7 @@
<div class="sm:grid sm:grid-cols-2 sm:gap-3 mt-5">
<button
type="submit"
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-sm font-medium text-white hover:bg-indigo-700 sm:col-start-2"
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-sm font-medium text-white hover:bg-blue-800 sm:col-start-2"
>
Guardar Cambios
</button>
@@ -913,20 +837,7 @@
<Modal open={showDeleteModal} title="Eliminar Ticket" on:close={() => (showDeleteModal = false)}>
<div class="space-y-4">
<div class="bg-red-50 border border-red-200 rounded-md p-4 flex gap-3">
<svg
class="h-5 w-5 text-red-400 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<div>
<h3 class="text-sm font-medium text-red-800">¿Eliminar este ticket?</h3>
<p class="text-sm text-red-700 mt-1">

View File

@@ -143,16 +143,14 @@
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
{#if isLoading}
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-700" />
<p class="mt-2 text-gray-600">Cargando ticket...</p>
</div>
{:else if ticket}
<!-- Breadcrumb -->
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-6">
<a href="/tickets" class="hover:text-indigo-600">Tickets</a>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<a href="/tickets" class="hover:text-blue-700">Tickets</a>
<span>/</span>
<span>#{ticket.ticket_number || ticket.id.substring(0, 8)}</span>
</div>
@@ -222,21 +220,9 @@
>
<div class="flex items-center space-x-3">
<div
class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0"
class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0"
>
<svg
class="w-5 h-5 text-indigo-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
<span class="text-xs font-bold text-blue-700">ADJ</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
@@ -255,17 +241,10 @@
</div>
<button
on:click={() => handleDownloadAttachment(attachment)}
class="inline-flex items-center p-2 text-sm font-medium text-indigo-600 hover:bg-indigo-50 rounded-md transition-colors"
class="inline-flex items-center px-2 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 rounded-md transition-colors"
title="Descargar {attachment.original_filename}"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Descargar
</button>
</div>
{/each}
@@ -289,15 +268,15 @@
{#each comments as comment}
<div class="flex space-x-3">
<div
class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0"
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<span class="text-indigo-600 text-xs font-medium">
<span class="text-blue-700 text-xs font-medium">
{comment.author_name
? comment.author_name
.split(' ')
.map(n => n[0])
.join('')
: '??'}
: 'U'}
</span>
</div>
<div class="flex-1 min-w-0">
@@ -309,7 +288,7 @@
{formatDate(comment.created_at)}
</span>
{#if comment.is_internal}
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
<span class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200">
Interno
</span>
{/if}
@@ -328,7 +307,7 @@
<form on:submit|preventDefault={handleAddComment} class="space-y-4">
<textarea
rows="4"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment}
disabled={isSubmittingComment}
@@ -337,7 +316,7 @@
<div class="flex justify-end">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
disabled={isSubmittingComment || !newComment.trim()}
>
{#if isSubmittingComment}
@@ -398,7 +377,7 @@
<!-- SLA Information -->
{#if ticket.sla_response_due || ticket.sla_resolution_due}
<div class="pt-4 border-t border-gray-200">
<h4 class="text-sm font-semibold text-gray-900 mb-3">⏱️ SLA (Acuerdos de Nivel de Servicio)</h4>
<h4 class="text-sm font-semibold text-gray-900 mb-3">SLA (Acuerdos de Nivel de Servicio)</h4>
{#if ticket.sla_response_due}
<div class="mb-3">
@@ -406,16 +385,16 @@
<dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_response_due)}
{#if new Date(ticket.sla_response_due) < new Date() && !ticket.sla_response_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
⚠️ Vencido
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
Vencido
</span>
{:else if ticket.sla_response_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Cumplido
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
Cumplido
</span>
{:else}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
En plazo
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
En plazo
</span>
{/if}
</dd>
@@ -428,16 +407,16 @@
<dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_resolution_due)}
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
⚠️ Vencido
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
Vencido
</span>
{:else if ticket.sla_resolution_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Cumplido
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
Cumplido
</span>
{:else}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
En plazo
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
En plazo
</span>
{/if}
</dd>

View File

@@ -116,7 +116,7 @@
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
>
Nuevo Usuario
</button>
@@ -154,12 +154,12 @@
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{getTenantName(user.tenant_id)}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={user.is_active} class:text-green-800={user.is_active} class:bg-red-100={!user.is_active} class:text-red-800={!user.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {user.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
{user.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(user)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
<button on:click={() => openEditModal(user)} class="text-blue-700 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
@@ -177,27 +177,27 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label>
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}</label>
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700">Rol</label>
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
{#each ROLES as role}
<option value={role.value}>{role.label}</option>
{/each}
@@ -206,7 +206,7 @@
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - solo para usuarios externos)</label>
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<option value="">-- Ninguno (Usuario Interno) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
@@ -215,15 +215,15 @@
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>

View File

@@ -6,27 +6,23 @@ export default {
'bg-red-600',
'bg-blue-600',
'bg-green-600',
'bg-indigo-600',
'bg-orange-600',
'bg-yellow-500',
'bg-yellow-600',
'bg-gray-600',
// Colores de severidad/riesgo
'bg-red-50',
'bg-red-100',
'bg-red-800',
'bg-orange-50',
'bg-orange-100',
'bg-orange-300',
'bg-orange-600',
'bg-orange-700',
'bg-orange-800',
'bg-yellow-100',
'bg-yellow-300',
'bg-yellow-800',
'bg-blue-50',
'bg-blue-100',
'bg-blue-300',
'bg-blue-800',
'bg-green-50',
'bg-green-100',
'bg-green-300',
'bg-green-800',
@@ -34,7 +30,6 @@ export default {
'border-red-300',
'border-blue-200',
'border-orange-300',
'border-yellow-300',
'border-blue-300',
'border-green-300',
// Text colors
@@ -45,30 +40,26 @@ export default {
'text-orange-400',
'text-orange-600',
'text-orange-800',
'text-yellow-400',
'text-yellow-600',
'text-yellow-800',
'text-blue-400',
'text-blue-600',
'text-blue-700',
'text-blue-800',
'text-blue-900',
'text-green-600',
'text-green-800',
'text-indigo-600',
'text-white',
// Hover states
'hover:bg-red-50',
'hover:bg-red-700',
'hover:bg-orange-700',
'hover:bg-blue-50',
'hover:bg-blue-700',
'hover:bg-indigo-50',
'hover:bg-indigo-700',
'hover:bg-blue-800',
'hover:text-red-700',
// Focus rings
'focus:ring-red-500',
'focus:ring-orange-500',
'focus:ring-blue-500',
'focus:ring-indigo-500',
],
theme: {
extend: {
@@ -84,7 +75,46 @@ export default {
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
}
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
}
}
},

View File

@@ -2,6 +2,54 @@
Este directorio contiene scripts para desarrollo y despliegue del sistema.
## 🔒 Scripts de Seguridad y Testing
### security-test-data.ps1
```powershell
# Generar datos de prueba para análisis de seguridad
.\scripts\security-test-data.ps1
# Limpiar datos de prueba
.\scripts\security-test-data.ps1 limpiar
```
**¿Qué hace?**
- Genera 105 logs de auditoría en Docker (backend container)
- Simula 3 amenazas: 1 CRITICAL (55 eliminaciones) + 2 HIGH (25 logins fallidos + 5 cambios privilegios)
- Permite probar toda la funcionalidad del análisis de seguridad
- Ver página: http://localhost:3001/audit/security
**Escenarios:**
- Sin parámetro o `generar`: Crea amenazas de prueba
- `limpiar`: Elimina TODOS los logs de las últimas 24h ⚠️
**Comandos equivalentes en Docker:**
```powershell
# Generar
docker exec servicemanager-backend python scripts/generate_security_test_data.py
# Limpiar
docker exec servicemanager-backend python scripts/generate_security_test_data.py cleanup
```
### 📊 Datos de Prueba SLA Management
```powershell
# Generar tickets con diferentes estados de SLA
docker exec servicemanager-backend python scripts/generate_sla_test_data.py
# Limpiar tickets de prueba
docker exec servicemanager-backend python scripts/generate_sla_test_data.py cleanup
```
**¿Qué genera?**
- 71 tickets totales con diferentes estados de SLA
- **Response SLA**: 15 cumplidos + 8 violados + 10 en riesgo
- **Resolution SLA**: 20 cumplidos + 6 violados + 12 en riesgo
- Distribuidos por categorías (Soporte, Facturación, Incidentes, Consultas)
- Distribuidos por prioridades (LOW, MEDIUM, HIGH, URGENT)
- Ver resultados: http://localhost:3001/sla
## Scripts de Desarrollo
### setup-dev.sh / setup-dev.ps1

View File

@@ -0,0 +1,54 @@
# Script PowerShell para gestionar datos de prueba de seguridad
# Uso: .\scripts\security-test-data.ps1 [generar|limpiar]
param(
[Parameter(Position=0)]
[ValidateSet("generar", "limpiar", "")]
[string]$Accion = "generar"
)
Write-Host ""
Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " GENERADOR DE DATOS DE PRUEBA - ANÁLISIS SEGURIDAD" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
# Verificar que el contenedor esté corriendo
$container = docker ps --filter "name=servicemanager-backend" --format "{{.Names}}"
if (-not $container) {
Write-Host "❌ ERROR: El contenedor 'servicemanager-backend' no está corriendo" -ForegroundColor Red
Write-Host " Ejecuta primero: docker-compose up -d" -ForegroundColor Yellow
exit 1
}
Write-Host "✓ Contenedor backend encontrado: $container" -ForegroundColor Green
Write-Host ""
if ($Accion -eq "limpiar") {
Write-Host "🧹 LIMPIANDO datos de prueba..." -ForegroundColor Yellow
Write-Host ""
docker exec servicemanager-backend python scripts/generate_security_test_data.py cleanup
} else {
Write-Host "🚀 GENERANDO datos de prueba..." -ForegroundColor Green
Write-Host ""
docker exec servicemanager-backend python scripts/generate_security_test_data.py
}
Write-Host ""
Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
if ($Accion -eq "limpiar") {
Write-Host "✅ Limpieza completada" -ForegroundColor Green
} else {
Write-Host "✅ Generación completada" -ForegroundColor Green
Write-Host ""
Write-Host "📱 Accede a la página de seguridad:" -ForegroundColor Cyan
Write-Host " http://localhost:3001/audit/security" -ForegroundColor White
Write-Host ""
Write-Host "💡 Para limpiar los datos:" -ForegroundColor Yellow
Write-Host " .\scripts\security-test-data.ps1 limpiar" -ForegroundColor White
}
Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""

67
test_critical_sync.ps1 Normal file
View File

@@ -0,0 +1,67 @@
# Test para verificar sincronización de incidentes críticos
# entre módulo Auditoría y módulo Seguridad
Write-Host "`n=== TEST: Sincronización de Incidentes Críticos ===" -ForegroundColor Cyan
# 1. Login
Write-Host "`n1. Autenticando..." -ForegroundColor Yellow
$loginBody = @{
email = "admin@aduanasoft.com"
password = "admin123"
tenant_slug = "aduanasoft"
} | ConvertTo-Json
try {
$loginResponse = Invoke-RestMethod -Uri "http://localhost/api/v1/auth/login" -Method Post -Body $loginBody -ContentType "application/json"
$token = $loginResponse.access_token
$headers = @{
"Authorization" = "Bearer $token"
}
Write-Host "✓ Autenticado correctamente" -ForegroundColor Green
} catch {
Write-Host "✗ Error en login: $_" -ForegroundColor Red
exit 1
}
# 2. Obtener stats del módulo Auditoría
Write-Host "`n2. Consultando módulo Auditoría (/audit/stats)..." -ForegroundColor Yellow
try {
$auditStats = Invoke-RestMethod -Uri "http://localhost/api/v1/audit/stats" -Headers $headers
$auditCritical = $auditStats.critical_today
Write-Host "✓ Incidentes críticos en Auditoría: $auditCritical" -ForegroundColor Green
} catch {
Write-Host "✗ Error consultando Auditoría: $_" -ForegroundColor Red
exit 1
}
# 3. Obtener stats del módulo Seguridad
Write-Host "`n3. Consultando módulo Seguridad (/audit/security/analysis)..." -ForegroundColor Yellow
try {
$securityAnalysis = Invoke-RestMethod -Uri "http://localhost/api/v1/audit/security/analysis?hours=24" -Headers $headers
$securityCritical = $securityAnalysis.critical_actions_count
Write-Host "✓ Acciones críticas en Seguridad: $securityCritical" -ForegroundColor Green
} catch {
Write-Host "✗ Error consultando Seguridad: $_" -ForegroundColor Red
exit 1
}
# 4. Comparar resultados
Write-Host "`n4. Comparación:" -ForegroundColor Yellow
Write-Host " Auditoría: $auditCritical incidentes críticos" -ForegroundColor White
Write-Host " Seguridad: $securityCritical acciones críticas" -ForegroundColor White
if ($auditCritical -eq $securityCritical) {
Write-Host "`n✓ SINCRONIZADOS: Ambos módulos reportan el mismo número" -ForegroundColor Green
Write-Host " Los contadores están alineados correctamente." -ForegroundColor Green
} else {
Write-Host "`n✗ DESINCRONIZADOS: Los números no coinciden" -ForegroundColor Red
Write-Host " Diferencia: $([Math]::Abs($auditCritical - $securityCritical)) registros" -ForegroundColor Red
}
Write-Host "`n=== Detalles adicionales ===" -ForegroundColor Cyan
Write-Host "Amenazas detectadas: $($securityAnalysis.total_threats_detected)" -ForegroundColor White
Write-Host "Nivel de riesgo: $($securityAnalysis.overall_risk_level)" -ForegroundColor White
Write-Host "Login fallidos: $($securityAnalysis.failed_login_attempts)" -ForegroundColor White
Write-Host "IPs sospechosas: $($securityAnalysis.suspicious_ips_count)" -ForegroundColor White
Write-Host "`n=== Test completado ===" -ForegroundColor Cyan