Compare commits

...

3 Commits

Author SHA1 Message Date
cc1e964c3a Release v1.7.1 - Mejoras en SLA, Auditoria y Multi-tenant
 Características Nuevas:
- Cálculo automático de SLA en tickets basado en categoría
- Auto-asignación de tickets según configuración de categoría
- Auditoría completa en operaciones de categorías (create/update/delete)
- Visualización de estado SLA en listado y detalle de tickets

🐛 Correcciones:
- Fix actualización de status en tenants (manejo correcto de enum TenantStatus)
- Corrección de campos contact_phone y contact_email en tenants
- Corrección de modelo TicketResponse (agregar campos SLA y usar ConfigDict)
- Eliminación de archivo changelog duplicado

🔧 Mejoras de Infraestructura:
- Agregar montaje de backend en workers y beat para imports correctos
- Mejorar path handling en sla_tasks.py para Docker
- Scripts de testing integrados (test_frontend_integration, test_manual, test_tenant_update)
- Agregar database.py en workers/app/core para sesiones async

📝 Frontend:
- Actualizar UI de tenants con nuevos campos (email, teléfono, status enum)
- Agregar columna de SLA en listado de tickets
- Mostrar información detallada de SLA en vista de ticket individual
- Indicadores visuales de estado de SLA (vencido, cumplido, en plazo)
2026-02-17 10:26:56 -07:00
75726d915f v1.7.0 - Fix: Corregido error 500 en SLA Dashboard
- Fix error de sintaxis SQL en cálculo de tickets 'at risk'
- Fix error de timezone (offset-naive vs offset-aware datetimes)
- Implementado sistema completo de SLA Management
- Agregados endpoints: /sla/dashboard, /sla/violations, /sla/at-risk
- Creadas vistas frontend para dashboard, violaciones y tickets en riesgo
- Actualizado sistema de Celery para monitoreo automático de SLAs
- Mejorada configuración de categorías con tiempos SLA personalizables
- Corregidos problemas de proxy en configuración de Vite
- Agregado troubleshooting guide en README

Archivos principales modificados:
- backend/app/api/v1/endpoints/sla.py (nuevo)
- backend/app/api/schemas/sla.py (nuevo)
- frontend-internal/src/routes/sla/ (nuevo módulo completo)
- workers/app/tasks/sla_tasks.py (queries async mejoradas)

Documentación: docs/changelog-2026-02-17.md
2026-02-17 08:22:32 -07:00
42a5bb54cc style: Reemplazar escala de grises por colores semánticos en auditoría
- Estadísticas de auditoría con colores apropiados:
  * Total: Negro (neutro)
  * Hoy: Azul (actividad actual)
  * Esta Semana: Indigo (período reciente)
  * Incidentes Críticos: Rojo (alerta máxima)

- Estadísticas de seguridad con colores semánticos:
  * Amenazas Detectadas: Rojo (peligro alto)
  * Intentos Fallidos: Naranja (advertencia)
  * IPs Sospechosas: Amarillo (precaución)
  * Acciones Críticas: Rojo (crítico)

- Recomendaciones de seguridad con esquema azul (informativo)

- Actualizado safelist de Tailwind con nuevos colores:
  * text-blue-600, text-indigo-600, text-red-600
  * text-orange-600, text-yellow-600
  * text-*-400 para iconos
  * bg-blue-50, bg-red-50 para fondos
  * hover:text-red-700, hover:bg-red-50

Mejora significativa en la visualización y jerarquía visual de la información.
2026-02-16 13:09:54 -07:00
29 changed files with 3147 additions and 194 deletions

View File

@@ -129,6 +129,42 @@ cd ../frontend-internal
npm test
```
## Troubleshooting
### Error 500 en Login / Proxy Error
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
**Solución**:
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
### Tenant Slug Incorrecto
**Síntoma**: Error de autenticación incluso con credenciales correctas.
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
**Solución**:
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
2. Actualizar el tenant_slug en el código de login
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
### Credenciales de Prueba
```
Email: admin@aduanasoft.com
Password: admin123
Tenant: aduanasoft-demo
Role: ADMIN
```
## Contribución
1. Fork del proyecto

View File

@@ -0,0 +1,253 @@
"""
SLA Schemas - ServiceManagerWeb
Schemas para el sistema de gestión de SLAs
"""
from pydantic import BaseModel, ConfigDict
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
import uuid
class SLATypeEnum(str, Enum):
"""Tipos de SLA"""
RESPONSE = "response"
RESOLUTION = "resolution"
class SLAStatusEnum(str, Enum):
"""Estados de cumplimiento SLA"""
MET = "met" # Cumplido
VIOLATED = "violated" # Violado
AT_RISK = "at_risk" # En riesgo (80%+ del tiempo)
PENDING = "pending" # Pendiente (ticket aún abierto)
# ===================================
# DASHBOARD SCHEMAS
# ===================================
class SLAComplianceMetrics(BaseModel):
"""Métricas de cumplimiento SLA"""
target_hours: int
met_count: int
violated_count: int
at_risk_count: int
total_count: int
compliance_percentage: float
avg_time_hours: Optional[float] = None
class SLADashboardResponse(BaseModel):
"""Response del dashboard principal de SLA"""
tenant_id: uuid.UUID
period_start: datetime
period_end: datetime
generated_at: datetime
# Métricas generales
response_sla: SLAComplianceMetrics
resolution_sla: SLAComplianceMetrics
# Contadores rápidos
active_violations: int
at_risk_tickets: int
total_tickets_period: int
# Breakdown por categoría (top 5)
by_category: List[Dict[str, Any]]
# Breakdown por prioridad
by_priority: Dict[str, Dict[str, float]]
# Tendencias (comparación con período anterior)
trends: Dict[str, str]
model_config = ConfigDict(from_attributes=True)
# ===================================
# VIOLATIONS SCHEMAS
# ===================================
class TicketBasicInfo(BaseModel):
"""Información básica del ticket"""
id: uuid.UUID
ticket_number: str
subject: str
priority: str
status: str
class UserBasicInfo(BaseModel):
"""Información básica del usuario"""
id: uuid.UUID
first_name: str
last_name: str
email: str
class CategoryBasicInfo(BaseModel):
"""Información básica de categoría"""
id: uuid.UUID
name: str
sla_response_hours: int
sla_resolution_hours: int
class SLAViolationResponse(BaseModel):
"""Detalle de una violación SLA"""
ticket: TicketBasicInfo
category: Optional[CategoryBasicInfo] = None
created_by: UserBasicInfo
assigned_to: Optional[UserBasicInfo] = None
sla_type: SLATypeEnum
sla_due_at: datetime
violated_at: datetime
hours_overdue: float
# Contexto adicional
first_response_at: Optional[datetime] = None
resolved_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class SLAViolationsListResponse(BaseModel):
"""Lista paginada de violaciones"""
violations: List[SLAViolationResponse]
total: int
page: int
per_page: int
total_pages: int
# ===================================
# TICKETS AT RISK
# ===================================
class SLATicketAtRisk(BaseModel):
"""Ticket que está en riesgo de violar SLA"""
ticket: TicketBasicInfo
category: Optional[CategoryBasicInfo] = None
assigned_to: Optional[UserBasicInfo] = None
sla_type: SLATypeEnum
sla_due_at: datetime
time_remaining_hours: float
risk_percentage: float # 0-100, qué % del tiempo ha pasado
model_config = ConfigDict(from_attributes=True)
class SLAAtRiskListResponse(BaseModel):
"""Lista de tickets en riesgo"""
tickets: List[SLATicketAtRisk]
total: int
# ===================================
# METRICS & REPORTS SCHEMAS
# ===================================
class SLAMetricsByCategory(BaseModel):
"""Métricas SLA por categoría"""
category_id: uuid.UUID
category_name: str
response_sla_compliance: float
resolution_sla_compliance: float
total_tickets: int
response_violations: int
resolution_violations: int
avg_response_time_hours: Optional[float]
avg_resolution_time_hours: Optional[float]
class SLAMetricsByAgent(BaseModel):
"""Métricas SLA por agente"""
agent_id: uuid.UUID
agent_name: str
tickets_assigned: int
response_sla_met: int
resolution_sla_met: int
response_compliance: float
resolution_compliance: float
avg_response_time_hours: Optional[float]
avg_resolution_time_hours: Optional[float]
class SLAMetricsByPriority(BaseModel):
"""Métricas SLA por prioridad"""
priority: str
total_tickets: int
response_sla_compliance: float
resolution_sla_compliance: float
avg_response_time_hours: Optional[float]
avg_resolution_time_hours: Optional[float]
class SLADetailedMetricsResponse(BaseModel):
"""Response de métricas detalladas"""
tenant_id: uuid.UUID
date_from: datetime
date_to: datetime
group_by: str # 'category', 'agent', 'priority'
by_category: Optional[List[SLAMetricsByCategory]] = None
by_agent: Optional[List[SLAMetricsByAgent]] = None
by_priority: Optional[List[SLAMetricsByPriority]] = None
generated_at: datetime
model_config = ConfigDict(from_attributes=True)
# ===================================
# HISTORICAL TRENDS
# ===================================
class SLADailyTrend(BaseModel):
"""Tendencia diaria de SLA"""
date: str # YYYY-MM-DD
response_compliance: float
resolution_compliance: float
total_tickets: int
violations: int
class SLATrendsResponse(BaseModel):
"""Response de tendencias históricas"""
tenant_id: uuid.UUID
days: int
daily_trends: List[SLADailyTrend]
# Promedios del período
avg_response_compliance: float
avg_resolution_compliance: float
total_tickets: int
total_violations: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# CONFIGURATION
# ===================================
class SLAConfigByCategoryResponse(BaseModel):
"""Configuración SLA por categoría"""
category_id: uuid.UUID
category_name: str
sla_response_hours: int
sla_resolution_hours: int
warning_threshold_percentage: int # % del tiempo para alertar
is_active: bool
class SLAConfigListResponse(BaseModel):
"""Lista de configuraciones SLA"""
tenant_id: uuid.UUID
categories: List[SLAConfigByCategoryResponse]

View File

@@ -9,7 +9,8 @@ import uuid
from app.core.database import get_db
from app.models.category import Category
from app.models.user import User
from app.api import deps
from app.api import deps
from app.services.audit_service import AuditService
router = APIRouter()
@@ -98,6 +99,27 @@ async def create_category(
db.add(db_category)
await db.commit()
await db.refresh(db_category)
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="category.create",
resource_type="category",
resource_id=db_category.id,
new_values={
"name": db_category.name,
"sla_response_hours": db_category.sla_response_hours,
"sla_resolution_hours": db_category.sla_resolution_hours,
"is_active": db_category.is_active
}
)
await db.commit()
except Exception:
pass # No fallar si falla el audit log
return db_category
@@ -153,6 +175,14 @@ async def update_category(
detail="Category not found"
)
# Guardar valores anteriores para auditoría
old_values = {
"name": db_category.name,
"sla_response_hours": db_category.sla_response_hours,
"sla_resolution_hours": db_category.sla_resolution_hours,
"is_active": db_category.is_active
}
# Actualizar campos
update_data = category_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -160,6 +190,29 @@ async def update_category(
await db.commit()
await db.refresh(db_category)
# Registrar actualización en auditoría
try:
new_values = {
"name": db_category.name,
"sla_response_hours": db_category.sla_response_hours,
"sla_resolution_hours": db_category.sla_resolution_hours,
"is_active": db_category.is_active
}
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="category.update",
resource_type="category",
resource_id=db_category.id,
old_values=old_values,
new_values=new_values
)
await db.commit()
except Exception:
pass # No fallar si falla el audit log
return db_category
@@ -187,7 +240,30 @@ async def delete_category(
detail="Category not found"
)
# Guardar valores para auditoría
old_values = {
"name": db_category.name,
"is_active": db_category.is_active
}
# Soft delete
db_category.is_active = False
await db.commit()
# Registrar eliminación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="category.delete",
resource_type="category",
resource_id=db_category.id,
old_values=old_values,
new_values={"is_active": False}
)
await db.commit()
except Exception:
pass # No fallar si falla el audit log
return None

View File

@@ -0,0 +1,649 @@
"""
SLA Endpoints - ServiceManagerWeb
Endpoints para gestión y monitoreo de SLAs
Solo accesible por roles staff internos
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc, case, cast
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
import structlog
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
from app.models.user import User, UserRole
from app.models.tenant import Tenant
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.api.schemas.sla import (
SLADashboardResponse,
SLAComplianceMetrics,
SLAViolationResponse,
SLAViolationsListResponse,
SLAAtRiskListResponse,
SLATicketAtRisk,
SLADetailedMetricsResponse,
SLAMetricsByCategory,
SLAMetricsByAgent,
SLAMetricsByPriority,
SLATrendsResponse,
SLADailyTrend,
SLAConfigListResponse,
SLAConfigByCategoryResponse,
SLATypeEnum,
TicketBasicInfo,
UserBasicInfo,
CategoryBasicInfo
)
router = APIRouter()
logger = structlog.get_logger(__name__)
def require_staff_role(current_user: User = Depends(get_current_user)) -> User:
"""Requiere roles de staff interno (ADMIN, SUPPORT_MANAGER, AGENT)"""
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AGENT, UserRole.AUDITOR]
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo staff interno puede acceder a métricas de SLA"
)
return current_user
def require_manager_role(current_user: User = Depends(get_current_user)) -> User:
"""Requiere roles de gestión (ADMIN, SUPPORT_MANAGER)"""
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo managers pueden acceder a esta funcionalidad"
)
return current_user
# ===================================
# DASHBOARD PRINCIPAL
# ===================================
@router.get("/dashboard", response_model=SLADashboardResponse)
async def get_sla_dashboard(
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás para el período"),
current_user: User = Depends(require_staff_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Dashboard principal de métricas SLA.
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR
Retorna métricas agregadas de cumplimiento SLA para el período especificado.
"""
logger.info(
"SLA dashboard requested",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
days=days
)
now = datetime.now(timezone.utc)
period_start = now - timedelta(days=days)
# Usar func.now() para comparaciones en SQL (evita timezone issues)
db_now = func.now()
# Query base para tickets del período
base_query = select(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start
)
)
# Calcular métricas de Response SLA
# Para "at risk": ticket pendiente que ha consumido >80% del tiempo disponible
# Calculamos: (now - created_at) > 0.8 * (sla_response_due - created_at)
response_query = select(
func.count().label('total'),
func.sum(case((Ticket.first_response_at <= Ticket.sla_response_due, 1), else_=0)).label('met'),
func.sum(case((and_(Ticket.first_response_at > Ticket.sla_response_due, Ticket.first_response_at != None), 1), else_=0)).label('violated'),
func.sum(case((and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due), 1), else_=0)).label('violated_pending'),
func.sum(case((
and_(
Ticket.first_response_at == None,
Ticket.sla_response_due != None,
db_now < Ticket.sla_response_due,
func.extract('epoch', db_now - Ticket.created_at) > (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * 0.8)
), 1), else_=0)
).label('at_risk'),
func.avg(
func.extract('epoch', Ticket.first_response_at - Ticket.created_at) / 3600
).label('avg_hours')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start,
Ticket.sla_response_due != None
)
)
response_result = await db.execute(response_query)
response_row = response_result.one()
response_total = response_row.total or 0
response_met = (response_row.met or 0)
response_violated = (response_row.violated or 0) + (response_row.violated_pending or 0)
response_at_risk = response_row.at_risk or 0
response_avg = float(response_row.avg_hours) if response_row.avg_hours else 0.0
response_compliance = (response_met / response_total * 100) if response_total > 0 else 0.0
# Calcular métricas de Resolution SLA
resolution_query = select(
func.count().label('total'),
func.sum(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 1), else_=0)).label('met'),
func.sum(case((and_(Ticket.resolved_at > Ticket.sla_resolution_due, Ticket.resolved_at != None), 1), else_=0)).label('violated'),
func.sum(case((and_(Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]), Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due), 1), else_=0)).label('violated_pending'),
func.sum(case((
and_(
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
Ticket.sla_resolution_due != None,
db_now < Ticket.sla_resolution_due,
func.extract('epoch', db_now - Ticket.created_at) > (func.extract('epoch', Ticket.sla_resolution_due - Ticket.created_at) * 0.8)
), 1), else_=0)
).label('at_risk'),
func.avg(
func.extract('epoch', Ticket.resolved_at - Ticket.created_at) / 3600
).label('avg_hours')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start,
Ticket.sla_resolution_due != None
)
)
resolution_result = await db.execute(resolution_query)
resolution_row = resolution_result.one()
resolution_total = resolution_row.total or 0
resolution_met = (resolution_row.met or 0)
resolution_violated = (resolution_row.violated or 0) + (resolution_row.violated_pending or 0)
resolution_at_risk = resolution_row.at_risk or 0
resolution_avg = float(resolution_row.avg_hours) if resolution_row.avg_hours else 0.0
resolution_compliance = (resolution_met / resolution_total * 100) if resolution_total > 0 else 0.0
# Métricas por categoría (top 5)
category_query = select(
Category.id,
Category.name,
func.count(Ticket.id).label('ticket_count'),
func.avg(case((Ticket.first_response_at <= Ticket.sla_response_due, 100.0), else_=0.0)).label('response_compliance'),
func.avg(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 100.0), else_=0.0)).label('resolution_compliance')
).select_from(Ticket).join(
Category, Ticket.category_id == Category.id, isouter=True
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start
)
).group_by(Category.id, Category.name).order_by(desc('ticket_count')).limit(5)
category_result = await db.execute(category_query)
by_category = [
{
"category_id": str(row.id) if row.id else None,
"category_name": row.name or "Sin categoría",
"ticket_count": row.ticket_count,
"response_compliance": float(row.response_compliance or 0.0),
"resolution_compliance": float(row.resolution_compliance or 0.0)
}
for row in category_result.all()
]
# Métricas por prioridad
by_priority = {}
for priority in TicketPriority:
priority_query = select(
func.avg(case((Ticket.first_response_at <= Ticket.sla_response_due, 100.0), else_=0.0)).label('response'),
func.avg(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 100.0), else_=0.0)).label('resolution')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start,
Ticket.priority == priority
)
)
priority_result = await db.execute(priority_query)
priority_row = priority_result.one()
by_priority[priority.value] = {
"response_compliance": float(priority_row.response or 0.0),
"resolution_compliance": float(priority_row.resolution or 0.0)
}
# Calcular tendencias (comparación con período anterior)
prev_period_start = period_start - timedelta(days=days)
prev_response_query = select(
func.count().label('total'),
func.sum(case((Ticket.first_response_at <= Ticket.sla_response_due, 1), else_=0)).label('met')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= prev_period_start,
Ticket.created_at < period_start,
Ticket.sla_response_due != None
)
)
prev_response_result = await db.execute(prev_response_query)
prev_response_row = prev_response_result.one()
prev_response_compliance = ((prev_response_row.met or 0) / (prev_response_row.total or 1) * 100) if (prev_response_row.total or 0) > 0 else 0.0
response_trend = response_compliance - prev_response_compliance
response_trend_str = f"+{response_trend:.1f}%" if response_trend >= 0 else f"{response_trend:.1f}%"
prev_resolution_query = select(
func.count().label('total'),
func.sum(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 1), else_=0)).label('met')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= prev_period_start,
Ticket.created_at < period_start,
Ticket.sla_resolution_due != None
)
)
prev_resolution_result = await db.execute(prev_resolution_query)
prev_resolution_row = prev_resolution_result.one()
prev_resolution_compliance = ((prev_resolution_row.met or 0) / (prev_resolution_row.total or 1) * 100) if (prev_resolution_row.total or 0) > 0 else 0.0
resolution_trend = resolution_compliance - prev_resolution_compliance
resolution_trend_str = f"+{resolution_trend:.1f}%" if resolution_trend >= 0 else f"{resolution_trend:.1f}%"
# Contar violaciones activas
active_violations_query = select(func.count()).select_from(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
or_(
and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due),
and_(Ticket.resolved_at == None, Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due)
)
)
)
active_violations_result = await db.execute(active_violations_query)
active_violations = active_violations_result.scalar() or 0
# Contar total de tickets del período
total_tickets_query = select(func.count()).select_from(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start
)
)
total_tickets_result = await db.execute(total_tickets_query)
total_tickets_period = total_tickets_result.scalar() or 0
return SLADashboardResponse(
tenant_id=current_tenant.id,
period_start=period_start,
period_end=now,
generated_at=now,
response_sla=SLAComplianceMetrics(
target_hours=2, # Promedio, podría calcularse
met_count=response_met,
violated_count=response_violated,
at_risk_count=response_at_risk,
total_count=response_total,
compliance_percentage=response_compliance,
avg_time_hours=response_avg
),
resolution_sla=SLAComplianceMetrics(
target_hours=24, # Promedio, podría calcularse
met_count=resolution_met,
violated_count=resolution_violated,
at_risk_count=resolution_at_risk,
total_count=resolution_total,
compliance_percentage=resolution_compliance,
avg_time_hours=resolution_avg
),
active_violations=active_violations,
at_risk_tickets=response_at_risk + resolution_at_risk,
total_tickets_period=total_tickets_period,
by_category=by_category,
by_priority=by_priority,
trends={
"response_sla": response_trend_str,
"resolution_sla": resolution_trend_str
}
)
# ===================================
# VIOLACIONES
# ===================================
@router.get("/violations", response_model=SLAViolationsListResponse)
async def get_sla_violations(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100),
sla_type: Optional[str] = Query(default=None, regex="^(response|resolution)$"),
category_id: Optional[uuid.UUID] = None,
priority: Optional[str] = None,
current_user: User = Depends(require_staff_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Listar violaciones SLA activas.
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT (solo sus tickets), AUDITOR
Retorna tickets que han violado sus SLAs de respuesta o resolución.
"""
logger.info(
"SLA violations requested",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
sla_type=sla_type
)
now = datetime.now(timezone.utc)
db_now = func.now()
# Base query
query = select(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
)
)
# Filtrar por tipo de SLA
if sla_type == "response":
query = query.where(
and_(
Ticket.first_response_at == None,
Ticket.sla_response_due != None,
db_now > Ticket.sla_response_due
)
)
elif sla_type == "resolution":
query = query.where(
and_(
Ticket.sla_resolution_due != None,
db_now > Ticket.sla_resolution_due
)
)
else:
# Ambos tipos
query = query.where(
or_(
and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due),
and_(Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due)
)
)
# Filtros adicionales
if category_id:
query = query.where(Ticket.category_id == category_id)
if priority:
try:
priority_enum = TicketPriority(priority.upper())
query = query.where(Ticket.priority == priority_enum)
except ValueError:
pass
# AGENTS solo ven sus tickets
if current_user.role == UserRole.AGENT:
query = query.where(Ticket.assigned_to == current_user.id)
# Contar total
count_query = select(func.count()).select_from(query.subquery())
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)
result = await db.execute(query)
tickets = result.scalars().all()
# Formatear response
violations = []
for ticket in tickets:
# Determinar tipo de violación
response_violated = ticket.first_response_at is None and ticket.sla_response_due and now > ticket.sla_response_due
resolution_violated = ticket.sla_resolution_due and now > ticket.sla_resolution_due
# Priorizar resolution si ambos están violados
if resolution_violated:
violation_type = SLATypeEnum.RESOLUTION
due_at = ticket.sla_resolution_due
else:
violation_type = SLATypeEnum.RESPONSE
due_at = ticket.sla_response_due
hours_overdue = (now - due_at).total_seconds() / 3600 if due_at else 0
# Cargar relaciones
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
violations.append(SLAViolationResponse(
ticket=TicketBasicInfo(
id=ticket.id,
ticket_number=ticket.ticket_number,
subject=ticket.subject,
priority=ticket.priority.value,
status=ticket.status.value
),
category=CategoryBasicInfo(
id=ticket.category.id,
name=ticket.category.name,
sla_response_hours=ticket.category.sla_response_hours,
sla_resolution_hours=ticket.category.sla_resolution_hours
) if ticket.category else None,
created_by=UserBasicInfo(
id=ticket.created_by.id,
first_name=ticket.created_by.first_name,
last_name=ticket.created_by.last_name,
email=ticket.created_by.email
),
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,
sla_type=violation_type,
sla_due_at=due_at,
violated_at=due_at, # Se violó en el momento del due
hours_overdue=hours_overdue,
first_response_at=ticket.first_response_at,
resolved_at=ticket.resolved_at
))
total_pages = (total + limit - 1) // limit
return SLAViolationsListResponse(
violations=violations,
total=total,
page=(skip // limit) + 1,
per_page=limit,
total_pages=total_pages
)
# ===================================
# TICKETS EN RIESGO
# ===================================
@router.get("/at-risk", response_model=SLAAtRiskListResponse)
async def get_tickets_at_risk(
threshold: int = Query(default=80, ge=50, le=95, description="% de tiempo consumido para considerar en riesgo"),
current_user: User = Depends(require_staff_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Listar tickets que están en riesgo de violar SLA.
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT (solo sus tickets), AUDITOR
Retorna tickets que están cerca de vencer su SLA (por defecto, 80% del tiempo consumido).
"""
logger.info(
"SLA at-risk tickets requested",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
threshold=threshold
)
now = datetime.now(timezone.utc)
db_now = func.now()
threshold_decimal = threshold / 100.0
# Query para tickets en riesgo
# Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold
query = select(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
or_(
# Response SLA en riesgo
and_(
Ticket.first_response_at == None,
Ticket.sla_response_due != None,
db_now < Ticket.sla_response_due,
# Calcular si está en zona de riesgo
func.extract('epoch', db_now - Ticket.created_at) >= (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * threshold_decimal)
),
# Resolution SLA en riesgo
and_(
Ticket.sla_resolution_due != None,
db_now < Ticket.sla_resolution_due,
func.extract('epoch', db_now - Ticket.created_at) >= (func.extract('epoch', Ticket.sla_resolution_due - Ticket.created_at) * threshold_decimal)
)
)
)
).order_by(desc(Ticket.sla_response_due if Ticket.sla_response_due else Ticket.sla_resolution_due))
# AGENTS solo ven sus tickets
if current_user.role == UserRole.AGENT:
query = query.where(Ticket.assigned_to == current_user.id)
result = await db.execute(query)
tickets = result.scalars().all()
# Formatear response
at_risk_tickets = []
for ticket in tickets:
# Determinar cuál SLA está en riesgo
response_at_risk = (
ticket.first_response_at is None and
ticket.sla_response_due and
now < ticket.sla_response_due
)
resolution_at_risk = (
ticket.sla_resolution_due and
now < ticket.sla_resolution_due
)
# Priorizar response si ambos están en riesgo
if response_at_risk:
sla_type = SLATypeEnum.RESPONSE
due_at = ticket.sla_response_due
elif resolution_at_risk:
sla_type = SLATypeEnum.RESOLUTION
due_at = ticket.sla_resolution_due
else:
continue
time_remaining = (due_at - now).total_seconds() / 3600
total_time = (due_at - ticket.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'])
at_risk_tickets.append(SLATicketAtRisk(
ticket=TicketBasicInfo(
id=ticket.id,
ticket_number=ticket.ticket_number,
subject=ticket.subject,
priority=ticket.priority.value,
status=ticket.status.value
),
category=CategoryBasicInfo(
id=ticket.category.id,
name=ticket.category.name,
sla_response_hours=ticket.category.sla_response_hours,
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,
sla_type=sla_type,
sla_due_at=due_at,
time_remaining_hours=time_remaining,
risk_percentage=risk_percentage
))
return SLAAtRiskListResponse(
tickets=at_risk_tickets,
total=len(at_risk_tickets)
)
# ===================================
# CONFIGURACIÓN
# ===================================
@router.get("/config", response_model=SLAConfigListResponse)
async def get_sla_config(
current_user: User = Depends(require_manager_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener configuración de SLAs por categoría.
**Permisos**: ADMIN, SUPPORT_MANAGER
Retorna la configuración de tiempos SLA para todas las categorías del tenant.
"""
query = select(Category).where(
Category.tenant_id == current_tenant.id
).order_by(Category.name)
result = await db.execute(query)
categories = result.scalars().all()
return SLAConfigListResponse(
tenant_id=current_tenant.id,
categories=[
SLAConfigByCategoryResponse(
category_id=cat.id,
category_name=cat.name,
sla_response_hours=cat.sla_response_hours,
sla_resolution_hours=cat.sla_resolution_hours,
warning_threshold_percentage=80, # Por ahora hardcoded
is_active=cat.is_active
)
for cat in categories
]
)

View File

@@ -16,6 +16,7 @@ class TenantBase(BaseModel):
slug: str
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = None
class TenantCreate(TenantBase):
pass
@@ -25,6 +26,7 @@ class TenantUpdate(BaseModel):
slug: Optional[str] = None
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = None
status: Optional[TenantStatus] = None
class TenantResponse(TenantBase):
@@ -86,12 +88,16 @@ async def update_tenant(
update_data = tenant_in.model_dump(exclude_unset=True)
if "status" in update_data:
tenant.is_active = update_data.pop("status") == TenantStatus.active
# Convertir string a enum TenantStatus
status_value = update_data.pop("status")
if isinstance(status_value, str):
tenant.status = TenantStatus(status_value)
else:
tenant.status = status_value
for field, value in update_data.items():
setattr(tenant, field, value)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return tenant

View File

@@ -4,7 +4,7 @@ Tickets endpoints - ServiceManagerWeb
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
@@ -45,6 +45,8 @@ class TicketUpdate(BaseModel):
assigned_to: Optional[str] = None
class TicketResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
ticket_number: str
subject: str
@@ -58,9 +60,10 @@ class TicketResponse(BaseModel):
assigned_to: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
sla_response_due: Optional[datetime] = None
sla_resolution_due: Optional[datetime] = None
first_response_at: Optional[datetime] = None
resolved_at: Optional[datetime] = None
class TicketCloseRequest(BaseModel):
resolution: Optional[str] = None
@@ -108,6 +111,7 @@ async def create_ticket(
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
# Validar categoría
category = None
if category_uuid:
category = await db.get(Category, category_uuid)
if not category:
@@ -125,6 +129,21 @@ async def create_ticket(
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
)
# Calcular SLA deadlines basados en la categoría
from datetime import timedelta
sla_response_due = None
sla_resolution_due = None
assigned_to_user = None
if category:
now = datetime.utcnow()
sla_response_due = now + timedelta(hours=category.sla_response_hours)
sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours)
# Auto-asignar si la categoría tiene configurado auto_assign_to
if category.auto_assign_to:
assigned_to_user = category.auto_assign_to
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
@@ -135,7 +154,10 @@ async def create_ticket(
affected_system_id=system_uuid,
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
assigned_to=assigned_to_user,
status=TicketStatus.NEW,
sla_response_due=sla_response_due,
sla_resolution_due=sla_resolution_due,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
@@ -251,7 +273,7 @@ async def get_tickets(
result = await db.execute(query)
tickets = result.scalars().all()
# ✅ CORREGIDO: Usar affected_system_id
# ✅ CORREGIDO: Usar affected_system_id y agregar campos SLA
return [
{
"id": str(t.id),
@@ -266,7 +288,11 @@ async def get_tickets(
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
"updated_at": t.updated_at,
"sla_response_due": t.sla_response_due,
"sla_resolution_due": t.sla_resolution_due,
"first_response_at": t.first_response_at,
"resolved_at": t.resolved_at
}
for t in tickets
]

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla
api_router = APIRouter()
@@ -66,4 +66,11 @@ api_router.include_router(
audit.router,
prefix="/audit",
tags=["audit"]
)
# SLA routes
api_router.include_router(
sla.router,
prefix="/sla",
tags=["sla"]
)

35
backend/check_tenants.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Utility script to list all tenants in the database
"""
import asyncio
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.tenant import Tenant
async def list_tenants():
"""List all tenants with their details."""
async with AsyncSessionLocal() as db:
result = await db.execute(select(Tenant))
tenants = result.scalars().all()
print("\n" + "="*60)
print("📋 TENANTS EN LA BASE DE DATOS")
print("="*60 + "\n")
if not tenants:
print("⚠️ No hay tenants en la base de datos\n")
print("💡 Ejecuta las migraciones o crea un tenant manualmente")
return
for tenant in tenants:
print(f"Slug: {tenant.slug}")
print(f"Nombre: {tenant.name}")
print(f"Status: {tenant.status}")
print(f"Email: {tenant.contact_email or 'N/A'}")
print(f"ID: {tenant.id}")
print("-" * 60)
print(f"\nTotal: {len(tenants)} tenant(s)\n")
if __name__ == "__main__":
asyncio.run(list_tenants())

View File

@@ -110,6 +110,7 @@ services:
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
volumes:
- ./workers:/app
- ./backend:/backend:ro
- uploads_data:/app/uploads
- logs_data:/app/logs
depends_on:
@@ -140,6 +141,7 @@ services:
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
volumes:
- ./workers:/app
- ./backend:/backend:ro
depends_on:
postgres:
condition: service_healthy
@@ -161,7 +163,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Cliente
volumes:
- ./frontend-client:/app
@@ -186,7 +188,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Admin
volumes:
- ./frontend-internal:/app

View File

@@ -8,7 +8,7 @@ export default defineConfig({
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://servicemanager-backend:8000',
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}

View File

@@ -0,0 +1,71 @@
<script lang="ts">
export let value: number = 0; // Percentage 0-100
export let label: string = '';
export let size: 'sm' | 'md' | 'lg' = 'md';
$: color = getColor(value);
$: sizeClass = getSizeClass(size);
$: strokeDasharray = `${(value / 100) * 283} 283`;
function getColor(val: number): string {
if (val >= 95) return '#10B981'; // green
if (val >= 85) return '#FBBF24'; // yellow
if (val >= 70) return '#F97316'; // orange
return '#EF4444'; // red
}
function getSizeClass(s: string): { width: number; fontSize: string } {
switch (s) {
case 'sm':
return { width: 80, fontSize: 'text-lg' };
case 'lg':
return { width: 160, fontSize: 'text-4xl' };
default:
return { width: 120, fontSize: 'text-2xl' };
}
}
</script>
<div class="flex flex-col items-center">
<svg width={sizeClass.width} height={sizeClass.width} viewBox="0 0 100 100">
<!-- Background circle -->
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="#E5E7EB"
stroke-width="10"
/>
<!-- Progress circle -->
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke={color}
stroke-width="10"
stroke-dasharray={strokeDasharray}
stroke-linecap="round"
transform="rotate(-90 50 50)"
style="transition: stroke-dasharray 0.5s ease;"
/>
<!-- Center text -->
<text
x="50"
y="50"
text-anchor="middle"
dominant-baseline="middle"
class="fill-current text-gray-900 font-bold"
font-size="20"
>
{value.toFixed(0)}%
</text>
</svg>
{#if label}
<p class="mt-2 text-sm font-medium text-gray-600">{label}</p>
{/if}
</div>

View File

@@ -629,26 +629,26 @@
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-sm text-gray-500">Hoy</div>
<div class="text-2xl font-bold text-gray-600">{stats.actions_today}</div>
<div class="text-2xl font-bold text-blue-600">{stats.actions_today}</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-sm text-gray-500">Esta Semana</div>
<div class="text-2xl font-bold text-gray-600">{stats.actions_this_week}</div>
<div class="text-2xl font-bold text-indigo-600">{stats.actions_this_week}</div>
</div>
<div class="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<div class="flex items-center gap-2 mb-1">
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-red-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>
<div class="text-sm text-gray-500">Incidentes Criticos</div>
</div>
<div class="flex items-center justify-between">
<div class="text-2xl font-bold text-gray-800">
<div class="text-2xl font-bold text-red-600">
{stats.critical_actions_today || 0}
</div>
<button
type="button"
class="text-xs text-gray-600 hover:text-gray-800 font-medium flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
class="text-xs text-red-600 hover:text-red-700 font-medium flex items-center gap-1 px-2 py-1 rounded hover:bg-red-50 transition-colors"
on:click={() => filterCriticalActions()}
title="Ver incidentes críticos"
>

View File

@@ -257,9 +257,9 @@
<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-gray-800">{analysis.total_threats_detected}</p>
<p class="text-2xl font-bold text-red-600">{analysis.total_threats_detected}</p>
</div>
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
@@ -269,9 +269,9 @@
<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-gray-700">{analysis.failed_login_attempts}</p>
<p class="text-2xl font-bold text-orange-600">{analysis.failed_login_attempts}</p>
</div>
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
@@ -281,9 +281,9 @@
<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-gray-700">{analysis.suspicious_ips_count}</p>
<p class="text-2xl font-bold text-yellow-600">{analysis.suspicious_ips_count}</p>
</div>
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
@@ -293,9 +293,9 @@
<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-gray-700">{analysis.critical_actions_count}</p>
<p class="text-2xl font-bold text-red-600">{analysis.critical_actions_count}</p>
</div>
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
@@ -304,17 +304,17 @@
<!-- Recomendaciones Generales -->
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<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-gray-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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-gray-900 mb-2">Acciones Recomendadas</h4>
<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-gray-700 flex items-start gap-2">
<svg class="w-4 h-4 text-gray-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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}

View File

@@ -14,7 +14,10 @@
name: '',
description: '',
tenant_id: '',
is_active: true
is_active: true,
color: '#4F46E5',
sla_response_hours: 24,
sla_resolution_hours: 72
};
async function loadData() {
@@ -35,7 +38,15 @@
function openCreateModal() {
editingCategory = null;
formData = { name: '', description: '', tenant_id: '', is_active: true };
formData = {
name: '',
description: '',
tenant_id: '',
is_active: true,
color: '#4F46E5',
sla_response_hours: 24,
sla_resolution_hours: 72
};
showModal = true;
}
@@ -45,7 +56,10 @@
name: category.name,
description: category.description,
tenant_id: category.tenant_id || '',
is_active: category.is_active
is_active: category.is_active,
color: category.color || '#4F46E5',
sla_response_hours: category.sla_response_hours || 24,
sla_resolution_hours: category.sla_resolution_hours || 72
};
showModal = true;
}
@@ -104,6 +118,8 @@
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Descripción</th>
<th scope="col" class="px-3 py-3.5 text-center text-sm font-semibold text-gray-900">SLA Respuesta</th>
<th scope="col" class="px-3 py-3.5 text-center text-sm font-semibold text-gray-900">SLA Resolución</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Tipo (Cliente)</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
@@ -113,14 +129,31 @@
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
<tr><td colspan="7" class="text-center py-4">Cargando...</td></tr>
{:else if categories.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay categorías registradas</td></tr>
<tr><td colspan="7" class="text-center py-4">No hay categorías registradas</td></tr>
{:else}
{#each categories as category}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{category.name}</td>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6">
<div class="flex items-center gap-2">
{#if category.color}
<div class="w-3 h-3 rounded-full" style="background-color: {category.color}"></div>
{/if}
<span class="font-medium text-gray-900">{category.name}</span>
</div>
</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>
</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>
</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">
{getTenantName(category.tenant_id)}
@@ -148,7 +181,7 @@
<Modal open={showModal} title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<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">
</div>
@@ -157,6 +190,61 @@
<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>
</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">
</div>
</div>
<div class="border-t pt-4">
<h3 class="text-sm font-medium text-gray-900 mb-3">Configuración de SLA</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="sla_response_hours" class="block text-sm font-medium text-gray-700">
Tiempo de Respuesta (horas) *
</label>
<input
type="number"
id="sla_response_hours"
bind:value={formData.sla_response_hours}
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"
>
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para primera respuesta</p>
</div>
<div>
<label for="sla_resolution_hours" class="block text-sm font-medium text-gray-700">
Tiempo de Resolución (horas) *
</label>
<input
type="number"
id="sla_resolution_hours"
bind:value={formData.sla_resolution_hours}
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"
>
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para resolver el ticket</p>
</div>
</div>
<div class="mt-3 p-3 bg-blue-50 rounded-md">
<p class="text-xs text-blue-700">
Ejemplos comunes:<br>
- Crítico: Respuesta 1h, Resolución 8h<br>
- Alto: Respuesta 2h, Resolución 24h<br>
- Normal: Respuesta 4h, Resolución 48h<br>
- Bajo: Respuesta 24h, Resolución 72h
</p>
</div>
</div>
<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">

View File

@@ -32,7 +32,7 @@
await auth.login({
email,
password,
tenant_slug: 'system-admin',
tenant_slug: 'aduanasoft-demo',
totp_code: totpCode || undefined
});

View File

@@ -0,0 +1,313 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
let isLoading = true;
let dashboardData: any = null;
let selectedPeriod = 30;
async function loadDashboard() {
isLoading = true;
try {
dashboardData = await api.get(`/sla/dashboard?days=${selectedPeriod}`);
} catch (e: any) {
toast.error(e.message || 'Error cargando dashboard SLA');
} finally {
isLoading = false;
}
}
function getRiskColor(percentage: number): string {
if (percentage >= 95) return 'text-green-600';
if (percentage >= 85) return 'text-yellow-600';
if (percentage >= 70) return 'text-orange-600';
return 'text-red-600';
}
function getTrendIcon(trend: string): string {
if (trend.startsWith('+')) return '↗';
if (trend.startsWith('-')) return '↘';
return '→';
}
function getTrendColor(trend: string): string {
if (trend.startsWith('+')) return 'text-green-600';
if (trend.startsWith('-')) return 'text-red-600';
return 'text-gray-600';
}
function getMetricValue(metrics: any, key: string): number {
return metrics[key] || 0;
}
onMount(loadDashboard);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex-auto">
<h1 class="text-2xl font-bold text-gray-900">SLA Management</h1>
<p class="mt-2 text-sm text-gray-700">Monitoreo y métricas de cumplimiento de SLAs</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<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"
>
<option value={7}>Últimos 7 días</option>
<option value={30}>Últimos 30 días</option>
<option value={90}>Últimos 90 días</option>
</select>
</div>
</div>
{#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>
<p class="mt-2 text-sm text-gray-500">Cargando métricas...</p>
</div>
{:else if dashboardData}
<!-- KPI Cards -->
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<!-- 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>
</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>
</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>
</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>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="mt-8 grid grid-cols-1 gap-5 lg:grid-cols-2">
<!-- Breakdown por Categoría -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Cumplimiento por Categoría</h3>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Categoría</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Tickets</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Respuesta</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Resolución</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each dashboardData.by_category as cat}
<tr>
<td class="px-3 py-2 text-sm text-gray-900">{cat.category_name}</td>
<td class="px-3 py-2 text-sm text-gray-500 text-right">{cat.ticket_count}</td>
<td class="px-3 py-2 text-sm text-right">
<span class="font-medium {getRiskColor(cat.response_compliance)}">
{cat.response_compliance.toFixed(1)}%
</span>
</td>
<td class="px-3 py-2 text-sm text-right">
<span class="font-medium {getRiskColor(cat.resolution_compliance)}">
{cat.resolution_compliance.toFixed(1)}%
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Breakdown por Prioridad -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Cumplimiento por Prioridad</h3>
<div class="space-y-4">
{#each Object.entries(dashboardData.by_priority) as [priority, metrics]}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-gray-700">{priority}</span>
<span class="text-gray-500">
Response: <span class="{getRiskColor(getMetricValue(metrics, 'response_compliance'))}">{getMetricValue(metrics, 'response_compliance').toFixed(1)}%</span>
| Resolution: <span class="{getRiskColor(getMetricValue(metrics, 'resolution_compliance'))}">{getMetricValue(metrics, 'resolution_compliance').toFixed(1)}%</span>
</span>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="bg-gray-200 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full {getRiskColor(getMetricValue(metrics, 'response_compliance')).replace('text-', 'bg-')}"
style="width: {getMetricValue(metrics, 'response_compliance')}%"
></div>
</div>
<div class="bg-gray-200 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full {getRiskColor(getMetricValue(metrics, 'resolution_compliance')).replace('text-', 'bg-')}"
style="width: {getMetricValue(metrics, 'resolution_compliance')}%"
></div>
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mt-8 bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Acciones Rápidas</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<a
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>
</div>
<!-- Summary Stats -->
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3 text-center">
<div>
<p class="text-sm text-gray-500">Total de Tickets</p>
<p class="text-2xl font-semibold text-gray-900">{dashboardData.total_tickets_period}</p>
</div>
<div>
<p class="text-sm text-gray-500">Tiempo Promedio de Respuesta</p>
<p class="text-2xl font-semibold text-gray-900">
{dashboardData.response_sla.avg_time_hours?.toFixed(1) || 'N/A'} hrs
</p>
</div>
<div>
<p class="text-sm text-gray-500">Tiempo Promedio de Resolución</p>
<p class="text-2xl font-semibold text-gray-900">
{dashboardData.resolution_sla.avg_time_hours?.toFixed(1) || 'N/A'} hrs
</p>
</div>
</div>
</div>
{:else}
<div class="mt-8 text-center">
<p class="text-gray-500">No se pudieron cargar los datos</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,240 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
let isLoading = true;
let atRiskTickets: any[] = [];
let threshold = 80;
async function loadAtRiskTickets() {
isLoading = true;
try {
const data: any = await api.get(`/sla/at-risk?threshold=${threshold}`);
atRiskTickets = data.tickets || [];
} catch (e: any) {
toast.error(e.message || 'Error cargando tickets en riesgo');
atRiskTickets = [];
} finally {
isLoading = false;
}
}
function formatHours(hours: number): string {
if (hours < 1) {
return `${Math.round(hours * 60)} min`;
} else if (hours < 24) {
return `${hours.toFixed(1)} hrs`;
} else {
const days = Math.floor(hours / 24);
const remainingHours = Math.round(hours % 24);
return `${days}d ${remainingHours}h`;
}
}
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';
}
function getRiskLabel(percentage: number): string {
if (percentage >= 95) return 'Crítico';
if (percentage >= 90) return 'Alto';
if (percentage >= 80) return 'Medio';
return 'Bajo';
}
function getPriorityColor(priority: string): string {
const colors: Record<string, string> = {
'LOW': 'bg-gray-100 text-gray-800',
'MEDIUM': 'bg-blue-100 text-blue-800',
'HIGH': 'bg-orange-100 text-orange-800',
'URGENT': 'bg-red-100 text-red-800'
};
return colors[priority] || 'bg-gray-100 text-gray-800';
}
function getSLATypeLabel(type: string): string {
return type === 'response' ? 'Respuesta' : 'Resolución';
}
onMount(loadAtRiskTickets);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex-auto">
<h1 class="text-2xl font-bold text-gray-900">Tickets en Riesgo</h1>
<p class="mt-2 text-sm text-gray-700">
Tickets que están próximos a violar sus SLAs
</p>
</div>
<div class="mt-4 sm:mt-0 flex gap-3">
<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"
>
<option value={70}>70% del tiempo</option>
<option value={80}>80% del tiempo</option>
<option value={90}>90% del tiempo</option>
</select>
<a
href="/sla"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
← Volver al Dashboard
</a>
</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>
</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>
</div>
</div>
</div>
<!-- Risk Tickets List -->
<div class="mt-6 space-y-4">
{#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>
{: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-1 text-sm text-gray-500">No hay tickets en riesgo de violar SLA</p>
</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">
<a
href="/tickets/{ticket.ticket.id}"
class="text-lg font-semibold text-indigo-600 hover:text-indigo-900"
>
{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)}">
{ticket.ticket.priority}
</span>
<span class="text-sm text-gray-500">
{getSLATypeLabel(ticket.sla_type)}
</span>
</div>
<p class="mt-1 text-sm text-gray-900">{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>
<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>
<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>
</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>
<div class="bg-gray-50 px-6 py-3 flex justify-end gap-3">
<a
href="/tickets/{ticket.ticket.id}"
class="text-sm font-medium text-indigo-600 hover:text-indigo-900"
>
Ver ticket →
</a>
</div>
</div>
{/each}
{/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">
<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>
</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>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,389 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
let isLoading = true;
let violations: any[] = [];
let total = 0;
let page = 1;
let perPage = 20;
let totalPages = 0;
// Filtros
let slaTypeFilter = '';
let categoryFilter = '';
let priorityFilter = '';
let categories: any[] = [];
async function loadViolations() {
isLoading = true;
try {
const params = new URLSearchParams();
params.append('skip', String((page - 1) * perPage));
params.append('limit', String(perPage));
if (slaTypeFilter) params.append('sla_type', slaTypeFilter);
if (categoryFilter) params.append('category_id', categoryFilter);
if (priorityFilter) params.append('priority', priorityFilter);
const data: any = await api.get(`/sla/violations?${params.toString()}`);
violations = data.violations || [];
total = data.total || 0;
totalPages = data.total_pages || 0;
} catch (e: any) {
toast.error(e.message || 'Error cargando violaciones');
violations = [];
} finally {
isLoading = false;
}
}
async function loadCategories() {
try {
categories = await api.get('/categories/');
} catch (e) {
console.error('Error loading categories', e);
}
}
function formatHours(hours: number): string {
if (hours < 1) {
return `${Math.round(hours * 60)} min`;
} else if (hours < 24) {
return `${hours.toFixed(1)} hrs`;
} else {
const days = Math.floor(hours / 24);
const remainingHours = Math.round(hours % 24);
return `${days}d ${remainingHours}h`;
}
}
function getPriorityColor(priority: string): string {
const colors: Record<string, string> = {
'LOW': 'bg-gray-100 text-gray-800',
'MEDIUM': 'bg-blue-100 text-blue-800',
'HIGH': 'bg-orange-100 text-orange-800',
'URGENT': 'bg-red-100 text-red-800'
};
return colors[priority] || 'bg-gray-100 text-gray-800';
}
function getSLATypeLabel(type: string): string {
return type === 'response' ? 'Respuesta' : 'Resolución';
}
function getSLATypeColor(type: string): string {
return type === 'response' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800';
}
function handleFilterChange() {
page = 1;
loadViolations();
}
function nextPage() {
if (page < totalPages) {
page++;
loadViolations();
}
}
function prevPage() {
if (page > 1) {
page--;
loadViolations();
}
}
onMount(() => {
loadCategories();
loadViolations();
});
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex-auto">
<h1 class="text-2xl font-bold text-gray-900">Violaciones SLA</h1>
<p class="mt-2 text-sm text-gray-700">
Tickets que han violado sus SLAs de respuesta o resolución
</p>
</div>
<div class="mt-4 sm:mt-0">
<a
href="/sla"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
← Volver al Dashboard
</a>
</div>
</div>
<!-- Filters -->
<div class="mt-6 bg-white shadow rounded-lg p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label for="slaType" class="block text-sm font-medium text-gray-700">Tipo de SLA</label>
<select
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"
>
<option value="">Todos</option>
<option value="response">Respuesta</option>
<option value="resolution">Resolución</option>
</select>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700">Categoría</label>
<select
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"
>
<option value="">Todas</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<div>
<label for="priority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<select
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"
>
<option value="">Todas</option>
<option value="LOW">Baja</option>
<option value="MEDIUM">Media</option>
<option value="HIGH">Alta</option>
<option value="URGENT">Urgente</option>
</select>
</div>
<div class="flex items-end">
<button
on:click={() => {
slaTypeFilter = '';
categoryFilter = '';
priorityFilter = '';
handleFilterChange();
}}
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Limpiar Filtros
</button>
</div>
</div>
</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
{#if total > 0}
- Requieren atención inmediata
{/if}
</p>
</div>
</div>
</div>
<!-- Violations Table -->
<div class="mt-6 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">
Ticket
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Categoría
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Tipo SLA
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Prioridad
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Tiempo Vencido
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Asignado a
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<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>
</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>
</tr>
{:else}
{#each violations as violation}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 sm:pl-6">
<div class="flex flex-col">
<a
href="/tickets/{violation.ticket.id}"
class="font-medium text-indigo-600 hover:text-indigo-900"
>
{violation.ticket.ticket_number}
</a>
<span class="text-sm text-gray-500 truncate max-w-xs">
{violation.ticket.subject}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{#if violation.category}
<span class="text-gray-900">{violation.category.name}</span>
<div class="text-xs text-gray-500">
R: {violation.category.sla_response_hours}h |
Res: {violation.category.sla_resolution_hours}h
</div>
{:else}
<span class="text-gray-400">Sin categoría</span>
{/if}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {getSLATypeColor(violation.sla_type)}">
{getSLATypeLabel(violation.sla_type)}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {getPriorityColor(violation.ticket.priority)}">
{violation.ticket.priority}
</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>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{#if violation.assigned_to}
<div class="flex flex-col">
<span class="text-gray-900">
{violation.assigned_to.first_name} {violation.assigned_to.last_name}
</span>
<span class="text-xs text-gray-500">
{violation.assigned_to.email}
</span>
</div>
{:else}
<span class="text-gray-400 italic">Sin asignar</span>
{/if}
</td>
<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"
>
Ver ticket →
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Pagination -->
{#if 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-700">
Mostrando
<span class="font-medium">{(page - 1) * perPage + 1}</span>
a
<span class="font-medium">{Math.min(page * perPage, total)}</span>
de
<span class="font-medium">{total}</span>
resultados
</p>
</div>
<div>
<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-2 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"
>
<span class="sr-only">Anterior</span>
</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-2 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"
>
<span class="sr-only">Siguiente</span>
</button>
</nav>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -14,7 +14,9 @@
name: '',
slug: '',
domain: '',
is_active: true
contact_phone: '',
contact_email: '',
status: 'active'
};
async function loadTenants() {
@@ -30,27 +32,30 @@
function openCreateModal() {
editingTenant = null;
formData = { name: '', slug: '', domain: '', is_active: true };
formData = { name: '', slug: '', domain: '', contact_phone: '', contact_email: '', status: 'active' };
showModal = true;
}
function openEditModal(tenant) {
editingTenant = tenant;
formData = { ...tenant };
formData = {
name: tenant.name,
slug: tenant.slug,
domain: tenant.domain || '',
contact_phone: tenant.contact_phone || '',
contact_email: tenant.contact_email || '',
status: tenant.status || 'active'
};
showModal = true;
}
async function handleSubmit() {
try {
if (editingTenant) {
// Asegurarse de enviar el campo "status" correctamente
const updatedData = { ...formData, status: formData.is_active ? 'active' : 'inactive' };
await api.put(`/tenants/${editingTenant.id}`, updatedData);
toast.success(`Cliente ${formData.is_active ? 'activado' : 'desactivado'} correctamente`);
await api.put(`/tenants/${editingTenant.id}`, formData);
toast.success('Cliente actualizado correctamente');
} else {
// Asegurarse de enviar el campo "status" al crear un cliente
const newData = { ...formData, status: formData.is_active ? 'active' : 'inactive' };
await api.post('/tenants/', newData);
await api.post('/tenants/', formData);
toast.success('Cliente creado correctamente');
}
showModal = false;
@@ -89,7 +94,8 @@
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Dominio</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Email Contacto</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Teléfono</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
@@ -98,18 +104,19 @@
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
<tr><td colspan="6" class="text-center py-4">Cargando...</td></tr>
{:else if tenants.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay clientes registrados</td></tr>
<tr><td colspan="6" class="text-center py-4">No hay clientes registrados</td></tr>
{:else}
{#each tenants as tenant}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{tenant.name}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.slug}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.domain || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.contact_email || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.contact_phone || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={tenant.is_active} class:text-green-800={tenant.is_active} class:bg-red-100={!tenant.is_active} class:text-red-800={!tenant.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{tenant.is_active ? 'Activo' : 'Inactivo'}
<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">
{tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
@@ -144,9 +151,23 @@
<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">
</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">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
<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">
</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">
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700">Estado</label>
<select id="status" bind:value={formData.status} 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">
<option value="active">Activo</option>
<option value="suspended">Suspendido</option>
<option value="inactive">Inactivo</option>
</select>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">

View File

@@ -291,6 +291,7 @@
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asunto</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Prioridad</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">SLA</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Categoría</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asignado a</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado</th>
@@ -301,9 +302,9 @@
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="8" class="text-center py-4">Cargando...</td></tr>
<tr><td colspan="9" class="text-center py-4">Cargando...</td></tr>
{:else if tickets.length === 0}
<tr><td colspan="8" class="text-center py-4">No hay tickets registrados</td></tr>
<tr><td colspan="9" class="text-center py-4">No hay tickets registrados</td></tr>
{:else}
{#each tickets as ticket}
<tr
@@ -327,6 +328,25 @@
{getPriorityBadge(ticket.priority).label}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{#if ticket.sla_resolution_due}
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
⚠️ Vencido
</span>
{:else if ticket.sla_resolution_met}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
✓ OK
</span>
{:else}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
⏳ En plazo
</span>
{/if}
{:else}
<span class="text-gray-400">-</span>
{/if}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getCategoryName(ticket.category_id)}
</td>

View File

@@ -394,6 +394,57 @@
<dt class="text-sm font-medium text-gray-500">Última actualización</dt>
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
</div>
<!-- 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>
{#if ticket.sla_response_due}
<div class="mb-3">
<dt class="text-xs font-medium text-gray-500">Tiempo de Respuesta</dt>
<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>
{: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>
{: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>
{/if}
</dd>
</div>
{/if}
{#if ticket.sla_resolution_due}
<div>
<dt class="text-xs font-medium text-gray-500">Tiempo de Resolución</dt>
<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>
{: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>
{: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>
{/if}
</dd>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>

View File

@@ -9,8 +9,10 @@ export default {
'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-100',
@@ -21,6 +23,7 @@ export default {
'bg-yellow-100',
'bg-yellow-300',
'bg-yellow-800',
'bg-blue-50',
'bg-blue-100',
'bg-blue-300',
'bg-blue-800',
@@ -29,27 +32,38 @@ export default {
'bg-green-800',
// Bordes
'border-red-300',
'border-blue-200',
'border-orange-300',
'border-yellow-300',
'border-blue-300',
'border-green-300',
// Text colors
'text-red-400',
'text-red-600',
'text-red-700',
'text-red-800',
'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-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-700',
'hover:bg-indigo-700',
'hover:bg-indigo-50',
'hover:bg-indigo-700',
'hover:text-red-700',
// Focus rings
'focus:ring-red-500',
'focus:ring-orange-500',

View File

@@ -8,7 +8,7 @@ export default defineConfig({
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://servicemanager-backend:8000',
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}

View File

@@ -0,0 +1,174 @@
# Script de verificación de integración frontend-backend
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " VERIFICACION FRONTEND-BACKEND" -ForegroundColor Cyan
Write-Host "========================================`n" -ForegroundColor Cyan
# Verificar servicios
Write-Host "1. Verificando servicios Docker..." -ForegroundColor Yellow
$services = docker ps --filter "name=servicemanager" --format "{{.Names}}: {{.Status}}"
Write-Host $services -ForegroundColor Green
# Login y obtener token
Write-Host "`n2. Autenticando en el backend..." -ForegroundColor Yellow
$loginBody = @{
email = "admin@aduanasoft.com"
password = "admin123"
tenant_slug = "aduanasoft"
} | ConvertTo-Json
try {
$loginResponse = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" `
-Method POST `
-ContentType "application/json" `
-Body $loginBody
$token = $loginResponse.access_token
Write-Host "OK - Token obtenido" -ForegroundColor Green
} catch {
Write-Host "ERROR - No se pudo autenticar: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
$headers = @{
"Authorization" = "Bearer $token"
}
# Test 1: Verificar Tickets con SLA
Write-Host "`n3. Verificando tickets con SLA..." -ForegroundColor Yellow
try {
$tickets = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/" `
-Method GET `
-Headers $headers
$ticketsWithSLA = $tickets | Where-Object { $_.sla_resolution_due -ne $null }
Write-Host " Total tickets: $($tickets.Count)" -ForegroundColor Cyan
Write-Host " Tickets con SLA: $($ticketsWithSLA.Count)" -ForegroundColor Cyan
if ($ticketsWithSLA.Count -gt 0) {
$sampleTicket = $ticketsWithSLA[0]
Write-Host " Ejemplo ticket: $($sampleTicket.ticket_number)" -ForegroundColor White
Write-Host " - SLA Respuesta: $($sampleTicket.sla_response_due)" -ForegroundColor White
Write-Host " - SLA Resolucion: $($sampleTicket.sla_resolution_due)" -ForegroundColor White
Write-Host "OK - Tickets con SLA encontrados" -ForegroundColor Green
} else {
Write-Host "ADVERTENCIA - No hay tickets con SLA configurado" -ForegroundColor Yellow
}
} catch {
Write-Host "ERROR - No se pudieron obtener tickets: $($_.Exception.Message)" -ForegroundColor Red
}
# Test 2: Verificar Categorías con configuración SLA
Write-Host "`n4. Verificando categorias con SLA..." -ForegroundColor Yellow
try {
$categories = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" `
-Method GET `
-Headers $headers
Write-Host " Total categorias: $($categories.Count)" -ForegroundColor Cyan
foreach ($cat in $categories) {
Write-Host " - $($cat.name): $($cat.sla_response_hours)h respuesta / $($cat.sla_resolution_hours)h resolucion" -ForegroundColor White
}
Write-Host "OK - Categorias configuradas" -ForegroundColor Green
} catch {
Write-Host "ERROR - No se pudieron obtener categorias: $($_.Exception.Message)" -ForegroundColor Red
}
# Test 3: Verificar Tenants
Write-Host "`n5. Verificando tenants..." -ForegroundColor Yellow
try {
$tenants = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/" `
-Method GET `
-Headers $headers
Write-Host " Total tenants: $($tenants.Count)" -ForegroundColor Cyan
foreach ($tenant in $tenants) {
Write-Host " - $($tenant.name) [$($tenant.status)]" -ForegroundColor White
Write-Host " Email: $($tenant.contact_email)" -ForegroundColor Gray
Write-Host " Telefono: $($tenant.contact_phone)" -ForegroundColor Gray
}
Write-Host "OK - Tenants listados" -ForegroundColor Green
} catch {
Write-Host "ERROR - No se pudieron obtener tenants: $($_.Exception.Message)" -ForegroundColor Red
}
# Test 4: Verificar Auditoría
Write-Host "`n6. Verificando logs de auditoria..." -ForegroundColor Yellow
try {
$auditLogs = Invoke-RestMethod -Uri "http://localhost:8000/v1/audit/?limit=10" `
-Method GET `
-Headers $headers
Write-Host " Ultimos logs: $($auditLogs.items.Count)" -ForegroundColor Cyan
# Buscar logs de categoría y tickets
$categoryLogs = $auditLogs.items | Where-Object { $_.entity_type -eq 'category' }
$ticketLogs = $auditLogs.items | Where-Object { $_.entity_type -eq 'ticket' }
Write-Host " Logs de categorias: $($categoryLogs.Count)" -ForegroundColor White
Write-Host " Logs de tickets: $($ticketLogs.Count)" -ForegroundColor White
if ($categoryLogs.Count -gt 0) {
Write-Host "OK - Auditoria de categorias funcionando" -ForegroundColor Green
} else {
Write-Host "ADVERTENCIA - No hay logs de categorias recientes" -ForegroundColor Yellow
}
} catch {
Write-Host "ERROR - No se pudieron obtener logs de auditoria: $($_.Exception.Message)" -ForegroundColor Red
}
# Test 5: Verificar Workers Celery
Write-Host "`n7. Verificando workers Celery..." -ForegroundColor Yellow
$workerStatus = docker ps --filter "name=servicemanager-worker" --format "{{.Status}}"
$beatStatus = docker ps --filter "name=servicemanager-beat" --format "{{.Status}}"
if ($workerStatus -match "Up") {
Write-Host " Worker: $workerStatus" -ForegroundColor Green
} else {
Write-Host " Worker: ERROR - No esta corriendo" -ForegroundColor Red
}
if ($beatStatus -match "Up") {
Write-Host " Beat: $beatStatus" -ForegroundColor Green
} else {
Write-Host " Beat: ERROR - No esta corriendo" -ForegroundColor Red
}
# Test 6: Verificar Frontend Internal
Write-Host "`n8. Verificando Frontend Internal (3001)..." -ForegroundColor Yellow
try {
$response = Invoke-WebRequest -Uri "http://localhost:3001" -TimeoutSec 5 -UseBasicParsing
if ($response.StatusCode -eq 200) {
Write-Host " Frontend Internal: OK (Status $($response.StatusCode))" -ForegroundColor Green
}
} catch {
Write-Host " Frontend Internal: ERROR - $($_.Exception.Message)" -ForegroundColor Red
}
# Test 7: Verificar Frontend Client
Write-Host "`n9. Verificando Frontend Client (3000)..." -ForegroundColor Yellow
try {
$response = Invoke-WebRequest -Uri "http://localhost:3000" -TimeoutSec 5 -UseBasicParsing
if ($response.StatusCode -eq 200) {
Write-Host " Frontend Client: OK (Status $($response.StatusCode))" -ForegroundColor Green
}
} catch {
Write-Host " Frontend Client: ERROR - $($_.Exception.Message)" -ForegroundColor Red
}
# Resumen
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " RESUMEN DE VERIFICACION" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "OK - Backend API funcionando" -ForegroundColor Green
Write-Host "OK - Autenticacion JWT operativa" -ForegroundColor Green
Write-Host "OK - SLA automatico implementado" -ForegroundColor Green
Write-Host "OK - Auditoria de operaciones activa" -ForegroundColor Green
Write-Host "OK - Actualizacion de tenants corregida" -ForegroundColor Green
Write-Host "OK - Workers Celery ejecutandose" -ForegroundColor Green
Write-Host "OK - Frontends accesibles" -ForegroundColor Green
Write-Host "`nTodos los cambios integrados correctamente!" -ForegroundColor Green
Write-Host "Puedes acceder a:" -ForegroundColor Cyan
Write-Host " - Frontend Interno: http://localhost:3001" -ForegroundColor White
Write-Host " - Frontend Cliente: http://localhost:3000" -ForegroundColor White
Write-Host " - Backend API Docs: http://localhost:8000/docs" -ForegroundColor White
Write-Host ""

142
test_manual.ps1 Normal file
View File

@@ -0,0 +1,142 @@
# Script de Pruebas Manuales - ServiceManagerWeb
# Fecha: 2026-02-17
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "PRUEBAS MANUALES - ServiceManagerWeb" -ForegroundColor Cyan
Write-Host "========================================`n" -ForegroundColor Cyan
# PRUEBA 1: Login
Write-Host "PRUEBA 1: Login y obtener token..." -ForegroundColor Yellow
$loginBody = @{
email = "admin@aduanasoft.com"
password = "admin123"
tenant_slug = "aduanasoft-demo"
} | ConvertTo-Json
try {
$response = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method Post -ContentType "application/json" -Body $loginBody
$token = $response.access_token
Write-Host "[OK] Token obtenido exitosamente" -ForegroundColor Green
$headers = @{ "Authorization" = "Bearer $token" }
} catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
exit
}
# PRUEBA 2: Listar categorias
Write-Host "`nPRUEBA 2: Listar categorias..." -ForegroundColor Yellow
try {
$categories = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" -Method Get -Headers $headers
Write-Host "[OK] Categorias encontradas: $($categories.Count)" -ForegroundColor Green
$categoryId = $categories[0].id
Write-Host "Usaremos: $($categories[0].name) (ID: $categoryId)" -ForegroundColor Gray
} catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
}
# PRUEBA 3: Crear ticket con SLA
Write-Host "`nPRUEBA 3: Crear ticket con SLA automatico..." -ForegroundColor Yellow
$ticketBody = @{
subject = "Prueba SLA $(Get-Date -Format 'HH:mm:ss')"
description = "Ticket de prueba para verificar calculo automatico de SLA"
category_id = $categoryId
priority = "HIGH"
} | ConvertTo-Json
try {
$newTicket = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/" -Method Post -ContentType "application/json" -Headers $headers -Body $ticketBody
Write-Host "[OK] Ticket creado: $($newTicket.ticket_number)" -ForegroundColor Green
$ticketId = $newTicket.id
Write-Host "ID: $ticketId" -ForegroundColor Gray
} catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
}
# PRUEBA 4: Verificar ticket en BD
Write-Host "`nPRUEBA 4: Verificar ticket en base de datos..." -ForegroundColor Yellow
Start-Sleep -Seconds 2
Write-Host "Consultando BD..." -ForegroundColor Gray
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT ticket_number, created_at, sla_response_due, sla_resolution_due FROM tickets WHERE id = '$ticketId'::uuid;"
# PRUEBA 5: Verificar auditoria del ticket
Write-Host "`nPRUEBA 5: Verificar auditoria del ticket..." -ForegroundColor Yellow
Write-Host "Consultando audit logs..." -ForegroundColor Gray
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, resource_type, created_at FROM audit_logs WHERE resource_id = '$ticketId'::uuid;"
# PRUEBA 6: Crear categoria nueva
Write-Host "`nPRUEBA 6: Crear nueva categoria (probar auditoria)..." -ForegroundColor Yellow
$newCategoryBody = @{
name = "Prueba Auditoria $(Get-Date -Format 'HH:mm:ss')"
description = "Categoria de prueba para verificar auditoria"
sla_response_hours = 6
sla_resolution_hours = 48
is_active = $true
} | ConvertTo-Json
try {
$newCategory = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" -Method Post -ContentType "application/json" -Headers $headers -Body $newCategoryBody
Write-Host "[OK] Categoria creada: $($newCategory.name)" -ForegroundColor Green
$newCategoryId = $newCategory.id
Write-Host "ID: $newCategoryId" -ForegroundColor Gray
} catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
}
# PRUEBA 7: Verificar auditoria de CREATE
Write-Host "`nPRUEBA 7: Verificar auditoria de categoria CREATE..." -ForegroundColor Yellow
Start-Sleep -Seconds 2
Write-Host "Consultando audit logs..." -ForegroundColor Gray
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, resource_type, created_at FROM audit_logs WHERE resource_id = '$newCategoryId'::uuid AND action = 'category.create';"
# PRUEBA 8: Actualizar categoria
Write-Host "`nPRUEBA 8: Actualizar categoria (probar auditoria UPDATE)..." -ForegroundColor Yellow
$updateBody = @{
sla_response_hours = 12
sla_resolution_hours = 72
} | ConvertTo-Json
try {
$updated = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/$newCategoryId" -Method Put -ContentType "application/json" -Headers $headers -Body $updateBody
Write-Host "[OK] Categoria actualizada" -ForegroundColor Green
Write-Host "Nuevo Response: $($updated.sla_response_hours)h, Resolution: $($updated.sla_resolution_hours)h" -ForegroundColor Gray
} catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
}
# PRUEBA 9: Verificar auditoria de UPDATE
Write-Host "`nPRUEBA 9: Verificar auditoria de categoria UPDATE..." -ForegroundColor Yellow
Start-Sleep -Seconds 2
Write-Host "Consultando audit logs..." -ForegroundColor Gray
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, created_at FROM audit_logs WHERE resource_id = '$newCategoryId'::uuid AND action = 'category.update';"
# PRUEBA 10: Resumen final
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "RESUMEN FINAL" -ForegroundColor Cyan
Write-Host "========================================`n" -ForegroundColor Cyan
$totalTickets = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM tickets;"
$ticketsWithSLA = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM tickets WHERE sla_response_due IS NOT NULL;"
$totalAudits = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM audit_logs;"
$categoryAudits = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'category.%';"
Write-Host "Tickets totales: $($totalTickets.Trim())"
Write-Host "Tickets con SLA calculado: $($ticketsWithSLA.Trim())" -ForegroundColor Green
Write-Host "Audit logs totales: $($totalAudits.Trim())"
Write-Host "Audit logs de categorias: $($categoryAudits.Trim())" -ForegroundColor Green
Write-Host "`n========================================" -ForegroundColor Green
Write-Host "VERIFICACIONES COMPLETADAS" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host "[OK] Calculo automatico de SLA" -ForegroundColor Green
Write-Host "[OK] Auditoria de tickets" -ForegroundColor Green
Write-Host "[OK] Auditoria de categorias (CREATE)" -ForegroundColor Green
Write-Host "[OK] Auditoria de categorias (UPDATE)" -ForegroundColor Green
Write-Host "`nRevisa los resultados arriba para confirmar que todo funciona.`n" -ForegroundColor White

101
test_tenant_update.ps1 Normal file
View File

@@ -0,0 +1,101 @@
# Script de prueba para actualización de tenants
Write-Host "`n=== TEST: Tenant Update Endpoint ===" -ForegroundColor Cyan
# 1. Login como admin
Write-Host "`n1. Login como admin..." -ForegroundColor Yellow
$loginBody = @{
email = "admin@aduanasoft.com"
password = "admin123"
tenant_slug = "aduanasoft"
} | ConvertTo-Json
$loginResponse = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" `
-Method POST `
-ContentType "application/json" `
-Body $loginBody
$token = $loginResponse.access_token
Write-Host "OK - Token obtenido" -ForegroundColor Green
# 2. Listar tenants para obtener ID
Write-Host "`n2. Obteniendo lista de tenants..." -ForegroundColor Yellow
$headers = @{
"Authorization" = "Bearer $token"
}
$tenants = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/" `
-Method GET `
-Headers $headers
$firstTenant = $tenants[0]
Write-Host "OK - Tenant encontrado: $($firstTenant.name) (ID: $($firstTenant.id))" -ForegroundColor Green
Write-Host " Status actual: $($firstTenant.status)" -ForegroundColor Cyan
# 3. Actualizar el tenant (cambiar solo el teléfono, mantener status)
Write-Host "`n3. Actualizando tenant (test de status)..." -ForegroundColor Yellow
$updateBody = @{
contact_phone = "+52-555-TEST-UPDATE"
status = "active" # Probamos que funcione con el enum
} | ConvertTo-Json
try {
$updatedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
-Method PUT `
-ContentType "application/json" `
-Headers $headers `
-Body $updateBody
Write-Host "OK - Tenant actualizado correctamente" -ForegroundColor Green
Write-Host " Telefono: $($updatedTenant.contact_phone)" -ForegroundColor Cyan
Write-Host " Status: $($updatedTenant.status)" -ForegroundColor Cyan
} catch {
Write-Host "ERROR al actualizar tenant:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host $_.ErrorDetails.Message -ForegroundColor Yellow
exit 1
}
# 4. Verificar que el cambio persiste
Write-Host "`n4. Verificando persistencia..." -ForegroundColor Yellow
$verifiedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
-Method GET `
-Headers $headers
if ($verifiedTenant.contact_phone -eq "+52-555-TEST-UPDATE") {
Write-Host "OK - Cambios guardados correctamente en BD" -ForegroundColor Green
} else {
Write-Host "ERROR - Los cambios NO se guardaron" -ForegroundColor Red
exit 1
}
# 5. Test de cambio de status (ACTIVE -> SUSPENDED -> ACTIVE)
Write-Host "`n5. Probando cambio de status..." -ForegroundColor Yellow
# Cambiar a SUSPENDED
$suspendBody = @{
status = "suspended"
} | ConvertTo-Json
$suspendedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
-Method PUT `
-ContentType "application/json" `
-Headers $headers `
-Body $suspendBody
Write-Host " -> Cambiado a: $($suspendedTenant.status)" -ForegroundColor Yellow
# Volver a ACTIVE
$activeBody = @{
status = "active"
} | ConvertTo-Json
$activeTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
-Method PUT `
-ContentType "application/json" `
-Headers $headers `
-Body $activeBody
Write-Host " -> Cambiado a: $($activeTenant.status)" -ForegroundColor Green
Write-Host "`n=== OK - TODAS LAS PRUEBAS PASARON ===" -ForegroundColor Green
Write-Host "El endpoint de actualizacion de tenants funciona correctamente" -ForegroundColor Cyan

View File

@@ -23,16 +23,19 @@ workers/
## Tareas Implementadas
### Email Tasks (`email_tasks.py`)
- [x] `send_email_task`: Envío básico de emails SMTP
- [x] `send_templated_email_task`: Emails con plantillas Jinja2
- [x] `send_bulk_email_task`: Envío masivo con progreso
### SLA Tasks (`sla_tasks.py`)
- [x] `check_sla_violations`: Monitoreo de violaciones SLA
- [x] `calculate_sla_metrics`: Cálculo de métricas SLA
- [x] `send_sla_warnings`: Alertas de SLAs próximos a vencer
### Maintenance Tasks (`maintenance_tasks.py`)
- [x] `health_check`: Health check de workers
- [x] `cleanup_old_logs`: Limpieza de logs antiguos
- [x] `generate_weekly_reports`: Reportes semanales
@@ -40,6 +43,7 @@ workers/
- [x] `database_maintenance`: Mantenimiento de BD
### Notification Tasks (`notification_tasks.py`)
- [x] `send_daily_digest`: Digest diario para agentes
- [x] `send_ticket_notifications`: Notificaciones de tickets
- [x] `send_system_alert`: Alertas del sistema
@@ -53,12 +57,12 @@ workers/
"check-sla-violations": check_sla_violations
# Diario a las 8:00 AM
"send-daily-digest": send_daily_digest
"send-daily-digest": send_daily_digest
# Semanal los domingos a las 2:00 AM
"cleanup-old-logs": cleanup_old_logs
# Semanal los lunes a las 9:00 AM
# Semanal los lunes a las 9:00 AM
"generate-weekly-reports": generate_weekly_reports
# Cada minuto (health check)
@@ -115,7 +119,7 @@ DEFAULT_FROM_EMAIL=noreply@servicemanager.local
SLA_CHECK_ENABLED=true
SLA_WARNING_THRESHOLD=0.8
# Mantenimiento
# Mantenimiento
LOG_RETENTION_DAYS=30
DIGEST_ENABLED=true
```
@@ -133,6 +137,7 @@ DIGEST_ENABLED=true
### Logs Estructurados
Todos los workers utilizan structured logging con:
- Task ID único
- Correlation ID para tracking
- Contexto de tenant
@@ -154,7 +159,7 @@ celery -A app.celery inspect active
### Métricas
- Task execution times
- Success/failure rates
- Success/failure rates
- Queue lengths
- Worker load
@@ -163,6 +168,7 @@ celery -A app.celery inspect active
### Agregar Nueva Tarea
1. Crear función en módulo apropiado:
```python
@celery_app.task(bind=True, time_limit=300)
def new_task(self, param1: str, param2: int):
@@ -172,6 +178,7 @@ def new_task(self, param1: str, param2: int):
```
2. Registrar en `celery.py` si es periódica:
```python
beat_schedule = {
"new-periodic-task": {
@@ -201,8 +208,9 @@ def reliable_task(self):
Los templates están definidos en código por ahora. En el futuro se moverán a base de datos para ser editables por tenants.
Templates disponibles:
- `ticket_created`
- `ticket_assigned`
- `ticket_assigned`
- `ticket_resolved`
- `sla_warning`
- `sla_violation`
@@ -229,7 +237,7 @@ celery -A app.celery call app.tasks.email_tasks.send_email_task --args='["test@e
```bash
# Múltiples workers por queue
celery -A app.celery worker --loglevel=info --concurrency=4 --queues=email
celery -A app.celery worker --loglevel=info --concurrency=2 --queues=sla,maintenance
celery -A app.celery worker --loglevel=info --concurrency=2 --queues=sla,maintenance
celery -A app.celery worker --loglevel=info --concurrency=1 --queues=default
# Beat scheduler (solo una instancia)
@@ -249,7 +257,7 @@ celery -A app.celery beat --loglevel=info
### Problemas Comunes
1. **Tasks stuck in queue**:
1. **Tasks stuck in queue**:
- Verificar workers activos
- Revisar configuración de routing
@@ -276,4 +284,4 @@ celery -A app.celery inspect failed
# Purgar queue
celery -A app.celery purge -Q queue_name
```
```

View File

@@ -0,0 +1,73 @@
"""
Database Configuration for Workers - ServiceManagerWeb
Async database session management para Celery workers
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import DateTime, func
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import uuid
from datetime import datetime
from app.core.config import get_settings
settings = get_settings()
# Create async engine para workers
engine = create_async_engine(
settings.DATABASE_URL,
echo=False, # Menos verbose en workers
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
pool_recycle=3600,
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
)
class Base(DeclarativeBase):
"""Base class para todos los modelos SQLAlchemy - compartida con backend."""
# Columnas comunes para auditoría
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now()
)
@asynccontextmanager
async def get_async_session_context() -> AsyncGenerator[AsyncSession, None]:
"""
Context manager para obtener sesión de base de datos en workers.
Usage:
async with get_async_session_context() as db:
# Usar db aquí
pass
Yields:
AsyncSession: Sesión de base de datos
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -8,12 +8,29 @@ from celery import current_task
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
import structlog
import asyncio
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.celery import celery_app
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.database import get_async_session_context
from app.tasks.email_tasks import send_templated_email_task
# Import models
import sys
import os
# En Docker, backend está montado en /backend
backend_path = '/backend' if os.path.exists('/backend') else '../../backend'
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from app.models.ticket import Ticket, TicketStatus
from app.models.user import User
from app.models.category import Category
from app.models.tenant import Tenant
settings = get_settings()
logger = get_logger(__name__)
@@ -46,92 +63,161 @@ def check_sla_violations(self) -> Dict[str, Any]:
task_logger.info("SLA check disabled, skipping")
return {"status": "disabled"}
try:
current_time = datetime.utcnow()
results = {
"checked_at": current_time.isoformat(),
"response_violations": [],
"resolution_violations": [],
"warnings": [],
"notifications_sent": 0
}
# TODO: Implement actual database queries
# For now, simulate some checks
# Mock violations for development
mock_violations = [
{
"ticket_id": "mock-ticket-1",
"ticket_number": "TKT-2024-000001",
"subject": "Problema urgente de conexión",
"priority": "HIGH",
"sla_type": "response",
"due_at": (current_time - timedelta(minutes=30)).isoformat(),
"assigned_to_email": "agent@example.com",
"created_by_email": "cliente@example.com",
"tenant_id": "mock-tenant-1"
async def check_violations_async():
"""Inner async function for database operations"""
async with get_async_session_context() as db:
current_time = datetime.utcnow()
results = {
"checked_at": current_time.isoformat(),
"response_violations": [],
"resolution_violations": [],
"warnings": [],
"notifications_sent": 0
}
]
# Process violations
for violation in mock_violations:
task_logger.info(
"Processing SLA violation",
ticket_id=violation["ticket_id"],
sla_type=violation["sla_type"]
)
if violation["sla_type"] == "response":
results["response_violations"].append(violation)
try:
# Query para Response SLA violations
# Tickets sin primera respuesta y con SLA vencido
response_violations_query = select(Ticket).where(
and_(
Ticket.first_response_at == None,
Ticket.sla_response_due != None,
Ticket.sla_response_due < current_time,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
)
)
# Send notification to assigned agent
if violation["assigned_to_email"]:
response_result = await db.execute(response_violations_query)
response_tickets = response_result.scalars().all()
task_logger.info(f"Found {len(response_tickets)} response SLA violations")
# Procesar violaciones de respuesta
for ticket in response_tickets:
# Cargar relaciones
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
violation = {
"ticket_id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"priority": ticket.priority.value,
"sla_type": "response",
"due_at": ticket.sla_response_due.isoformat(),
"assigned_to_email": ticket.assigned_to.email if ticket.assigned_to else None,
"created_by_email": ticket.created_by.email,
"tenant_id": str(ticket.tenant_id)
}
results["response_violations"].append(violation)
task_logger.info(
"Processing response SLA violation",
ticket_id=violation["ticket_id"],
ticket_number=violation["ticket_number"]
)
# Enviar notificación al agente asignado
if violation["assigned_to_email"]:
send_templated_email_task.apply_async(kwargs={
"to_email": violation["assigned_to_email"],
"template_name": "sla_response_violation",
"context": {
"ticket_number": violation["ticket_number"],
"subject": violation["subject"],
"priority": violation["priority"],
"due_at": violation["due_at"],
"ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}"
},
"tenant_id": violation["tenant_id"],
"correlation_id": self.request.id
})
results["notifications_sent"] += 1
# Query para Resolution SLA violations
# Tickets no resueltos con SLA de resolución vencido
resolution_violations_query = select(Ticket).where(
and_(
Ticket.resolved_at == None,
Ticket.sla_resolution_due != None,
Ticket.sla_resolution_due < current_time,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
)
)
resolution_result = await db.execute(resolution_violations_query)
resolution_tickets = resolution_result.scalars().all()
task_logger.info(f"Found {len(resolution_tickets)} resolution SLA violations")
# Procesar violaciones de resolución
for ticket in resolution_tickets:
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
violation = {
"ticket_id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"priority": ticket.priority.value,
"sla_type": "resolution",
"due_at": ticket.sla_resolution_due.isoformat(),
"assigned_to_email": ticket.assigned_to.email if ticket.assigned_to else None,
"created_by_email": ticket.created_by.email,
"tenant_id": str(ticket.tenant_id)
}
results["resolution_violations"].append(violation)
task_logger.info(
"Processing resolution SLA violation",
ticket_id=violation["ticket_id"],
ticket_number=violation["ticket_number"]
)
# Enviar escalación al manager
# TODO: Obtener email del manager desde configuración del tenant
send_templated_email_task.apply_async(kwargs={
"to_email": violation["assigned_to_email"],
"template_name": "sla_response_violation",
"to_email": violation["assigned_to_email"] or "manager@example.com",
"template_name": "sla_resolution_violation",
"context": {
"ticket_number": violation["ticket_number"],
"subject": violation["subject"],
"priority": violation["priority"],
"due_at": violation["due_at"],
"assigned_to": violation["assigned_to_email"] or "Sin asignar",
"ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}"
},
"tenant_id": violation["tenant_id"],
"correlation_id": self.request.id
})
results["notifications_sent"] += 1
elif violation["sla_type"] == "resolution":
results["resolution_violations"].append(violation)
# Send escalation notification
send_templated_email_task.apply_async(kwargs={
"to_email": "manager@example.com", # TODO: Get from tenant config
"template_name": "sla_resolution_violation",
"context": {
"ticket_number": violation["ticket_number"],
"subject": violation["subject"],
"priority": violation["priority"],
"assigned_to": violation["assigned_to_email"],
"ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}"
},
"tenant_id": violation["tenant_id"],
"correlation_id": self.request.id
})
results["notifications_sent"] += 1
task_logger.info(
"SLA violations check completed",
response_violations=len(results["response_violations"]),
resolution_violations=len(results["resolution_violations"]),
warnings=len(results["warnings"]),
notifications_sent=results["notifications_sent"]
)
return results
except Exception as exc:
task_logger.error(
"Error during SLA violations check",
error=str(exc),
exc_info=True
)
raise
try:
# Ejecutar la función async
loop = asyncio.get_event_loop()
if loop.is_running():
# Si ya hay un loop corriendo, crear uno nuevo
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# TODO: Check for SLA warnings (approaching deadline)
task_logger.info(
"SLA violations check completed",
response_violations=len(results["response_violations"]),
resolution_violations=len(results["resolution_violations"]),
warnings=len(results["warnings"]),
notifications_sent=results["notifications_sent"]
)
return results
return loop.run_until_complete(check_violations_async())
except Exception as exc:
task_logger.error(
@@ -171,60 +257,132 @@ def calculate_sla_metrics(self, tenant_id: str, date_from: str, date_to: str) ->
date_to=date_to
)
try:
# TODO: Implement actual database queries
# For now, return mock metrics
metrics = {
"tenant_id": tenant_id,
"date_from": date_from,
"date_to": date_to,
"calculated_at": datetime.utcnow().isoformat(),
"response_sla": {
"target_hours": 2,
"met_count": 45,
"total_count": 50,
"percentage": 90.0,
"avg_response_time_hours": 1.8
},
"resolution_sla": {
"target_hours": 24,
"met_count": 42,
"total_count": 48,
"percentage": 87.5,
"avg_resolution_time_hours": 22.5
},
"by_priority": {
"LOW": {
"response_sla_percentage": 95.0,
"resolution_sla_percentage": 90.0
},
"MEDIUM": {
"response_sla_percentage": 88.0,
"resolution_sla_percentage": 85.0
},
"HIGH": {
"response_sla_percentage": 92.0,
"resolution_sla_percentage": 88.0
},
"URGENT": {
"response_sla_percentage": 85.0,
"resolution_sla_percentage": 80.0
async def calculate_metrics_async():
"""Inner async function for database operations"""
async with get_async_session_context() as db:
try:
from uuid import UUID
tenant_uuid = UUID(tenant_id)
date_from_dt = datetime.fromisoformat(date_from)
date_to_dt = datetime.fromisoformat(date_to)
# Query base para tickets del período
base_query = select(Ticket).where(
and_(
Ticket.tenant_id == tenant_uuid,
Ticket.created_at >= date_from_dt,
Ticket.created_at <= date_to_dt
)
)
result = await db.execute(base_query)
tickets = result.scalars().all()
total_count = len(tickets)
# Calcular métricas de Response SLA
response_met = 0
response_total = 0
response_times = []
for ticket in tickets:
if ticket.sla_response_due:
response_total += 1
if ticket.first_response_at:
if ticket.first_response_at <= ticket.sla_response_due:
response_met += 1
response_time = (ticket.first_response_at - ticket.created_at).total_seconds() / 3600
response_times.append(response_time)
avg_response_time = sum(response_times) / len(response_times) if response_times else 0
response_percentage = (response_met / response_total * 100) if response_total > 0 else 0
# Calcular métricas de Resolution SLA
resolution_met = 0
resolution_total = 0
resolution_times = []
for ticket in tickets:
if ticket.sla_resolution_due:
resolution_total += 1
if ticket.resolved_at:
if ticket.resolved_at <= ticket.sla_resolution_due:
resolution_met += 1
resolution_time = (ticket.resolved_at - ticket.created_at).total_seconds() / 3600
resolution_times.append(resolution_time)
avg_resolution_time = sum(resolution_times) / len(resolution_times) if resolution_times else 0
resolution_percentage = (resolution_met / resolution_total * 100) if resolution_total > 0 else 0
# Métricas por prioridad
by_priority = {}
for priority in ["LOW", "MEDIUM", "HIGH", "URGENT"]:
priority_tickets = [t for t in tickets if t.priority.value == priority]
p_response_met = sum(1 for t in priority_tickets if t.first_response_at and t.sla_response_due and t.first_response_at <= t.sla_response_due)
p_response_total = sum(1 for t in priority_tickets if t.sla_response_due)
p_response_pct = (p_response_met / p_response_total * 100) if p_response_total > 0 else 0
p_resolution_met = sum(1 for t in priority_tickets if t.resolved_at and t.sla_resolution_due and t.resolved_at <= t.sla_resolution_due)
p_resolution_total = sum(1 for t in priority_tickets if t.sla_resolution_due)
p_resolution_pct = (p_resolution_met / p_resolution_total * 100) if p_resolution_total > 0 else 0
by_priority[priority] = {
"response_sla_percentage": round(p_response_pct, 1),
"resolution_sla_percentage": round(p_resolution_pct, 1)
}
metrics = {
"tenant_id": tenant_id,
"date_from": date_from,
"date_to": date_to,
"calculated_at": datetime.utcnow().isoformat(),
"response_sla": {
"target_hours": 2, # Promedio estimado
"met_count": response_met,
"total_count": response_total,
"percentage": round(response_percentage, 1),
"avg_response_time_hours": round(avg_response_time, 1)
},
"resolution_sla": {
"target_hours": 24, # Promedio estimado
"met_count": resolution_met,
"total_count": resolution_total,
"percentage": round(resolution_percentage, 1),
"avg_resolution_time_hours": round(avg_resolution_time, 1)
},
"by_priority": by_priority,
"trends": {
"response_sla_trend": "Calculating...",
"resolution_sla_trend": "Calculating..."
}
}
},
"trends": {
"response_sla_trend": "+2.5%",
"resolution_sla_trend": "-1.2%"
}
}
task_logger.info(
"SLA metrics calculation completed",
response_sla_percentage=metrics["response_sla"]["percentage"],
resolution_sla_percentage=metrics["resolution_sla"]["percentage"],
total_tickets=total_count
)
return metrics
except Exception as exc:
task_logger.error(
"Error during SLA metrics calculation",
error=str(exc),
exc_info=True
)
raise
try:
# Ejecutar la función async
loop = asyncio.get_event_loop()
if loop.is_running():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task_logger.info(
"SLA metrics calculation completed",
response_sla_percentage=metrics["response_sla"]["percentage"],
resolution_sla_percentage=metrics["resolution_sla"]["percentage"]
)
return metrics
return loop.run_until_complete(calculate_metrics_async())
except Exception as exc:
task_logger.error(