Compare commits

...

1 Commits

Author SHA1 Message Date
bd21207aae v1.15.1 - modulo de reportes implementado
- Nuevo módulo de reportes: backend/app/api/v1/endpoints/reports.py
- Schemas de reportes: backend/app/api/schemas/reports.py
- Frontend: frontend-internal/src/routes/reports/
- Mejoras al módulo de auditoría (audit.py, audit_helpers.py)
- Modelo de auditoría actualizado
- Sidebar actualizado con enlace a reportes
2026-02-26 08:51:46 -07:00
9 changed files with 3619 additions and 1297 deletions

View File

@@ -0,0 +1,227 @@
"""
Reports Schemas - ServiceManagerWeb
Schemas de respuesta para el módulo de reportes y estadísticas.
"""
from pydantic import BaseModel, ConfigDict
from typing import Optional, List, Dict, Any
from datetime import datetime
# ===================================
# RESUMEN GENERAL
# ===================================
class TicketsByStatus(BaseModel):
"""Conteo de tickets agrupado por estado"""
new: int = 0
triage: int = 0
in_progress: int = 0
waiting_customer: int = 0
resolved: int = 0
closed: int = 0
reopened: int = 0
total: int = 0
class TicketsByPriority(BaseModel):
"""Conteo de tickets agrupado por prioridad"""
low: int = 0
medium: int = 0
high: int = 0
urgent: int = 0
total: int = 0
class ReportSummaryResponse(BaseModel):
"""Resumen ejecutivo del período seleccionado"""
period_start: datetime
period_end: datetime
generated_at: datetime
# Totales del período
total_tickets: int
open_tickets: int # Tickets sin resolver
resolved_tickets: int # Tickets resueltos o cerrados
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
avg_first_response_hours: Optional[float] # Promedio de horas para primera respuesta
# Satisfacción del cliente
avg_rating: Optional[float] # Promedio de calificación (1-5)
total_rated: int # Cuántos tickets tienen calificación
# Desglose por estado y prioridad
by_status: TicketsByStatus
by_priority: TicketsByPriority
# Comparación vs período anterior
tickets_change_pct: Optional[float] # % cambio vs período anterior
resolution_change_pct: Optional[float] # % cambio en tasa de resolución
model_config = ConfigDict(from_attributes=True)
# ===================================
# RENDIMIENTO POR AGENTE
# ===================================
class AgentReportRow(BaseModel):
"""Estadísticas de un agente específico"""
agent_id: str
agent_name: str
agent_email: str
total_assigned: int # Total asignados en el período
resolved: int # Cuántos resolvió
open: int # Cuántos siguen abiertos
resolution_rate: float # Porcentaje de resolución (0-100)
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
avg_rating: Optional[float] # Calificación promedio (1-5)
total_rated: int # Cuántos tickets calificaron al agente
urgent_handled: int # Urgentes atendidos
class AgentReportResponse(BaseModel):
"""Reporte de rendimiento por agente"""
period_start: datetime
period_end: datetime
generated_at: datetime
agents: List[AgentReportRow]
total_agents: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# TICKETS POR CATEGORÍA
# ===================================
class CategoryReportRow(BaseModel):
"""Estadísticas de una categoría"""
category_id: str
category_name: str
total_tickets: int
open_tickets: int
resolved_tickets: int
avg_resolution_hours: Optional[float]
sla_response_hours: int # SLA configurado para respuesta
sla_resolution_hours: int # SLA configurado para resolución
sla_compliance_pct: float # % de tickets que cumplieron SLA de resolución
class CategoryReportResponse(BaseModel):
"""Reporte de tickets agrupado por categoría"""
period_start: datetime
period_end: datetime
generated_at: datetime
categories: List[CategoryReportRow]
uncategorized_count: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# TICKETS POR CLIENTE (TENANT)
# ===================================
class ClientReportRow(BaseModel):
"""Estadísticas de un cliente (tenant)"""
tenant_id: str
tenant_name: str
total_tickets: int
open_tickets: int
resolved_tickets: int
urgent_tickets: int
avg_resolution_hours: Optional[float]
avg_rating: Optional[float]
last_ticket_at: Optional[datetime]
class ClientReportResponse(BaseModel):
"""Reporte de tickets agrupado por cliente — solo ADMIN"""
period_start: datetime
period_end: datetime
generated_at: datetime
clients: List[ClientReportRow]
total_clients: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# TENDENCIAS (TICKETS EN EL TIEMPO)
# ===================================
class TrendDataPoint(BaseModel):
"""Un punto de datos en la línea de tendencia"""
date: str # Formato YYYY-MM-DD
created: int # Tickets creados ese día
resolved: int # Tickets resueltos ese día
net_open: int # Diferencia: creados - resueltos
class TrendsReportResponse(BaseModel):
"""Evolución de tickets día a día"""
period_start: datetime
period_end: datetime
generated_at: datetime
data_points: List[TrendDataPoint]
total_days: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# SATISFACCIÓN DEL CLIENTE (CSAT)
# ===================================
class CSATDistribution(BaseModel):
"""Distribución de calificaciones 1-5"""
rating_1: int = 0
rating_2: int = 0
rating_3: int = 0
rating_4: int = 0
rating_5: int = 0
class CSATReportResponse(BaseModel):
"""Reporte de satisfacción del cliente"""
period_start: datetime
period_end: datetime
generated_at: datetime
avg_rating: Optional[float]
total_rated: int
total_tickets: int
response_rate: float # % de tickets que recibieron calificación
distribution: CSATDistribution
by_category: List[Dict[str, Any]] # Promedio por categoría
by_agent: List[Dict[str, Any]] # Promedio por agente
recent_comments: List[Dict[str, Any]] = [] # Últimos comentarios de calificación
model_config = ConfigDict(from_attributes=True)
# ===================================
# TICKETS POR SISTEMA AFECTADO
# ===================================
class SystemReportRow(BaseModel):
"""Estadísticas de un sistema afectado"""
system_id: str
system_name: str
total_tickets: int
open_tickets: int
resolved_tickets: int
urgent_tickets: int
avg_resolution_hours: Optional[float]
class SystemReportResponse(BaseModel):
"""Reporte de tickets agrupado por sistema afectado"""
period_start: datetime
period_end: datetime
generated_at: datetime
systems: List[SystemReportRow]
no_system_count: int # Tickets sin sistema asignado
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,8 +1,34 @@
"""Helper functions for audit endpoints"""
"""
Audit Helpers - ServiceManagerWeb
===================================
Funciones auxiliares reutilizables para los endpoints de auditoría.
Este archivo contiene:
- audit_log_to_dict: Convierte un modelo AuditLog a diccionario
- apply_tenant_filter: Aplica filtro de tenant según permisos
- get_count_stat: Cuenta registros con filtros opcionales (CORREGIDO)
- get_top_items: Obtiene los items más frecuentes
- detect_mass_deletions: Detecta eliminaciones masivas sospechosas
- detect_brute_force: Detecta ataques de fuerza bruta
- detect_privilege_escalation: Detecta escaladas de privilegios
CORRECCIÓN APLICADA en get_count_stat:
La columna created_at en PostgreSQL es 'timestamp with time zone' (TIMESTAMPTZ),
lo que significa que almacena y devuelve fechas CON información de timezone (+00).
El bug era que se comparaba un datetime naive (sin timezone) contra una columna
TIMESTAMPTZ. PostgreSQL no puede comparar ambos tipos directamente, por lo que
el filtro se ignoraba silenciosamente y los tres contadores devolvían el mismo
valor (el total histórico completo sin ningún filtro de fecha).
La solución es garantizar que TODAS las fechas que se usen en queries tengan
timezone info (aware datetime en UTC) usando _ensure_aware_utc().
"""
from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Dict, List
from datetime import datetime
from datetime import datetime, timezone
import uuid
from app.models.audit import AuditLog
@@ -10,8 +36,18 @@ from app.models.user import User, UserRole
from app.models.tenant import Tenant
# =============================================================================
# CONVERSIÓN DE MODELOS
# =============================================================================
def audit_log_to_dict(log: AuditLog) -> dict:
"""Convierte AuditLog a diccionario de respuesta"""
"""
Convierte un objeto AuditLog de SQLAlchemy a un diccionario plano
compatible con los schemas de respuesta de Pydantic.
Incluye los datos del usuario relacionado si están cargados
(requiere que la query use selectinload(AuditLog.user)).
"""
log_dict = {
"id": log.id,
"tenant_id": log.tenant_id,
@@ -19,47 +55,145 @@ def audit_log_to_dict(log: AuditLog) -> dict:
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
# ip_address puede ser un objeto especial de PostgreSQL, convertir a string
"ip_address": str(log.ip_address) if log.ip_address else None,
"user_agent": log.user_agent,
"correlation_id": log.correlation_id,
"old_values": log.old_values,
"new_values": log.new_values,
# extra_metadata evita conflicto con la palabra reservada 'metadata'
"metadata": log.extra_metadata,
"created_at": log.created_at,
"action_display": log.action_display,
# Campos del usuario (se llenan abajo si la relación está cargada)
"user_email": None,
"user_name": None
"user_name": None,
"user_role": None,
}
# Solo agregar datos del usuario si la relación fue cargada en la query
if log.user:
log_dict["user_email"] = log.user.email
log_dict["user_name"] = log.user.full_name
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
# El rol puede ser un Enum de Python o un string, manejar ambos casos
log_dict["user_role"] = (
log.user.role.value
if hasattr(log.user.role, 'value')
else str(log.user.role)
)
return log_dict
def apply_tenant_filter(query, current_user: User, current_tenant: Tenant, all_tenants: bool = False, specific_tenant_id: Optional[uuid.UUID] = None):
"""Aplica filtro de tenant según permisos del usuario"""
# =============================================================================
# FILTRO DE MULTI-TENANCY
# =============================================================================
def apply_tenant_filter(
query,
current_user: User,
current_tenant: Tenant,
all_tenants: bool = False,
specific_tenant_id: Optional[uuid.UUID] = None
):
"""
Aplica el filtro de tenant a una query de SQLAlchemy según los
permisos del usuario actual.
Reglas:
- ADMIN y SUPPORT_MANAGER pueden ver todos los tenants si
all_tenants=True, o filtrar por un tenant específico.
- Cualquier otro rol solo puede ver los datos de su propio tenant.
"""
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
if all_tenants and can_see_all_tenants:
return query # No filtrar por tenant
# Usuario privilegiado pidiendo ver todos los tenants → sin filtro
return query
elif specific_tenant_id and can_see_all_tenants:
# Usuario privilegiado pidiendo un tenant específico
return query.where(AuditLog.tenant_id == specific_tenant_id)
else:
# Cualquier otro caso → solo ver el propio tenant
return query.where(AuditLog.tenant_id == current_tenant.id)
async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None,
date_from: Optional[datetime] = None, action_filter=None) -> int:
"""Obtiene estadística de conteo con filtros opcionales"""
# =============================================================================
# UTILIDAD DE FECHAS
# =============================================================================
def _ensure_aware_utc(dt: datetime) -> datetime:
"""
Garantiza que un datetime tenga información de timezone en UTC.
PROBLEMA QUE RESUELVE:
La columna created_at en PostgreSQL es 'timestamp with time zone'
(TIMESTAMPTZ). Cuando se compara con un datetime naive (sin timezone),
PostgreSQL no puede hacer la comparación correctamente y el filtro
de fecha se ignora silenciosamente, devolviendo todos los registros
sin importar la fecha.
SOLUCIÓN:
Siempre convertir las fechas a aware UTC antes de usarlas en queries.
Casos que maneja:
- datetime naive (sin tzinfo): agrega UTC como timezone
- datetime aware (con tzinfo): convierte a UTC si es otra zona horaria
Ejemplos:
datetime(2026, 2, 24, 15, 0, 0) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
datetime(2026, 2, 24, 9, 0, 0, tzinfo=CST) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
"""
if dt.tzinfo is None:
# Datetime naive → asumir que ya es UTC y agregarle timezone info
return dt.replace(tzinfo=timezone.utc)
else:
# Datetime aware → convertir a UTC (por si viene en otra zona horaria)
return dt.astimezone(timezone.utc)
# =============================================================================
# CONTADORES DE ESTADÍSTICAS
# =============================================================================
async def get_count_stat(
db: AsyncSession,
tenant_id: Optional[uuid.UUID] = None,
date_from: Optional[datetime] = None,
action_filter=None
) -> int:
"""
Cuenta registros de AuditLog con filtros opcionales.
Usado por get_audit_stats() para calcular:
- total_actions: Sin date_from → cuenta todos los registros
- actions_today: date_from = now - 24h → registros del día
- actions_this_week: date_from = now - 7d → registros de la semana
CORRECCIÓN: Las fechas se convierten a aware UTC con _ensure_aware_utc()
antes de usarlas en la query, para que sean compatibles con la columna
TIMESTAMPTZ de PostgreSQL y el filtro se aplique correctamente.
Args:
db: Sesión de base de datos
tenant_id: Si se especifica, filtra por ese tenant
date_from: Si se especifica, solo cuenta registros desde esa fecha
action_filter: Condición SQLAlchemy adicional opcional
Returns:
Número entero de registros que cumplen los filtros
"""
query = select(func.count()).select_from(AuditLog)
if tenant_id:
query = query.where(AuditLog.tenant_id == tenant_id)
if date_from:
query = query.where(AuditLog.created_at >= date_from)
# CORRECCIÓN: convertir a aware UTC para compatibilidad con TIMESTAMPTZ
# Sin esto, el filtro se ignora y los tres contadores son idénticos
date_from_aware = _ensure_aware_utc(date_from)
query = query.where(AuditLog.created_at >= date_from_aware)
if action_filter is not None:
query = query.where(action_filter)
@@ -67,21 +201,52 @@ async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None
return result.scalar() or 0
async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID] = None,
limit: int = 5, join_user: bool = False) -> Dict[str, int]:
"""Obtiene top items por campo con conteo"""
# =============================================================================
# ITEMS MÁS FRECUENTES
# =============================================================================
async def get_top_items(
db: AsyncSession,
field,
tenant_id: Optional[uuid.UUID] = None,
limit: int = 5,
join_user: bool = False
) -> Dict[str, int]:
"""
Obtiene los valores más frecuentes de un campo, ordenados por conteo.
Ejemplos de uso:
- get_top_items(db, AuditLog.action, ...) → {"ticket.create": 45}
- get_top_items(db, AuditLog.resource_type, ...) → {"ticket": 60}
- get_top_items(db, None, ..., join_user=True) → {"admin@empresa.com": 40}
Args:
db: Sesión de base de datos
field: Campo de AuditLog por el que agrupar
tenant_id: Si se especifica, filtra por ese tenant
limit: Máximo de resultados a devolver (por defecto 5)
join_user: Si True, agrupa por email de usuario
Returns:
Diccionario {valor: conteo} ordenado de mayor a menor
"""
if join_user:
query = select(User.email, func.count(AuditLog.id).label('count')).join(User, AuditLog.user_id == User.id)
# Modo usuarios: hacer JOIN con tabla User y agrupar por email
query = (
select(User.email, func.count(AuditLog.id).label('count'))
.join(User, AuditLog.user_id == User.id)
)
else:
# Modo campo: agrupar por el campo especificado
query = select(field, func.count(AuditLog.id).label('count'))
if tenant_id:
query = query.where(AuditLog.tenant_id == tenant_id)
if not join_user:
query = query.group_by(field)
else:
if join_user:
query = query.group_by(User.email)
else:
query = query.group_by(field)
query = query.order_by(desc('count')).limit(limit)
@@ -89,8 +254,27 @@ async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID]
return {row[0]: row[1] for row in result}
# =============================================================================
# DETECTORES DE INCIDENTES DE SEGURIDAD
# =============================================================================
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
"""Detecta eliminaciones masivas de logs de auditoría"""
"""
Detecta patrones de eliminación masiva agrupando por usuario y día.
Lógica:
- Agrupa todos los logs de eliminación por (usuario, día)
- Si un usuario eliminó >= 3 recursos en un día, genera un incidente
- La severidad escala según la cantidad:
- >= 3 eliminaciones → medium
- >= 5 eliminaciones → high
- >= 10 eliminaciones → critical
El estado del incidente es:
- "active": si la última eliminación fue hace menos de 24 horas
- "resolved": si fue hace más de 24 horas
"""
# Agrupar eliminaciones por usuario y día
deletion_groups = {}
for log in logs:
@@ -98,10 +282,15 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
continue
key = f"{log.user.email}_{log.created_at.date()}"
if key not in deletion_groups:
deletion_groups[key] = {
'user': log.user.email, 'date': log.created_at.date(),
'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at
'user': log.user.email,
'date': log.created_at.date(),
'count': 0,
'logs': [],
'first_seen': log.created_at,
'last_seen': log.created_at
}
deletion_groups[key]['count'] += 1
@@ -110,25 +299,50 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at)
incidents = []
for key, group in deletion_groups.items():
if group['count'] >= 3:
severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium"
status = "active" if (now - group['last_seen']).days <= 1 else "resolved"
if group['count'] < 3:
continue
if group['count'] >= 10:
severity = "critical"
elif group['count'] >= 5:
severity = "high"
else:
severity = "medium"
# Convertir ambas fechas a aware UTC para comparación segura
now_aware = _ensure_aware_utc(now)
last_seen_aware = _ensure_aware_utc(group['last_seen'])
hours_since_last = (now_aware - last_seen_aware).total_seconds() / 3600
incident_status = "active" if hours_since_last <= 24 else "resolved"
incidents.append({
"id": f"mass_del_{key.replace('_', '-')}",
"title": f"Eliminaciones masivas - {group['user']}",
"description": f"{group['user']} eliminó {group['count']} elementos el {group['date']}",
"description": (
f"{group['user']} elimino {group['count']} elementos "
f"el {group['date']}"
),
"severity": severity,
"status": status,
"status": incident_status,
"incident_type": "mass_deletion",
"affected_user": group['user'],
"source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None,
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
"source_ip": (
str(group['logs'][0].ip_address)
if group['logs'][0].ip_address
else None
),
"evidence": [
f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}"
for log in group['logs'][:5]
],
"metadata": {
"total_deletions": group['count'],
"resource_types": list(set(log.resource_type for log in group['logs'])),
"time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60)
"time_span_minutes": int(
(group['last_seen'] - group['first_seen']).total_seconds() / 60
)
},
"created_at": group['first_seen'],
"updated_at": group['last_seen']
@@ -138,7 +352,21 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
"""Detecta ataques de fuerza bruta de logs de login fallido"""
"""
Detecta ataques de fuerza bruta agrupando intentos fallidos por IP.
Lógica:
- Agrupa todos los intentos fallidos de login por dirección IP
- Si una IP tiene >= 5 intentos, genera un incidente
- La severidad escala según la cantidad:
- >= 5 intentos → medium
- >= 10 intentos → high
- >= 20 intentos → critical
El estado del incidente es:
- "active": si el último intento fue hace menos de 24 horas
- "investigating": si fue hace más de 24 horas
"""
ip_groups = {}
for log in logs:
@@ -146,36 +374,71 @@ def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
continue
ip = str(log.ip_address)
if ip not in ip_groups:
ip_groups[ip] = {'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at, 'users': set()}
ip_groups[ip] = {
'count': 0,
'logs': [],
'first_seen': log.created_at,
'last_seen': log.created_at,
'users': set()
}
ip_groups[ip]['count'] += 1
ip_groups[ip]['logs'].append(log)
ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at)
ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at)
if log.user and log.user.email:
ip_groups[ip]['users'].add(log.user.email)
incidents = []
for ip, group in ip_groups.items():
if group['count'] >= 5:
severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium"
status = "active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating"
if group['count'] < 5:
continue
if group['count'] >= 20:
severity = "critical"
elif group['count'] >= 10:
severity = "high"
else:
severity = "medium"
# Convertir ambas fechas a aware UTC para comparación segura
now_aware = _ensure_aware_utc(now)
last_seen_aware = _ensure_aware_utc(group['last_seen'])
seconds_since_last = (now_aware - last_seen_aware).total_seconds()
incident_status = "active" if seconds_since_last <= 86400 else "investigating"
incidents.append({
"id": f"brute_force_{ip.replace('.', '-')}",
"title": f"Posible ataque de fuerza bruta desde {ip}",
"description": f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}",
"description": (
f"Se detectaron {group['count']} intentos fallidos de "
f"login desde la IP {ip}"
),
"severity": severity,
"status": status,
"status": incident_status,
"incident_type": "brute_force_attack",
"affected_user": ', '.join(list(group['users'])[:3]) if group['users'] else None,
"affected_user": (
', '.join(list(group['users'])[:3])
if group['users']
else None
),
"source_ip": ip,
"evidence": [f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
"evidence": [
f"Login fallido - "
f"{log.user.email if log.user else 'Desconocido'} - "
f"{log.created_at.strftime('%H:%M:%S')}"
for log in group['logs'][:5]
],
"metadata": {
"total_attempts": group['count'],
"targeted_users": list(group['users']),
"time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600)
"time_span_hours": int(
(group['last_seen'] - group['first_seen']).total_seconds() / 3600
)
},
"created_at": group['first_seen'],
"updated_at": group['last_seen']
@@ -185,8 +448,25 @@ def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
"""Detecta escaladas de privilegios"""
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
"""
Detecta escaladas de privilegios comparando el rol anterior y nuevo.
Lógica:
- Analiza cada log de cambio de rol (user.update con campo 'role')
- Si el nuevo rol tiene más privilegios que el anterior, es sospechoso
- Cada cambio que represente una escalada genera un incidente
Jerarquía de roles (de menor a mayor privilegio):
CLIENT_USER(1) < CLIENT_ADMIN(2) < AGENT(3) < SUPPORT_MANAGER(4) < ADMIN(5)
"""
role_hierarchy = {
'CLIENT_USER': 1,
'CLIENT_ADMIN': 2,
'AGENT': 3,
'SUPPORT_MANAGER': 4,
'ADMIN': 5
}
incidents = []
for log in logs:
@@ -195,24 +475,40 @@ def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
new_role = log.new_values.get('role')
old_level = role_hierarchy.get(old_role, 0)
new_level = role_hierarchy.get(new_role, 0)
if new_level > old_level:
# Solo generar incidente si el nuevo rol tiene MÁS privilegios
if new_level <= old_level:
continue
severity = "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium"
incidents.append({
"id": f"priv_esc_{log.id}",
"title": f"Escalada de privilegios - {log.user.email}",
"description": f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}",
"severity": "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium",
"description": (
f"Usuario {log.user.email} cambio de rol "
f"{old_role} a {new_role}"
),
"severity": severity,
"status": "investigating",
"incident_type": "privilege_escalation",
"affected_user": log.user.email,
"source_ip": str(log.ip_address) if log.ip_address else None,
"evidence": [f"Cambio de rol: {old_role}{new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
"evidence": [
f"Cambio de rol: {old_role}{new_role} - "
f"{log.created_at.strftime('%Y-%m-%d %H:%M')}"
],
"metadata": {
"old_role": old_role,
"new_role": new_role,
"correlation_id": str(log.correlation_id) if log.correlation_id else None
"correlation_id": (
str(log.correlation_id)
if log.correlation_id
else None
)
},
"created_at": log.created_at,
"updated_at": log.created_at

View File

@@ -1,4 +1,29 @@
"""Audit Endpoints - ServiceManagerWeb"""
"""
Audit Endpoints - ServiceManagerWeb
====================================
Este archivo maneja todos los endpoints de auditoría y seguridad.
Rutas disponibles:
GET /audit/ → Lista de logs con filtros y paginación
GET /audit/stats → Estadísticas generales de auditoría
GET /audit/{log_id} → Detalle de un log específico
GET /audit/security/analysis → Análisis de amenazas en tiempo real
POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.)
GET /audit/security/incidents → Lista de incidentes detectados
CORRECCIONES APLICADAS:
1. Todos los endpoints usan datetime.now(timezone.utc) para generar
fechas aware (con timezone info en UTC), compatibles con la columna
'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL.
2. audit_helpers.get_count_stat() convierte las fechas a aware UTC
con _ensure_aware_utc() antes de usarlas en queries, resolviendo
el bug donde los tres contadores (total, hoy, semana) devolvían
el mismo valor porque el filtro de fecha se ignoraba.
3. critical_actions_today usa los mismos umbrales que /security/incidents
para que el contador del dashboard coincida con la lista de detalles.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc
@@ -24,31 +49,83 @@ from app.api.v1.audit_helpers import (
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
)
# Instancia del router de FastAPI para este módulo
router = APIRouter()
# Logger estructurado para registrar eventos internos del sistema
logger = structlog.get_logger(__name__)
# =============================================================================
# DEPENDENCIA DE AUTORIZACIÓN
# =============================================================================
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
"""Verifica que el usuario tenga rol de auditor"""
"""
Dependencia reutilizable que verifica que el usuario tenga permisos
para ver logs de auditoría.
Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR.
Si no tiene el rol correcto, lanza un error 403 Forbidden.
"""
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
)
return current_user
@router.get("/", response_model=AuditLogListResponse)
async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=100),
user_id: Optional[uuid.UUID] = Query(None), action: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None), resource_id: Optional[uuid.UUID] = Query(None),
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
search: Optional[str] = Query(None), tenant_id: Optional[uuid.UUID] = Query(None),
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
"""Obtener logs de auditoría con filtros y paginación"""
logger.info("Fetching audit logs", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
filters={"user_id": str(user_id) if user_id else None, "action": action, "page": page, "all_tenants": all_tenants})
# =============================================================================
# ENDPOINT: LISTA DE LOGS DE AUDITORÍA
# =============================================================================
@router.get("/", response_model=AuditLogListResponse)
async def get_audit_logs(
# Paginación
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=100),
# Filtros opcionales
user_id: Optional[uuid.UUID] = Query(None),
action: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
resource_id: Optional[uuid.UUID] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
search: Optional[str] = Query(None),
tenant_id: Optional[uuid.UUID] = Query(None),
all_tenants: bool = Query(False),
# Dependencias de autenticación y base de datos
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener el historial completo de logs de auditoría con filtros opcionales.
Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas
y búsqueda de texto. También soporta ver logs de todos los tenants
si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER.
"""
logger.info(
"Obteniendo logs de auditoria",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
filters={
"user_id": str(user_id) if user_id else None,
"action": action,
"page": page,
"all_tenants": all_tenants
}
)
# Construir la query base con relación al usuario que hizo la acción
query = select(AuditLog).options(selectinload(AuditLog.user))
# Aplicar filtro de tenant según permisos del usuario
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
# Aplicar filtros opcionales uno por uno
if user_id:
query = query.where(AuditLog.user_id == user_id)
if action:
@@ -62,229 +139,609 @@ async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Que
if date_to:
query = query.where(AuditLog.created_at < date_to)
if search:
# Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create")
query = query.where(AuditLog.action.ilike(f"%{search}%"))
# Ordenar por fecha descendente (más reciente primero)
query = query.order_by(desc(AuditLog.created_at))
# Contar total de registros para calcular páginas
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar() or 0
# Aplicar paginación
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
# Ejecutar query y obtener resultados
result = await db.execute(query)
logs = result.scalars().all()
# Calcular número total de páginas
total_pages = (total + per_page - 1) // per_page
# Convertir modelos a schemas de respuesta
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
return AuditLogListResponse(logs=logs_response, total=total, page=page, per_page=per_page, total_pages=total_pages)
return AuditLogListResponse(
logs=logs_response,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages
)
# =============================================================================
# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA
# =============================================================================
@router.get("/stats", response_model=AuditLogStats)
async def get_audit_stats(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
"""Obtener estadísticas de auditoría"""
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
logger.info("Fetching audit stats", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
all_tenants=all_tenants, can_see_all=can_see_all_tenants)
async def get_audit_stats(
all_tenants: bool = Query(False),
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener estadísticas resumidas de auditoría para el dashboard.
Incluye:
- Total de acciones registradas
- Acciones de las últimas 24 horas
- Acciones de los últimos 7 días
- Incidentes críticos detectados hoy (alineado con /security/incidents)
- Acciones más frecuentes
- Usuarios más activos
- Distribución por tipo de recurso
"""
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
logger.info(
"Obteniendo estadisticas de auditoria",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
all_tenants=all_tenants,
can_see_all=can_see_all_tenants
)
# datetime.now(timezone.utc) genera un datetime aware en UTC,
# compatible con la columna TIMESTAMPTZ de PostgreSQL
now = datetime.now(timezone.utc)
# Determinar si se debe filtrar por tenant o ver todos
apply_tenant = not (all_tenants and can_see_all_tenants)
tenant_filter = current_tenant.id if apply_tenant else None
# ------------------------------------------------------------------
# CONTADORES GENERALES
# ------------------------------------------------------------------
# Total histórico de acciones (sin filtro de fecha)
total_actions = await get_count_stat(db, tenant_filter)
# Acciones en las últimas 24 horas
# get_count_stat convierte internamente a aware UTC con _ensure_aware_utc()
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
# Acciones en los últimos 7 días
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
# ------------------------------------------------------------------
# CONTADOR DE INCIDENTES CRÍTICOS
# ------------------------------------------------------------------
# Usa los mismos umbrales que los detectores de /security/incidents
# para que el número del dashboard sea consistente con la lista.
# ------------------------------------------------------------------
today_start = now - timedelta(days=1)
critical_conditions = [
# Contar intentos fallidos de login en las últimas 24 horas
failed_login_count = (await db.execute(
select(func.count()).select_from(AuditLog).where(
AuditLog.action == 'user.login_failed',
AuditLog.created_at >= today_start,
or_(AuditLog.action.like('%.delete'), AuditLog.action.like('user.update'),
AuditLog.action.like('%.assign'), AuditLog.action.in_(['user.login_failed', 'user.logout']))
]
if apply_tenant:
critical_conditions.append(AuditLog.tenant_id == tenant_filter)
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
)
)).scalar() or 0
critical_actions_today = (await db.execute(select(func.count()).select_from(AuditLog).where(and_(*critical_conditions)))).scalar() or 0
# Contar eliminaciones en las últimas 24 horas
deletion_count = (await db.execute(
select(func.count()).select_from(AuditLog).where(
AuditLog.action.like('%.delete'),
AuditLog.created_at >= today_start,
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
)
)).scalar() or 0
# Contar cambios de privilegios en las últimas 24 horas
privilege_count = (await db.execute(
select(func.count()).select_from(AuditLog).where(
AuditLog.action == 'user.update',
AuditLog.created_at >= today_start,
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
)
)).scalar() or 0
# Calcular número real de incidentes usando los mismos umbrales
# que los detectores en /security/incidents:
# - Fuerza bruta: incidente si hay >= 20 intentos fallidos
# - Eliminación masiva: incidente si hay >= 50 eliminaciones
# - Escalada privilegios: incidente si hay >= 3 cambios de rol
critical_actions_today = sum([
1 if failed_login_count >= 20 else 0,
1 if deletion_count >= 50 else 0,
1 if privilege_count >= 3 else 0,
])
# ------------------------------------------------------------------
# DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD
# ------------------------------------------------------------------
# Top acciones más frecuentes (ej: "ticket.create", "user.login")
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
# Distribución por tipo de recurso (ej: "ticket", "user", "tenant")
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
# Usuarios más activos (hace join con tabla de usuarios)
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
return AuditLogStats(total_actions=total_actions, actions_today=actions_today,
actions_this_week=actions_this_week, critical_actions_today=critical_actions_today,
top_actions=top_actions, top_users=top_users, by_resource_type=by_resource_type)
return AuditLogStats(
total_actions=total_actions,
actions_today=actions_today,
actions_this_week=actions_this_week,
critical_actions_today=critical_actions_today,
top_actions=top_actions,
top_users=top_users,
by_resource_type=by_resource_type
)
# =============================================================================
# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO
# =============================================================================
@router.get("/{log_id}", response_model=AuditLogResponse)
async def get_audit_log_detail(log_id: uuid.UUID, current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
"""Obtener detalle de un log de auditoría"""
async def get_audit_log_detail(
log_id: uuid.UUID,
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener el detalle completo de un log de auditoría por su ID.
Incluye información del usuario que realizó la acción, valores
anteriores y nuevos (para cambios), IP de origen, user agent, etc.
Retorna 404 si el log no existe o no pertenece al tenant del usuario.
"""
# Buscar el log por ID incluyendo los datos del usuario relacionado
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
# Aplicar filtro de tenant para garantizar aislamiento multi-tenant
query = apply_tenant_filter(query, current_user, current_tenant)
result = await db.execute(query)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Audit log {log_id} not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Registro de auditoria {log_id} no encontrado"
)
return AuditLogResponse(**audit_log_to_dict(log))
# =============================================================================
# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL
# =============================================================================
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
async def get_security_analysis(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
"""Análisis de seguridad basado en logs de auditoría"""
logger.info("Security analysis requested", user_id=str(current_user.id), tenant_id=str(current_tenant.id))
async def get_security_analysis(
hours: int = Query(default=24, ge=1, le=720),
all_tenants: bool = Query(False),
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Analizar los logs de auditoría para detectar patrones sospechosos.
Detecta tres tipos de amenazas:
1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs
2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo
3. Escalada privilegios: Cambios de roles sospechosos en usuarios
Calcula un nivel de riesgo general (low/medium/high/critical) y
devuelve recomendaciones de acción.
"""
logger.info(
"Analisis de seguridad solicitado",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
hours=hours
)
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
now = datetime.now(timezone.utc)
analysis_start = now - timedelta(hours=24)
analysis_start = now - timedelta(hours=hours)
query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user))
query = (
select(AuditLog)
.where(AuditLog.created_at >= analysis_start)
.options(selectinload(AuditLog.user))
)
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
result = await db.execute(query)
logs = result.scalars().all()
# ------------------------------------------------------------------
# CONTADORES DE EVENTOS SOSPECHOSOS
# ------------------------------------------------------------------
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
privilege_changes = sum(1 for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values)
privilege_changes = sum(
1 for log in logs
if log.action == 'user.update'
and log.new_values
and 'role' in log.new_values
)
# ------------------------------------------------------------------
# GENERACIÓN DE PATRONES DE AMENAZA
# ------------------------------------------------------------------
threat_patterns = []
# Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos)
if failed_logins >= 5:
affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address]
affected_ips_list = [
str(log.ip_address)
for log in logs
if log.action == 'user.login_failed' and log.ip_address
]
threat_patterns.append(SecurityThreatPattern(
id="brute_force_attempt",
type="brute_force",
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
description=(
f"Se detectaron {failed_logins} intentos fallidos de "
f"login en las ultimas {hours}h"
),
severity="high" if failed_logins >= 20 else "medium",
occurrences=failed_logins,
first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
first_seen=min(
(log.created_at for log in logs if log.action == 'user.login_failed'),
default=now
),
last_seen=max(
(log.created_at for log in logs if log.action == 'user.login_failed'),
default=now
),
affected_ips=list(set(affected_ips_list))[:5],
affected_users=[],
recommended_action="Considerar bloquear IPs con múltiples fallos"
recommended_action="Considerar bloquear IPs con multiples fallos"
))
# Amenaza 2: Eliminación masiva (umbral mínimo: 10 eliminaciones)
if mass_deletions >= 10:
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
deleting_users = [
log.user.email
for log in logs
if '.delete' in log.action and log.user
]
threat_patterns.append(SecurityThreatPattern(
id="mass_deletion",
type="mass_deletion",
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
description=(
f"Se detectaron {mass_deletions} eliminaciones en "
f"las ultimas {hours}h"
),
severity="critical" if mass_deletions >= 50 else "high",
occurrences=mass_deletions,
first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now),
last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now),
first_seen=min(
(log.created_at for log in logs if '.delete' in log.action),
default=now
),
last_seen=max(
(log.created_at for log in logs if '.delete' in log.action),
default=now
),
affected_ips=[],
affected_users=list(set(deleting_users))[:5],
recommended_action="Revisar qué usuarios están eliminando recursos"
recommended_action="Revisar que usuarios estan eliminando recursos masivamente"
))
# Amenaza 3: Escalada de privilegios (umbral mínimo: 3 cambios de rol)
if privilege_changes >= 3:
affected_users_list = [log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values]
affected_users_list = [
log.user.email
for log in logs
if log.action == 'user.update'
and log.user
and log.new_values
and 'role' in log.new_values
]
threat_patterns.append(SecurityThreatPattern(
id="suspicious_privilege_changes",
type="privilege_escalation",
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
description=(
f"Se detectaron {privilege_changes} cambios de "
f"privilegios en las ultimas {hours}h"
),
severity="high",
occurrences=privilege_changes,
first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
first_seen=min(
(
log.created_at for log in logs
if log.action == 'user.update'
and log.new_values
and 'role' in log.new_values
),
default=now
),
last_seen=max(
(
log.created_at for log in logs
if log.action == 'user.update'
and log.new_values
and 'role' in log.new_values
),
default=now
),
affected_ips=[],
affected_users=list(set(affected_users_list))[:5],
recommended_action="Auditar cambios de roles recientes"
))
risk_score = min(100, (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10))
risk_level = "critical" if risk_score >= 80 else "high" if risk_score >= 50 else "medium" if risk_score >= 20 else "low"
# ------------------------------------------------------------------
# CÁLCULO DE NIVEL DE RIESGO GENERAL
# ------------------------------------------------------------------
risk_score = min(
100,
(failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)
)
if risk_score >= 80:
risk_level = "critical"
elif risk_score >= 50:
risk_level = "high"
elif risk_score >= 20:
risk_level = "medium"
else:
risk_level = "low"
# ------------------------------------------------------------------
# RECOMENDACIONES AUTOMÁTICAS
# ------------------------------------------------------------------
recommended_actions = []
if failed_logins >= 20:
recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos")
recommended_actions.append(
"Implementar bloqueo automatico de IPs despues de multiples intentos fallidos"
)
if mass_deletions >= 50:
recommended_actions.append("Activar confirmación adicional para eliminaciones masivas")
recommended_actions.append(
"Activar confirmacion adicional para eliminaciones masivas"
)
if privilege_changes >= 3:
recommended_actions.append(
"Revisar y aprobar manualmente los cambios de roles recientes"
)
if not recommended_actions:
recommended_actions.append("Continuar monitoreando actividad del sistema")
# Calcular IPs sospechosas (más de 5 intentos fallidos)
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed']))
suspicious_ips = len(set(
log.ip_address
for log in logs
if log.ip_address and log.action == 'user.login_failed'
))
# Contar acciones críticas (delete, privilege changes, etc)
critical_actions = mass_deletions + privilege_changes
return SecurityAnalysisResponse(
overall_risk_level=risk_level,
total_threats_detected=len(threat_patterns),
threats=threat_patterns,
analysis_period_hours=24,
generated_at=datetime.utcnow(),
analysis_period_hours=hours,
generated_at=datetime.now(timezone.utc),
failed_login_attempts=failed_logins,
suspicious_ips_count=suspicious_ips,
critical_actions_count=critical_actions,
recommended_actions=recommended_actions
)
# =============================================================================
# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD
# =============================================================================
@router.post("/security/action", response_model=SecurityActionResponse)
async def execute_security_action(action: SecurityActionRequest, current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
"""Ejecutar acción de seguridad"""
async def execute_security_action(
action: SecurityActionRequest,
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Ejecutar una acción de seguridad manual sobre una amenaza detectada.
Acciones disponibles:
- block_ip: Bloquear una dirección IP por X minutos
- notify_admin: Enviar notificación a los administradores
- force_password_reset: Forzar cambio de contraseña a un usuario
- disable_user: Desactivar temporalmente una cuenta de usuario
Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones.
Todas las acciones quedan registradas en el log de auditoría.
"""
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Solo administradores pueden ejecutar acciones de seguridad")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo administradores pueden ejecutar acciones de seguridad"
)
logger.info("Security action requested", user_id=str(current_user.id),
action_type=action.action_type, target=action.target)
logger.info(
"Accion de seguridad solicitada",
user_id=str(current_user.id),
action_type=action.action_type,
target=action.target
)
# Registrar en auditoría para trazabilidad completa
try:
await AuditService.log(db=db, tenant_id=current_tenant.id, user_id=current_user.id,
action=f"security.{action.action_type}", resource_type="security", resource_id=None,
metadata={"target": action.target, "reason": action.reason, "duration_minutes": action.duration_minutes})
await AuditService.log(
db=db,
tenant_id=current_tenant.id,
user_id=current_user.id,
action=f"security.{action.action_type}",
resource_type="security",
resource_id=None,
metadata={
"target": action.target,
"reason": action.reason,
"duration_minutes": action.duration_minutes
}
)
await db.commit()
except Exception as e:
logger.error("Failed to log security action", error=str(e))
logger.error("Fallo al registrar accion de seguridad en auditoria", error=str(e))
action_messages = {
"block_ip": f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}",
"notify_admin": f"Notificación enviada a administradores sobre: {action.reason}",
"force_password_reset": f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}",
"disable_user": f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}"
"block_ip": (
f"IP {action.target} bloqueada por "
f"{action.duration_minutes or 60} minutos. Razon: {action.reason}"
),
"notify_admin": (
f"Notificacion enviada a administradores sobre: {action.reason}"
),
"force_password_reset": (
f"Se forzara cambio de contrasena para {action.target}. "
f"Razon: {action.reason}"
),
"disable_user": (
f"Usuario {action.target} desactivado temporalmente. "
f"Razon: {action.reason}"
)
}
success = action.action_type in action_messages
message = action_messages.get(action.action_type, f"Tipo de acción no reconocida: {action.action_type}")
message = action_messages.get(
action.action_type,
f"Tipo de accion no reconocida: {action.action_type}"
)
return SecurityActionResponse(success=success, message=message, action_id=None)
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100),
severity: Optional[str] = Query(None), status: Optional[str] = Query(None),
incident_type: Optional[str] = Query(None), search: Optional[str] = Query(None),
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
"""Obtener incidentes de seguridad"""
logger.info("Fetching security incidents", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
filters={"severity": severity, "status": status, "type": incident_type, "page": page})
# =============================================================================
# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD
# =============================================================================
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
async def get_security_incidents(
# Paginación
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100),
# Filtros opcionales
severity: Optional[str] = Query(None),
status: Optional[str] = Query(None),
incident_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
all_tenants: bool = Query(False),
# Dependencias
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener la lista de incidentes de seguridad detectados.
Los incidentes se generan dinámicamente analizando los logs de
auditoría de los últimos 7 días usando tres detectores:
1. detect_brute_force: Analiza intentos fallidos de login
2. detect_mass_deletions: Analiza eliminaciones masivas
3. detect_privilege_escalation: Analiza cambios de rol sospechosos
Los umbrales son los mismos que usa /stats para critical_actions_today,
garantizando consistencia entre el contador y la lista.
"""
logger.info(
"Obteniendo incidentes de seguridad",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
filters={
"severity": severity,
"status": status,
"type": incident_type,
"page": page
}
)
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
now = datetime.now(timezone.utc)
analysis_start = now - timedelta(days=7)
base_query = select(AuditLog).options(selectinload(AuditLog.user)).where(AuditLog.created_at >= analysis_start)
base_query = (
select(AuditLog)
.options(selectinload(AuditLog.user))
.where(AuditLog.created_at >= analysis_start)
)
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
deletion_result = await db.execute(base_query.where(AuditLog.action.like('%.delete')).order_by(desc(AuditLog.created_at)))
# ------------------------------------------------------------------
# DETECTOR 1: ELIMINACIONES MASIVAS
# ------------------------------------------------------------------
deletion_result = await db.execute(
base_query
.where(AuditLog.action.like('%.delete'))
.order_by(desc(AuditLog.created_at))
)
deletion_logs = deletion_result.scalars().all()
deletion_incidents = detect_mass_deletions(deletion_logs, now)
failed_login_result = await db.execute(base_query.where(AuditLog.action == 'user.login_failed').order_by(desc(AuditLog.created_at)))
# ------------------------------------------------------------------
# DETECTOR 2: FUERZA BRUTA
# ------------------------------------------------------------------
failed_login_result = await db.execute(
base_query
.where(AuditLog.action == 'user.login_failed')
.order_by(desc(AuditLog.created_at))
)
failed_login_logs = failed_login_result.scalars().all()
brute_force_incidents = detect_brute_force(failed_login_logs, now)
privilege_result = await db.execute(base_query.where(and_(AuditLog.action == 'user.update', AuditLog.new_values.op('?')('role'))).order_by(desc(AuditLog.created_at)))
# ------------------------------------------------------------------
# DETECTOR 3: ESCALADA DE PRIVILEGIOS
# El operador '?' verifica si el campo JSON contiene la clave 'role'
# ------------------------------------------------------------------
privilege_result = await db.execute(
base_query
.where(and_(
AuditLog.action == 'user.update',
AuditLog.new_values.op('?')('role')
))
.order_by(desc(AuditLog.created_at))
)
privilege_logs = privilege_result.scalars().all()
privilege_incidents = detect_privilege_escalation(privilege_logs)
incidents = [SecurityIncidentResponse(**inc) for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)]
# Combinar todos los incidentes
incidents = [
SecurityIncidentResponse(**inc)
for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)
]
# ------------------------------------------------------------------
# FILTROS EN MEMORIA (los incidentes son generados dinámicamente)
# ------------------------------------------------------------------
if severity:
incidents = [i for i in incidents if i.severity == severity]
@@ -294,14 +751,29 @@ async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: i
incidents = [i for i in incidents if i.incident_type == incident_type]
if search:
search_lower = search.lower()
incidents = [i for i in incidents if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())]
incidents = [
i for i in incidents
if search_lower in i.title.lower()
or (i.description and search_lower in i.description.lower())
]
# Ordenar por fecha descendente
incidents.sort(key=lambda x: x.created_at, reverse=True)
# ------------------------------------------------------------------
# PAGINACIÓN MANUAL
# ------------------------------------------------------------------
total = len(incidents)
total_pages = (total + per_page - 1) // per_page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_incidents = incidents[start_idx:end_idx]
return SecurityIncidentListResponse(incidents=paginated_incidents, total=total, page=page, per_page=per_page, total_pages=total_pages)
return SecurityIncidentListResponse(
incidents=paginated_incidents,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages
)

View File

@@ -0,0 +1,761 @@
"""
Reports Endpoints - ServiceManagerWeb
Módulo de reportes y estadísticas del sistema.
Accesible por ADMIN y SUPPORT_MANAGER.
"""
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, case, text
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User, UserRole
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.models.system import System
from app.models.tenant import Tenant, TenantStatus
from app.api.schemas.reports import (
ReportSummaryResponse,
TicketsByStatus,
TicketsByPriority,
AgentReportResponse,
AgentReportRow,
CategoryReportResponse,
CategoryReportRow,
ClientReportResponse,
ClientReportRow,
TrendsReportResponse,
TrendDataPoint,
CSATReportResponse,
CSATDistribution,
SystemReportResponse,
SystemReportRow,
)
router = APIRouter()
CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
# ===================================
# HELPERS
# ===================================
def require_reports_access(current_user: User = Depends(get_current_user)) -> User:
"""ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes."""
allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
if current_user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.",
)
return current_user
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Solo ADMIN puede ver reportes entre tenants."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo ADMIN puede ver reportes de todos los clientes.",
)
return current_user
def _period_dates(days: int) -> tuple[datetime, datetime]:
"""Devuelve (inicio, fin) del período solicitado en UTC."""
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
return start, end
# ===================================
# 1. RESUMEN GENERAL
# ===================================
@router.get("/summary", response_model=ReportSummaryResponse)
async def get_report_summary(
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Resumen ejecutivo del período seleccionado.
Incluye:
- Total de tickets creados
- Tickets abiertos vs resueltos
- Tiempo promedio de resolución
- Calificación promedio (CSAT)
- Desglose por estado y prioridad
- Comparación con el período anterior
"""
period_start, period_end = _period_dates(days)
prev_start = period_start - timedelta(days=days)
tenant_filter = Ticket.tenant_id == current_user.tenant_id
# ── Conteos por estado ──
status_rows = (await db.execute(
select(Ticket.status, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(Ticket.status)
)).all()
by_status = TicketsByStatus()
for row in status_rows:
s = row.status.value if hasattr(row.status, "value") else str(row.status)
setattr(by_status, s.lower(), row.cnt)
by_status.total = sum(
[by_status.new, by_status.triage, by_status.in_progress,
by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened]
)
# ── Conteos por prioridad ──
priority_rows = (await db.execute(
select(Ticket.priority, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(Ticket.priority)
)).all()
by_priority = TicketsByPriority()
for row in priority_rows:
p = row.priority.value if hasattr(row.priority, "value") else str(row.priority)
setattr(by_priority, p.lower(), row.cnt)
by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent])
total_tickets = by_status.total
resolved_tickets = by_status.resolved + by_status.closed
open_tickets = total_tickets - resolved_tickets
# ── Promedio de tiempo de resolución (segundos → horas) ──
res_time_row = (await db.execute(
select(func.avg(
func.extract("epoch", Ticket.resolved_at - Ticket.created_at)
).label("avg_seconds"))
.where(and_(
tenant_filter,
Ticket.created_at >= period_start,
Ticket.resolved_at.isnot(None),
))
)).scalar_one_or_none()
avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None
# ── Promedio de primera respuesta ──
resp_time_row = (await db.execute(
select(func.avg(
func.extract("epoch", Ticket.first_response_at - Ticket.created_at)
).label("avg_seconds"))
.where(and_(
tenant_filter,
Ticket.created_at >= period_start,
Ticket.first_response_at.isnot(None),
))
)).scalar_one_or_none()
avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None
# ── CSAT ──
csat_row = (await db.execute(
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None)))
)).one()
avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None
total_rated = csat_row.cnt or 0
# ── Comparación con período anterior ──
prev_total = (await db.execute(
select(func.count(Ticket.id))
.where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start))
)).scalar_one_or_none() or 0
prev_resolved = (await db.execute(
select(func.count(Ticket.id))
.where(and_(
tenant_filter,
Ticket.created_at >= prev_start,
Ticket.created_at < period_start,
Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
))
)).scalar_one_or_none() or 0
tickets_change_pct = None
if prev_total > 0:
tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1)
resolution_change_pct = None
if prev_total > 0 and total_tickets > 0:
cur_rate = resolved_tickets / total_tickets * 100
prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0
resolution_change_pct = round(cur_rate - prev_rate, 1)
return ReportSummaryResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
total_tickets=total_tickets,
open_tickets=open_tickets,
resolved_tickets=resolved_tickets,
avg_resolution_hours=avg_resolution_hours,
avg_first_response_hours=avg_first_response_hours,
avg_rating=avg_rating,
total_rated=total_rated,
by_status=by_status,
by_priority=by_priority,
tickets_change_pct=tickets_change_pct,
resolution_change_pct=resolution_change_pct,
)
# ===================================
# 2. RENDIMIENTO POR AGENTE
# ===================================
@router.get("/by-agent", response_model=AgentReportResponse)
async def get_report_by_agent(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Rendimiento de cada agente en el período:
- Tickets asignados y resueltos
- Tasa de resolución
- Tiempo promedio de resolución
- Calificación promedio (CSAT)
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
Ticket.assigned_to.isnot(None),
)
# Obtener todos los agentes del tenant
agents_result = await db.execute(
select(User).where(
and_(
User.tenant_id == current_user.tenant_id,
User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]),
User.is_active == True,
)
)
)
agents = agents_result.scalars().all()
rows: List[AgentReportRow] = []
for agent in agents:
agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id)
total_assigned = (await db.execute(
select(func.count(Ticket.id)).where(agent_filter)
)).scalar_one_or_none() or 0
if total_assigned == 0:
continue # omitir agentes sin tickets en el período
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(agent_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
csat = (await db.execute(
select(func.avg(Ticket.rating), func.count(Ticket.rating))
.where(and_(agent_filter, Ticket.rating.isnot(None)))
)).one()
urgent_handled = (await db.execute(
select(func.count(Ticket.id)).where(
and_(agent_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
rows.append(AgentReportRow(
agent_id=str(agent.id),
agent_name=f"{agent.first_name} {agent.last_name}",
agent_email=agent.email,
total_assigned=total_assigned,
resolved=resolved,
open=total_assigned - resolved,
resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
avg_rating=round(float(csat[0]), 2) if csat[0] else None,
total_rated=csat[1] or 0,
urgent_handled=urgent_handled,
))
rows.sort(key=lambda r: r.resolved, reverse=True)
return AgentReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
agents=rows,
total_agents=len(rows),
)
# ===================================
# 3. TICKETS POR CATEGORÍA
# ===================================
@router.get("/by-category", response_model=CategoryReportResponse)
async def get_report_by_category(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Tickets agrupados por categoría con tasa de cumplimiento SLA.
"""
period_start, _ = _period_dates(days)
period_end = datetime.now(timezone.utc)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
categories_result = await db.execute(
select(Category).where(
and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True)
)
)
categories = categories_result.scalars().all()
rows: List[CategoryReportRow] = []
for cat in categories:
cat_filter = and_(tenant_filter, Ticket.category_id == cat.id)
total = (await db.execute(
select(func.count(Ticket.id)).where(cat_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(cat_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
# SLA compliance: tickets resueltos ANTES del deadline
sla_met = (await db.execute(
select(func.count(Ticket.id)).where(
and_(
cat_filter,
Ticket.resolved_at.isnot(None),
Ticket.sla_resolution_due.isnot(None),
Ticket.resolved_at <= Ticket.sla_resolution_due,
)
)
)).scalar_one_or_none() or 0
tickets_with_sla = (await db.execute(
select(func.count(Ticket.id)).where(
and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None))
)
)).scalar_one_or_none() or 0
sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0
rows.append(CategoryReportRow(
category_id=str(cat.id),
category_name=cat.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
sla_response_hours=cat.sla_response_hours,
sla_resolution_hours=cat.sla_resolution_hours,
sla_compliance_pct=sla_compliance_pct,
))
# Sin categoría
uncategorized = (await db.execute(
select(func.count(Ticket.id)).where(
and_(tenant_filter, Ticket.category_id.is_(None))
)
)).scalar_one_or_none() or 0
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return CategoryReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
categories=rows,
uncategorized_count=uncategorized,
)
# ===================================
# 4. TICKETS POR CLIENTE (solo ADMIN)
# ===================================
@router.get("/by-client", response_model=ClientReportResponse)
async def get_report_by_client(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""
Tickets agrupados por cliente (tenant). Solo accesible por ADMIN.
Útil para ver qué clientes generan más trabajo.
"""
period_start, period_end = _period_dates(days)
tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE))
tenants = tenants_result.scalars().all()
rows: List[ClientReportRow] = []
for tenant in tenants:
t_filter = and_(
Ticket.tenant_id == tenant.id,
Ticket.created_at >= period_start,
)
total = (await db.execute(
select(func.count(Ticket.id)).where(t_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
urgent = (await db.execute(
select(func.count(Ticket.id)).where(
and_(t_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
csat_row = (await db.execute(
select(func.avg(Ticket.rating))
.where(and_(t_filter, Ticket.rating.isnot(None)))
)).scalar_one_or_none()
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(t_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
last_ticket = (await db.execute(
select(func.max(Ticket.created_at)).where(t_filter)
)).scalar_one_or_none()
rows.append(ClientReportRow(
tenant_id=str(tenant.id),
tenant_name=tenant.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
urgent_tickets=urgent,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
avg_rating=round(float(csat_row), 2) if csat_row else None,
last_ticket_at=last_ticket,
))
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return ClientReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
clients=rows,
total_clients=len(rows),
)
# ===================================
# 5. TENDENCIAS (TICKETS EN EL TIEMPO)
# ===================================
@router.get("/trends", response_model=TrendsReportResponse)
async def get_report_trends(
days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Evolución diaria de tickets creados y resueltos.
Útil para detectar picos de trabajo.
"""
period_start, period_end = _period_dates(days)
tenant_filter = Ticket.tenant_id == current_user.tenant_id
# Tickets creados por día
created_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.created_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(func.date_trunc("day", Ticket.created_at))
.order_by(func.date_trunc("day", Ticket.created_at))
)).all()
# Tickets resueltos por día (según resolved_at)
resolved_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.resolved_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(
tenant_filter,
Ticket.resolved_at >= period_start,
Ticket.resolved_at.isnot(None),
))
.group_by(func.date_trunc("day", Ticket.resolved_at))
.order_by(func.date_trunc("day", Ticket.resolved_at))
)).all()
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows}
# Un punto por cada día del período
data_points: List[TrendDataPoint] = []
current = period_start
while current <= period_end:
date_str = current.strftime("%Y-%m-%d")
c = created_map.get(date_str, 0)
r = resolved_map.get(date_str, 0)
data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r))
current += timedelta(days=1)
return TrendsReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
data_points=data_points,
total_days=len(data_points),
)
# ===================================
# 6. SATISFACCIÓN DEL CLIENTE (CSAT)
# ===================================
@router.get("/csat", response_model=CSATReportResponse)
async def get_report_csat(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Reporte de satisfacción del cliente (calificaciones 1-5).
Incluye distribución, promedio por categoría y por agente.
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
# Total y promedio general
general = (await db.execute(
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated"))
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
)).one()
total_tickets = (await db.execute(
select(func.count(Ticket.id)).where(tenant_filter)
)).scalar_one_or_none() or 0
# Distribución por estrellas
dist_rows = (await db.execute(
select(Ticket.rating, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(Ticket.rating)
)).all()
dist = CSATDistribution()
for row in dist_rows:
setattr(dist, f"rating_{row.rating}", row.cnt)
# Promedio por categoría
cat_rows = (await db.execute(
select(
Category.name.label("cat_name"),
func.avg(Ticket.rating).label("avg"),
func.count(Ticket.rating).label("cnt"),
)
.join(Category, Ticket.category_id == Category.id, isouter=True)
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(Category.name)
.order_by(func.avg(Ticket.rating).desc())
)).all()
by_category = [
{
"category": row.cat_name or "Sin categoría",
"avg_rating": round(float(row.avg), 2) if row.avg else None,
"total_rated": row.cnt,
}
for row in cat_rows
]
# Promedio por agente
agent_rows = (await db.execute(
select(
User.first_name.label("fname"),
User.last_name.label("lname"),
func.avg(Ticket.rating).label("avg"),
func.count(Ticket.rating).label("cnt"),
)
.join(User, Ticket.assigned_to == User.id, isouter=True)
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(User.first_name, User.last_name)
.order_by(func.avg(Ticket.rating).desc())
)).all()
by_agent = [
{
"agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar",
"avg_rating": round(float(row.avg), 2) if row.avg else None,
"total_rated": row.cnt,
}
for row in agent_rows
]
# Últimos comentarios de calificación (rating_comment)
comment_rows = (await db.execute(
select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at)
.where(and_(
tenant_filter,
Ticket.rating.isnot(None),
Ticket.rating_comment.isnot(None),
Ticket.rating_comment != "",
))
.order_by(Ticket.rated_at.desc())
.limit(10)
)).all()
recent_comments = [
{
"rating": row.rating,
"comment": row.rating_comment,
"rated_at": row.rated_at.isoformat() if row.rated_at else None,
}
for row in comment_rows
]
total_rated = general.rated or 0
response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0
return CSATReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
avg_rating=round(float(general.avg), 2) if general.avg else None,
total_rated=total_rated,
total_tickets=total_tickets,
response_rate=response_rate,
distribution=dist,
by_category=by_category,
by_agent=by_agent,
recent_comments=recent_comments,
)
# ===================================
# 7. TICKETS POR SISTEMA AFECTADO
# ===================================
@router.get("/by-system", response_model=SystemReportResponse)
async def get_report_by_system(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Tickets agrupados por sistema afectado.
Útil para detectar qué sistemas generan más incidentes.
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
systems_result = await db.execute(
select(System).where(
and_(System.tenant_id == current_user.tenant_id, System.is_active == True)
)
)
systems = systems_result.scalars().all()
rows: List[SystemReportRow] = []
for sys in systems:
sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id)
total = (await db.execute(
select(func.count(Ticket.id)).where(sys_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
urgent = (await db.execute(
select(func.count(Ticket.id)).where(
and_(sys_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(sys_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
rows.append(SystemReportRow(
system_id=str(sys.id),
system_name=sys.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
urgent_tickets=urgent,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
))
# Sin sistema asignado
no_system = (await db.execute(
select(func.count(Ticket.id)).where(
and_(tenant_filter, Ticket.affected_system_id.is_(None))
)
)).scalar_one_or_none() or 0
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return SystemReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
systems=rows,
no_system_count=no_system,
)

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, sla
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports
api_router = APIRouter()
@@ -74,3 +74,10 @@ api_router.include_router(
prefix="/sla",
tags=["sla"]
)
# Reports routes
api_router.include_router(
reports.router,
prefix="/reports",
tags=["reports"]
)

View File

@@ -1,7 +1,7 @@
"""
Audit Log Model - ServiceManagerWeb
Modelo para bitácora de auditoría y compliance.
Modelo para bitácora de auditoría y compliance.
Registra todas las acciones importantes del sistema.
"""
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import INET, JSONB
from typing import Optional, Dict, Any, TYPE_CHECKING
import uuid
from datetime import datetime
from datetime import datetime, timezone
from app.core.database import Base, GUID
@@ -21,20 +21,20 @@ if TYPE_CHECKING:
class AuditLog(Base):
"""
Bitácora de auditoría para tracking completo de acciones.
Bitácora de auditoría para tracking completo de acciones.
Registra:
- Qui├®n hizo la acci├│n (user_id)
- Qu├® hizo (action)
- Sobre qu├® recurso (resource_type + resource_id)
- Cuándo lo hizo (created_at)
- Desde d├│nde (ip_address, user_agent)
- Qu├® cambi├│ (old_values, new_values)
- Quién hizo la acción (user_id)
- Qué hizo (action)
- Sobre qué recurso (resource_type + resource_id)
- Cuándo lo hizo (created_at)
- Desde dónde (ip_address, user_agent)
- Qué cambió (old_values, new_values)
"""
__tablename__ = "audit_logs"
# Multi-tenancy
# Multi-tenancy: cada registro pertenece a un tenant específico
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
@@ -42,7 +42,7 @@ class AuditLog(Base):
index=True
)
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
# Usuario que ejecutó la acción (NULL = acción del sistema)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="SET NULL"),
@@ -50,7 +50,8 @@ class AuditLog(Base):
index=True
)
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign")
# Acción realizada en formato "recurso.verbo"
# Ejemplos: "user.login", "ticket.create", "ticket.assign"
action: Mapped[str] = mapped_column(
String(100),
nullable=False,
@@ -70,82 +71,104 @@ class AuditLog(Base):
nullable=True
)
# Contexto de la request
# Contexto de la request: IP y navegador del usuario
ip_address: Mapped[Optional[str]] = mapped_column(
String(45).with_variant(INET, "postgresql"),
nullable=True,
)
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Correlation ID para rastrear requests relacionadas
# Correlation ID para rastrear todas las requests relacionadas
# en una misma operación o sesión
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
GUID(),
nullable=True,
index=True
)
# Valores antes del cambio (JSON)
# Estado del recurso antes del cambio (para auditoría de cambios)
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)
# Valores despu├®s del cambio (JSON)
# Estado del recurso después del cambio (para auditoría de cambios)
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)
# Metadata adicional (cualquier info relevante)
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
# Metadata adicional con cualquier información relevante del contexto
# Nota: 'metadata' está reservado en SQLAlchemy, se usa 'extra_metadata'
# como nombre del atributo Python, pero la columna en BD se llama 'metadata'
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
'metadata', # Nombre real de la columna en BD
'metadata',
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)
# Timestamp
# Timestamp de creación con timezone
# CORRECCIÓN: default=lambda: datetime.now(timezone.utc) genera un
# datetime aware en UTC, compatible con DateTime(timezone=True).
# El default anterior (datetime.utcnow) generaba datetimes naive,
# causando que los filtros de fecha fallaran silenciosamente porque
# SQLAlchemy no podía comparar aware vs naive correctamente.
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
default=lambda: datetime.now(timezone.utc),
nullable=False,
index=True
)
# Relaciones
# Relaciones con otros modelos
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
# Índices compuestos para queries comunes
# Índices compuestos para optimizar las queries más frecuentes
__table_args__ = (
# Filtrar logs por tenant y tipo de acción (uso más común)
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
# Buscar el historial de un recurso específico
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
# Ver la actividad de un usuario ordenada por fecha
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
)
# Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables
# Los audit logs son inmutables: nunca se actualizan, solo se crean
# Por eso se excluye updated_at del mapper
__mapper_args__ = {
"exclude_properties": ["updated_at"]
}
def __repr__(self) -> str:
return f"<AuditLog(action='{self.action}', resource='{self.resource_type}:{self.resource_id}')>"
return (
f"<AuditLog("
f"action='{self.action}', "
f"resource='{self.resource_type}:{self.resource_id}'"
f")>"
)
@property
def action_display(self) -> str:
"""Formato amigable de la acci├│n."""
"""
Formato legible de la acción para mostrar en la interfaz.
Convierte el formato interno "recurso.verbo" a texto descriptivo.
Ejemplo: "ticket.create""creó ticket"
"""
parts = self.action.split('.')
if len(parts) == 2:
resource, verb = parts
verb_map = {
'create': 'cre├│',
'update': 'actualiz├│',
'delete': 'elimin├│',
'login': 'inici├│ sesi├│n',
'logout': 'cerr├│ sesi├│n',
'assign': 'asign├│',
'close': 'cerr├│',
'reopen': 'reabri├│'
'create': 'creó',
'update': 'actualizó',
'delete': 'eliminó',
'login': 'inició sesión',
'logout': 'cerró sesión',
'login_failed': 'intentó iniciar sesión',
'assign': 'asignó',
'close': 'cerró',
'reopen': 'reabrió'
}
return f"{verb_map.get(verb, verb)} {resource}"
return self.action

View File

@@ -46,7 +46,7 @@
);
}
if (role === 'ADMIN') {
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
baseNavigation.push(
{
name: 'SLA Management',
@@ -57,7 +57,12 @@
name: 'Reportes',
href: '/reports',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
},
}
);
}
if (role === 'ADMIN') {
baseNavigation.push(
{
name: 'Auditoría',
href: '/audit',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,661 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import { auth } from '$lib/stores/auth';
import { get } from 'svelte/store';
let isLoading = false;
let days = 30;
let activeTab = 'summary';
const user = get(auth).user;
const isAdmin = user?.role === 'ADMIN';
let summary: any = null;
let agentReport: any = null;
let catReport: any = null;
let sysReport: any = null;
let clientReport: any = null;
let trendsReport: any = null;
let csatReport: any = null;
const tabs = [
{ id: 'summary', label: 'Resumen', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ id: 'agents', label: 'Por Agente', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197' },
{ id: 'categories', label: 'Por Categoría', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ id: 'systems', label: 'Por Sistema', icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18' },
{ id: 'clients', label: 'Por Cliente', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4', adminOnly: true },
{ id: 'trends', label: 'Tendencias', icon: 'M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z' },
{ id: 'csat', label: 'Satisfacción', icon: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z' },
].filter(t => !t.adminOnly || isAdmin);
async function loadTab(tab: string) {
isLoading = true;
try {
switch (tab) {
case 'summary': summary = await api.get(`/reports/summary?days=${days}`); break;
case 'agents': agentReport = await api.get(`/reports/by-agent?days=${days}`); break;
case 'categories': catReport = await api.get(`/reports/by-category?days=${days}`); break;
case 'systems': sysReport = await api.get(`/reports/by-system?days=${days}`); break;
case 'clients': if (isAdmin) clientReport = await api.get(`/reports/by-client?days=${days}`); break;
case 'trends': trendsReport = await api.get(`/reports/trends?days=${Math.min(days, 90)}`); break;
case 'csat': csatReport = await api.get(`/reports/csat?days=${days}`); break;
}
} catch (e: any) {
toast.error('Error cargando reporte: ' + (e.message ?? 'Error desconocido'));
} finally {
isLoading = false;
}
}
async function switchTab(tab: string) { activeTab = tab; await loadTab(tab); }
async function reloadAll() { await loadTab(activeTab); }
onMount(() => loadTab('summary'));
const STATUS_MAP: Record<string, { label: string; color: string }> = {
NEW: { label: 'Nuevo', color: 'blue' },
TRIAGE: { label: 'Triaje', color: 'purple' },
IN_PROGRESS: { label: 'En progreso', color: 'indigo' },
WAITING_CUSTOMER: { label: 'Esp. cliente', color: 'yellow' },
RESOLVED: { label: 'Resuelto', color: 'green' },
CLOSED: { label: 'Cerrado', color: 'gray' },
REOPENED: { label: 'Reabierto', color: 'red' },
};
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
LOW: { label: 'Baja', color: 'gray' },
MEDIUM: { label: 'Media', color: 'blue' },
HIGH: { label: 'Alta', color: 'orange' },
URGENT: { label: 'Urgente', color: 'red' },
};
function statusBadge(s: string) { const c = STATUS_MAP[s]?.color ?? 'gray'; return `bg-${c}-100 text-${c}-800`; }
function statusLabel(s: string) { return STATUS_MAP[s]?.label ?? s.replace(/_/g, ' '); }
function priorityBadge(p: string) { const c = PRIORITY_MAP[p]?.color ?? 'gray'; return `bg-${c}-100 text-${c}-800`; }
function priorityLabel(p: string) { return PRIORITY_MAP[p]?.label ?? p; }
function fmtHours(h: number | null): string {
if (h == null) return '—';
if (h < 1) return `${Math.round(h * 60)} min`;
if (h < 24) return `${h.toFixed(1)} h`;
return `${(h / 24).toFixed(1)} días`;
}
function fmtDate(d: string): string {
return new Date(d).toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
}
function stars(r: number | null): string {
if (!r) return '—';
const n = Math.round(r);
return '★'.repeat(n) + '☆'.repeat(5 - n);
}
function changePct(val: number | null, type: 'tickets' | 'resolution'): { cls: string; txt: string } {
if (val == null) return { cls: '', txt: '' };
const arrow = val > 0 ? '↑' : '↓';
const cls = type === 'tickets'
? (val > 0 ? 'text-red-600' : 'text-green-600')
: (val > 0 ? 'text-green-600' : 'text-red-600');
return { cls, txt: `${arrow} ${Math.abs(val)}%` };
}
function slaBarColor(pct: number): string {
if (pct >= 90) return 'bg-green-500';
if (pct >= 70) return 'bg-yellow-400';
return 'bg-red-500';
}
function maxTrend(pts: any[]): number {
if (!pts?.length) return 1;
return Math.max(...pts.map((p: any) => Math.max(p.created, p.resolved, 1)));
}
</script>
<!-- PAGINA -->
<div class="p-6 space-y-6">
<!-- ENCABEZADO -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Reportes</h1>
<p class="text-sm text-gray-500 mt-1">Estadísticas y métricas del sistema de soporte</p>
</div>
<div class="flex items-center gap-3">
<label for="period-select" class="text-sm font-medium text-gray-600">Período:</label>
<select
id="period-select"
class="border rounded-lg px-3 py-2 text-sm bg-white shadow-sm focus:ring-2 focus:ring-blue-500"
bind:value={days}
on:change={reloadAll}
>
<option value={7}>Últimos 7 días</option>
<option value={30}>Últimos 30 días</option>
<option value={60}>Últimos 60 días</option>
<option value={90}>Últimos 90 días</option>
<option value={180}>Últimos 6 meses</option>
<option value={365}>Último año</option>
</select>
<button
class="px-3 py-2 bg-blue-700 text-white text-sm rounded-lg hover:bg-blue-800 transition disabled:opacity-50"
on:click={reloadAll}
disabled={isLoading}
>
{isLoading ? '...' : '↺ Actualizar'}
</button>
</div>
</div>
<!-- TABS -->
<div class="border-b border-gray-200">
<nav class="flex gap-1 overflow-x-auto">
{#each tabs as tab}
<button
class="flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 whitespace-nowrap transition
{activeTab === tab.id ? 'border-blue-700 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"
on:click={() => switchTab(tab.id)}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={tab.icon} />
</svg>
{tab.label}
</button>
{/each}
</nav>
</div>
<!-- SPINNER -->
{#if isLoading}
<div class="flex justify-center items-center py-16 text-gray-400 text-sm gap-2">
<svg class="animate-spin h-5 w-5 text-blue-700" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
Cargando reporte...
</div>
<!-- RESUMEN -->
{:else if activeTab === 'summary' && summary}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total tickets</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{summary.total_tickets}</p>
{#if summary.tickets_change_pct != null}
{@const cp = changePct(summary.tickets_change_pct, 'tickets')}
<p class="text-sm mt-1 {cp.cls}">{cp.txt} vs período anterior</p>
{/if}
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-orange-400 border border-gray-100">
<p class="text-xs font-medium text-orange-500 uppercase tracking-wide">Abiertos</p>
<p class="text-3xl font-bold text-orange-500 mt-2">{summary.open_tickets}</p>
<p class="text-sm text-gray-400 mt-1">
{summary.total_tickets > 0 ? Math.round(summary.open_tickets / summary.total_tickets * 100) : 0}% del total
</p>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-green-500 border border-gray-100">
<p class="text-xs font-medium text-green-600 uppercase tracking-wide">Resueltos</p>
<p class="text-3xl font-bold text-green-600 mt-2">{summary.resolved_tickets}</p>
{#if summary.resolution_change_pct != null}
{@const cp = changePct(summary.resolution_change_pct, 'resolution')}
<p class="text-sm mt-1 {cp.cls}">{cp.txt} tasa vs anterior</p>
{/if}
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-red-500 border border-gray-100">
<p class="text-xs font-medium text-red-500 uppercase tracking-wide">Urgentes</p>
<p class="text-3xl font-bold text-red-600 mt-2">{summary.by_priority.urgent}</p>
<p class="text-sm text-gray-400 mt-1">Tiempo prom: {fmtHours(summary.avg_resolution_hours)}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Tiempo prom. resolución</p>
<p class="text-3xl font-bold text-blue-700 mt-2">{fmtHours(summary.avg_resolution_hours)}</p>
<p class="text-sm text-gray-400 mt-1">Primera resp: {fmtHours(summary.avg_first_response_hours)}</p>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Satisfacción (CSAT)</p>
{#if summary.avg_rating}
<p class="text-4xl font-bold text-yellow-500">{summary.avg_rating.toFixed(1)} <span class="text-2xl"></span></p>
<p class="text-sm text-gray-400 mt-1">{summary.total_rated} calificaciones</p>
{:else}
<p class="text-gray-400 text-sm mt-2">Sin calificaciones</p>
{/if}
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Por estado</p>
<div class="space-y-2">
{#each Object.entries(summary.by_status).filter(([k]) => k !== 'total') as [s, count]}
{#if count > 0}
<div class="flex items-center justify-between">
<span class="text-xs px-2 py-0.5 rounded-full font-medium {statusBadge(s.toUpperCase())}">
{statusLabel(s.toUpperCase())}
</span>
<span class="text-sm font-semibold text-gray-700">{count}</span>
</div>
{/if}
{/each}
</div>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Por prioridad</p>
<div class="space-y-2">
{#each [['URGENT', summary.by_priority.urgent], ['HIGH', summary.by_priority.high], ['MEDIUM', summary.by_priority.medium], ['LOW', summary.by_priority.low]] as [p, cnt]}
{#if cnt > 0}
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-0.5 rounded-full font-medium {priorityBadge(String(p))} w-20 text-center">
{priorityLabel(String(p))}
</span>
<div class="flex-1 bg-gray-100 rounded-full h-2">
<div class="h-2 rounded-full bg-blue-600"
style="width:{summary.by_priority.total > 0 ? Math.round(Number(cnt) / summary.by_priority.total * 100) : 0}%">
</div>
</div>
<span class="text-sm font-semibold w-6 text-right">{cnt}</span>
</div>
{/if}
{/each}
</div>
</div>
</div>
<!-- POR AGENTE -->
{:else if activeTab === 'agents' && agentReport}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="px-6 py-4 border-b bg-gray-50">
<h2 class="font-semibold text-gray-700">Rendimiento por agente — {agentReport.total_agents} agentes</h2>
</div>
{#if agentReport.agents.length === 0}
<p class="p-8 text-center text-gray-400">No hay datos de agentes en este período.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Agente</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Asignados</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resolución %</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">CSAT</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each agentReport.agents as a}
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{a.agent_name}</div>
<div class="text-xs text-gray-400">{a.agent_email}</div>
</td>
<td class="px-4 py-3 text-center font-semibold">{a.total_assigned}</td>
<td class="px-4 py-3 text-center text-green-600 font-semibold">{a.resolved}</td>
<td class="px-4 py-3 text-center text-orange-500 font-semibold">{a.open}</td>
<td class="px-4 py-3 text-center">
<div class="flex items-center gap-2 justify-center">
<div class="w-16 bg-gray-200 rounded-full h-2">
<div class="h-2 rounded-full {a.resolution_rate >= 80 ? 'bg-green-500' : a.resolution_rate >= 50 ? 'bg-yellow-400' : 'bg-red-500'}"
style="width:{a.resolution_rate}%"></div>
</div>
<span class="text-xs font-medium">{a.resolution_rate}%</span>
</div>
</td>
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(a.avg_resolution_hours)}</td>
<td class="px-4 py-3 text-center">
{#if a.avg_rating}
<span class="text-yellow-500 font-semibold">{a.avg_rating.toFixed(1)}</span>
<div class="text-xs text-gray-400">{a.total_rated} cal.</div>
{:else}
<span class="text-gray-300"></span>
{/if}
</td>
<td class="px-4 py-3 text-center">
{#if a.urgent_handled > 0}
<span class="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-800 font-semibold">{a.urgent_handled}</span>
{:else}
<span class="text-gray-300"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- POR CATEGORIA -->
{:else if activeTab === 'categories' && catReport}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="px-6 py-4 border-b bg-gray-50 flex justify-between items-center">
<h2 class="font-semibold text-gray-700">Tickets por categoría</h2>
{#if catReport.uncategorized_count > 0}
<span class="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded-full">
+ {catReport.uncategorized_count} sin categoría
</span>
{/if}
</div>
{#if catReport.categories.length === 0}
<p class="p-8 text-center text-gray-400">No hay datos de categorías en este período.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Categoría</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">SLA resp/resol</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Cumplimiento SLA</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each catReport.categories as cat}
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 font-medium text-gray-900">{cat.category_name}</td>
<td class="px-4 py-3 text-center font-semibold">{cat.total_tickets}</td>
<td class="px-4 py-3 text-center text-orange-500">{cat.open_tickets}</td>
<td class="px-4 py-3 text-center text-green-600">{cat.resolved_tickets}</td>
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(cat.avg_resolution_hours)}</td>
<td class="px-4 py-3 text-center text-gray-500 text-xs">{cat.sla_response_hours}h / {cat.sla_resolution_hours}h</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex-1 bg-gray-200 rounded-full h-2.5">
<div class="h-2.5 rounded-full {slaBarColor(cat.sla_compliance_pct)}" style="width:{cat.sla_compliance_pct}%"></div>
</div>
<span class="text-xs font-medium w-10 text-right">{cat.sla_compliance_pct}%</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- POR SISTEMA -->
{:else if activeTab === 'systems' && sysReport}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="px-6 py-4 border-b bg-gray-50 flex justify-between items-center">
<h2 class="font-semibold text-gray-700">Tickets por sistema afectado</h2>
{#if sysReport.no_system_count > 0}
<span class="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded-full">
+ {sysReport.no_system_count} sin sistema
</span>
{/if}
</div>
{#if sysReport.systems.length === 0}
<p class="p-8 text-center text-gray-400">No hay tickets con sistema asignado en este período.</p>
{:else}
{@const maxSys = Math.max(...sysReport.systems.map(s => s.total_tickets), 1)}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Sistema</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Carga de trabajo</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each sysReport.systems as sys}
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 font-medium text-gray-900">{sys.system_name}</td>
<td class="px-4 py-3 text-center font-semibold">{sys.total_tickets}</td>
<td class="px-4 py-3 text-center text-orange-500">{sys.open_tickets}</td>
<td class="px-4 py-3 text-center text-green-600">{sys.resolved_tickets}</td>
<td class="px-4 py-3 text-center">
{#if sys.urgent_tickets > 0}
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-800 text-xs font-semibold">{sys.urgent_tickets}</span>
{:else}
<span class="text-gray-300"></span>
{/if}
</td>
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(sys.avg_resolution_hours)}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex-1 bg-gray-100 rounded-full h-2.5">
<div class="h-2.5 rounded-full bg-blue-600" style="width:{Math.round(sys.total_tickets / maxSys * 100)}%"></div>
</div>
<span class="text-xs text-gray-400 w-8 text-right">{Math.round(sys.total_tickets / maxSys * 100)}%</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- POR CLIENTE (solo ADMIN) -->
{:else if activeTab === 'clients' && isAdmin && clientReport}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="px-6 py-4 border-b bg-gray-50">
<h2 class="font-semibold text-gray-700">Tickets por cliente — {clientReport.total_clients} clientes activos</h2>
</div>
{#if clientReport.clients.length === 0}
<p class="p-8 text-center text-gray-400">No hay datos de clientes en este período.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Cliente</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">CSAT</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Último ticket</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each clientReport.clients as c}
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 font-medium text-gray-900">{c.tenant_name}</td>
<td class="px-4 py-3 text-center font-semibold">{c.total_tickets}</td>
<td class="px-4 py-3 text-center text-orange-500">{c.open_tickets}</td>
<td class="px-4 py-3 text-center text-green-600">{c.resolved_tickets}</td>
<td class="px-4 py-3 text-center">
{#if c.urgent_tickets > 0}
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-800 text-xs font-semibold">{c.urgent_tickets}</span>
{:else}
<span class="text-gray-300"></span>
{/if}
</td>
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(c.avg_resolution_hours)}</td>
<td class="px-4 py-3 text-center text-yellow-500">
{c.avg_rating ? c.avg_rating.toFixed(1) + ' ★' : '—'}
</td>
<td class="px-4 py-3 text-center text-gray-400 text-xs">
{c.last_ticket_at ? new Date(c.last_ticket_at).toLocaleDateString('es-MX') : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- TENDENCIAS -->
{:else if activeTab === 'trends' && trendsReport}
<div class="bg-white rounded-xl shadow-sm border p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="font-semibold text-gray-700">Tickets diarios — últimos {trendsReport.total_days} días</h2>
<div class="flex gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-blue-400 inline-block"></span> Creados</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-green-500 inline-block"></span> Resueltos</span>
</div>
</div>
{#if trendsReport.data_points.length > 0}
{@const maxVal = maxTrend(trendsReport.data_points)}
<div class="overflow-x-auto">
<div class="relative" style="height:160px; min-width:max-content">
<div class="flex items-end gap-1 h-full border-b border-gray-200">
{#each trendsReport.data_points as pt}
<div class="w-3 bg-blue-400 rounded-t opacity-80 shrink-0"
style="height:{maxVal > 0 ? Math.round(pt.created / maxVal * 100) : 0}%"
title="Creados {pt.date}: {pt.created}"></div>
{/each}
</div>
<div class="absolute bottom-0 left-0 flex items-end gap-1 h-full pointer-events-none">
{#each trendsReport.data_points as pt}
<div class="w-3 bg-green-500 rounded-t opacity-60 shrink-0"
style="height:{maxVal > 0 ? Math.round(pt.resolved / maxVal * 100) : 0}%"
title="Resueltos {pt.date}: {pt.resolved}"></div>
{/each}
</div>
</div>
<div class="flex gap-1 mt-2" style="min-width:max-content">
{#each trendsReport.data_points as pt, i}
<div class="w-3 shrink-0 text-center">
{#if i % 7 === 0}
<span class="text-gray-400 block" style="font-size:0.55rem;writing-mode:vertical-rl">{fmtDate(pt.date)}</span>
{/if}
</div>
{/each}
</div>
</div>
<div class="mt-6 max-h-48 overflow-y-auto rounded border border-gray-100">
<table class="w-full text-xs">
<thead class="sticky top-0 bg-gray-50">
<tr class="border-b">
<th class="px-3 py-2 text-left text-gray-500 font-medium">Fecha</th>
<th class="px-3 py-2 text-center text-blue-500 font-medium">Creados</th>
<th class="px-3 py-2 text-center text-green-600 font-medium">Resueltos</th>
<th class="px-3 py-2 text-center text-gray-500 font-medium">Balance</th>
</tr>
</thead>
<tbody>
{#each [...trendsReport.data_points].reverse() as pt}
{#if pt.created > 0 || pt.resolved > 0}
<tr class="border-b hover:bg-gray-50">
<td class="px-3 py-1.5 text-gray-600">{pt.date}</td>
<td class="px-3 py-1.5 text-center text-blue-600 font-semibold">{pt.created}</td>
<td class="px-3 py-1.5 text-center text-green-600 font-semibold">{pt.resolved}</td>
<td class="px-3 py-1.5 text-center font-semibold {pt.net_open > 0 ? 'text-red-500' : pt.net_open < 0 ? 'text-green-500' : 'text-gray-400'}">
{pt.net_open > 0 ? '+' : ''}{pt.net_open}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-center text-gray-400 py-8">No hay datos en este período.</p>
{/if}
</div>
<!-- CSAT -->
{:else if activeTab === 'csat' && csatReport}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="font-semibold text-gray-700 mb-4">Resumen CSAT</h3>
{#if csatReport.avg_rating}
<div class="text-center">
<p class="text-5xl font-bold text-yellow-500">{csatReport.avg_rating.toFixed(1)}</p>
<p class="text-3xl mt-1 text-yellow-400">{stars(csatReport.avg_rating)}</p>
<p class="text-sm text-gray-500 mt-2">{csatReport.total_rated} de {csatReport.total_tickets} tickets calificados</p>
<p class="text-sm font-medium text-blue-600 mt-1">{csatReport.response_rate}% tasa de respuesta</p>
</div>
<div class="mt-6 space-y-2">
{#each [5, 4, 3, 2, 1] as star}
{@const count = csatReport.distribution[`rating_${star}`] ?? 0}
{@const pct = csatReport.total_rated > 0 ? Math.round(count / csatReport.total_rated * 100) : 0}
<div class="flex items-center gap-2 text-sm">
<span class="text-yellow-400 w-6 text-right shrink-0">{star}</span>
<div class="flex-1 bg-gray-100 rounded-full h-3">
<div class="h-3 rounded-full bg-yellow-400" style="width:{pct}%"></div>
</div>
<span class="text-gray-500 w-8 text-right text-xs shrink-0">{count}</span>
</div>
{/each}
</div>
{:else}
<p class="text-gray-400 text-center py-4">Sin calificaciones en este período</p>
{/if}
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="font-semibold text-gray-700 mb-4">CSAT por categoría</h3>
{#if csatReport.by_category.length}
<div class="space-y-3">
{#each csatReport.by_category as item}
<div>
<div class="flex justify-between items-center text-sm mb-1">
<span class="text-gray-700 truncate">{item.category}</span>
<span class="text-yellow-500 font-medium ml-2 shrink-0">{item.avg_rating ? item.avg_rating.toFixed(1) + ' ★' : '—'}</span>
</div>
<div class="bg-gray-100 rounded-full h-2">
<div class="h-2 rounded-full bg-yellow-400" style="width:{item.avg_rating ? item.avg_rating / 5 * 100 : 0}%"></div>
</div>
<p class="text-xs text-gray-400 mt-0.5">{item.total_rated} calificaciones</p>
</div>
{/each}
</div>
{:else}
<p class="text-gray-400 text-sm">Sin datos</p>
{/if}
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="font-semibold text-gray-700 mb-4">CSAT por agente</h3>
{#if csatReport.by_agent.length}
<div class="space-y-3">
{#each csatReport.by_agent as item}
<div>
<div class="flex justify-between items-center text-sm mb-1">
<span class="text-gray-700 truncate">{item.agent}</span>
<span class="text-yellow-500 font-medium ml-2 shrink-0">{item.avg_rating ? item.avg_rating.toFixed(1) + ' ★' : '—'}</span>
</div>
<div class="bg-gray-100 rounded-full h-2">
<div class="h-2 rounded-full bg-yellow-400" style="width:{item.avg_rating ? item.avg_rating / 5 * 100 : 0}%"></div>
</div>
<p class="text-xs text-gray-400 mt-0.5">{item.total_rated} calificaciones</p>
</div>
{/each}
</div>
{:else}
<p class="text-gray-400 text-sm">Sin datos</p>
{/if}
</div>
</div>
{#if csatReport.recent_comments?.length > 0}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="font-semibold text-gray-700 mb-4">Comentarios recientes</h3>
<div class="space-y-3">
{#each csatReport.recent_comments as c}
<div class="flex gap-3 items-start pb-3 border-b border-gray-100 last:border-0">
<span class="text-yellow-400 font-bold text-lg shrink-0 leading-none">
{'★'.repeat(c.rating)}{'☆'.repeat(5 - c.rating)}
</span>
<div>
<p class="text-sm text-gray-700">{c.comment}</p>
<p class="text-xs text-gray-400 mt-0.5">
{c.rated_at ? new Date(c.rated_at).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }) : ''}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- ESTADO VACIO -->
{:else if !isLoading}
<div class="flex justify-center py-16">
<p class="text-gray-400">Selecciona un período o cambia de pestaña para ver el reporte.</p>
</div>
{/if}
</div>