v1.7.0 - Fix: Corregido error 500 en SLA Dashboard

- Fix error de sintaxis SQL en cálculo de tickets 'at risk'
- Fix error de timezone (offset-naive vs offset-aware datetimes)
- Implementado sistema completo de SLA Management
- Agregados endpoints: /sla/dashboard, /sla/violations, /sla/at-risk
- Creadas vistas frontend para dashboard, violaciones y tickets en riesgo
- Actualizado sistema de Celery para monitoreo automático de SLAs
- Mejorada configuración de categorías con tiempos SLA personalizables
- Corregidos problemas de proxy en configuración de Vite
- Agregado troubleshooting guide en README

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

Documentación: docs/changelog-2026-02-17.md
This commit is contained in:
2026-02-17 08:22:32 -07:00
parent 42a5bb54cc
commit 75726d915f
17 changed files with 2504 additions and 143 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,313 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
let isLoading = true;
let dashboardData: any = null;
let selectedPeriod = 30;
async function loadDashboard() {
isLoading = true;
try {
dashboardData = await api.get(`/sla/dashboard?days=${selectedPeriod}`);
} catch (e: any) {
toast.error(e.message || 'Error cargando dashboard SLA');
} finally {
isLoading = false;
}
}
function getRiskColor(percentage: number): string {
if (percentage >= 95) return 'text-green-600';
if (percentage >= 85) return 'text-yellow-600';
if (percentage >= 70) return 'text-orange-600';
return 'text-red-600';
}
function getTrendIcon(trend: string): string {
if (trend.startsWith('+')) return '↗';
if (trend.startsWith('-')) return '↘';
return '→';
}
function getTrendColor(trend: string): string {
if (trend.startsWith('+')) return 'text-green-600';
if (trend.startsWith('-')) return 'text-red-600';
return 'text-gray-600';
}
function getMetricValue(metrics: any, key: string): number {
return metrics[key] || 0;
}
onMount(loadDashboard);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex-auto">
<h1 class="text-2xl font-bold text-gray-900">SLA Management</h1>
<p class="mt-2 text-sm text-gray-700">Monitoreo y métricas de cumplimiento de SLAs</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<select
bind:value={selectedPeriod}
on:change={loadDashboard}
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value={7}>Últimos 7 días</option>
<option value={30}>Últimos 30 días</option>
<option value={90}>Últimos 90 días</option>
</select>
</div>
</div>
{#if isLoading}
<div class="mt-8 text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<p class="mt-2 text-sm text-gray-500">Cargando métricas...</p>
</div>
{:else if dashboardData}
<!-- KPI Cards -->
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<!-- Response SLA -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Response SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.response_sla.compliance_percentage)}">
{dashboardData.response_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.response_sla)}">
{getTrendIcon(dashboardData.trends.response_sla)} {dashboardData.trends.response_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.response_sla.met_count} / {dashboardData.response_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Resolution SLA -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Resolution SLA</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold {getRiskColor(dashboardData.resolution_sla.compliance_percentage)}">
{dashboardData.resolution_sla.compliance_percentage.toFixed(1)}%
</div>
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.resolution_sla)}">
{getTrendIcon(dashboardData.trends.resolution_sla)} {dashboardData.trends.resolution_sla}
</div>
</dd>
<dd class="mt-1 text-xs text-gray-500">
{dashboardData.resolution_sla.met_count} / {dashboardData.resolution_sla.total_count} cumplidos
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Active Violations -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Violaciones Activas</dt>
<dd class="text-2xl font-semibold text-red-600">
{dashboardData.active_violations}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/violations" class="text-indigo-600 hover:text-indigo-900">Ver detalles →</a>
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- At Risk Tickets -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Tickets en Riesgo</dt>
<dd class="text-2xl font-semibold text-yellow-600">
{dashboardData.at_risk_tickets}
</dd>
<dd class="mt-1 text-xs text-gray-500">
<a href="/sla/at-risk" class="text-indigo-600 hover:text-indigo-900">Ver lista →</a>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="mt-8 grid grid-cols-1 gap-5 lg:grid-cols-2">
<!-- Breakdown por Categoría -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Cumplimiento por Categoría</h3>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Categoría</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Tickets</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Respuesta</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Resolución</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each dashboardData.by_category as cat}
<tr>
<td class="px-3 py-2 text-sm text-gray-900">{cat.category_name}</td>
<td class="px-3 py-2 text-sm text-gray-500 text-right">{cat.ticket_count}</td>
<td class="px-3 py-2 text-sm text-right">
<span class="font-medium {getRiskColor(cat.response_compliance)}">
{cat.response_compliance.toFixed(1)}%
</span>
</td>
<td class="px-3 py-2 text-sm text-right">
<span class="font-medium {getRiskColor(cat.resolution_compliance)}">
{cat.resolution_compliance.toFixed(1)}%
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Breakdown por Prioridad -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Cumplimiento por Prioridad</h3>
<div class="space-y-4">
{#each Object.entries(dashboardData.by_priority) as [priority, metrics]}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-gray-700">{priority}</span>
<span class="text-gray-500">
Response: <span class="{getRiskColor(getMetricValue(metrics, 'response_compliance'))}">{getMetricValue(metrics, 'response_compliance').toFixed(1)}%</span>
| Resolution: <span class="{getRiskColor(getMetricValue(metrics, 'resolution_compliance'))}">{getMetricValue(metrics, 'resolution_compliance').toFixed(1)}%</span>
</span>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="bg-gray-200 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full {getRiskColor(getMetricValue(metrics, 'response_compliance')).replace('text-', 'bg-')}"
style="width: {getMetricValue(metrics, 'response_compliance')}%"
></div>
</div>
<div class="bg-gray-200 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full {getRiskColor(getMetricValue(metrics, 'resolution_compliance')).replace('text-', 'bg-')}"
style="width: {getMetricValue(metrics, 'resolution_compliance')}%"
></div>
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mt-8 bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Acciones Rápidas</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<a
href="/sla/violations"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Ver Violaciones
</a>
<a
href="/categories"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurar SLAs
</a>
<a
href="/tickets"
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg class="mr-3 h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Ver Todos los Tickets
</a>
</div>
</div>
<!-- Summary Stats -->
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3 text-center">
<div>
<p class="text-sm text-gray-500">Total de Tickets</p>
<p class="text-2xl font-semibold text-gray-900">{dashboardData.total_tickets_period}</p>
</div>
<div>
<p class="text-sm text-gray-500">Tiempo Promedio de Respuesta</p>
<p class="text-2xl font-semibold text-gray-900">
{dashboardData.response_sla.avg_time_hours?.toFixed(1) || 'N/A'} hrs
</p>
</div>
<div>
<p class="text-sm text-gray-500">Tiempo Promedio de Resolución</p>
<p class="text-2xl font-semibold text-gray-900">
{dashboardData.resolution_sla.avg_time_hours?.toFixed(1) || 'N/A'} hrs
</p>
</div>
</div>
</div>
{:else}
<div class="mt-8 text-center">
<p class="text-gray-500">No se pudieron cargar los datos</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,240 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
let isLoading = true;
let atRiskTickets: any[] = [];
let threshold = 80;
async function loadAtRiskTickets() {
isLoading = true;
try {
const data: any = await api.get(`/sla/at-risk?threshold=${threshold}`);
atRiskTickets = data.tickets || [];
} catch (e: any) {
toast.error(e.message || 'Error cargando tickets en riesgo');
atRiskTickets = [];
} finally {
isLoading = false;
}
}
function formatHours(hours: number): string {
if (hours < 1) {
return `${Math.round(hours * 60)} min`;
} else if (hours < 24) {
return `${hours.toFixed(1)} hrs`;
} else {
const days = Math.floor(hours / 24);
const remainingHours = Math.round(hours % 24);
return `${days}d ${remainingHours}h`;
}
}
function getRiskColor(percentage: number): string {
if (percentage >= 95) return 'bg-red-100 text-red-800 border-red-200';
if (percentage >= 90) return 'bg-orange-100 text-orange-800 border-orange-200';
if (percentage >= 80) return 'bg-yellow-100 text-yellow-800 border-yellow-200';
return 'bg-blue-100 text-blue-800 border-blue-200';
}
function getRiskLabel(percentage: number): string {
if (percentage >= 95) return 'Crítico';
if (percentage >= 90) return 'Alto';
if (percentage >= 80) return 'Medio';
return 'Bajo';
}
function getPriorityColor(priority: string): string {
const colors: Record<string, string> = {
'LOW': 'bg-gray-100 text-gray-800',
'MEDIUM': 'bg-blue-100 text-blue-800',
'HIGH': 'bg-orange-100 text-orange-800',
'URGENT': 'bg-red-100 text-red-800'
};
return colors[priority] || 'bg-gray-100 text-gray-800';
}
function getSLATypeLabel(type: string): string {
return type === 'response' ? 'Respuesta' : 'Resolución';
}
onMount(loadAtRiskTickets);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex-auto">
<h1 class="text-2xl font-bold text-gray-900">Tickets en Riesgo</h1>
<p class="mt-2 text-sm text-gray-700">
Tickets que están próximos a violar sus SLAs
</p>
</div>
<div class="mt-4 sm:mt-0 flex gap-3">
<select
bind:value={threshold}
on:change={loadAtRiskTickets}
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value={70}>70% del tiempo</option>
<option value={80}>80% del tiempo</option>
<option value={90}>90% del tiempo</option>
</select>
<a
href="/sla"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
← Volver al Dashboard
</a>
</div>
</div>
<!-- Info Banner -->
<div class="mt-6 bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
Mostrando tickets que han consumido {threshold}% o más de su tiempo SLA.
Estos tickets requieren atención prioritaria para evitar violaciones.
</p>
</div>
</div>
</div>
<!-- Risk Tickets List -->
<div class="mt-6 space-y-4">
{#if isLoading}
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<p class="mt-2 text-sm text-gray-500">Cargando tickets en riesgo...</p>
</div>
{:else if atRiskTickets.length === 0}
<div class="bg-white shadow rounded-lg text-center py-12">
<svg class="mx-auto h-12 w-12 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-2 text-lg font-medium text-gray-900">¡Todo bajo control!</p>
<p class="mt-1 text-sm text-gray-500">No hay tickets en riesgo de violar SLA</p>
</div>
{:else}
{#each atRiskTickets as ticket}
<div class="bg-white shadow rounded-lg overflow-hidden border-l-4 {getRiskColor(ticket.risk_percentage)}">
<div class="px-6 py-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
<a
href="/tickets/{ticket.ticket.id}"
class="text-lg font-semibold text-indigo-600 hover:text-indigo-900"
>
{ticket.ticket.ticket_number}
</a>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {getPriorityColor(ticket.ticket.priority)}">
{ticket.ticket.priority}
</span>
<span class="text-sm text-gray-500">
{getSLATypeLabel(ticket.sla_type)}
</span>
</div>
<p class="mt-1 text-sm text-gray-900">{ticket.ticket.subject}</p>
{#if ticket.category}
<div class="mt-2 text-xs text-gray-500">
📂 {ticket.category.name}
<span class="text-gray-400">
(SLA: {ticket.sla_type === 'response' ? ticket.category.sla_response_hours : ticket.category.sla_resolution_hours}h)
</span>
</div>
{/if}
{#if ticket.assigned_to}
<div class="mt-2 text-xs text-gray-500">
👤 Asignado a: <span class="text-gray-900">{ticket.assigned_to.first_name} {ticket.assigned_to.last_name}</span>
</div>
{/if}
</div>
<div class="ml-6 flex-shrink-0 text-right">
<div class="text-sm font-medium {getRiskColor(ticket.risk_percentage)} inline-flex items-center px-3 py-1 rounded-full border">
{getRiskLabel(ticket.risk_percentage)}
</div>
<div class="mt-2 text-sm">
<span class="font-semibold text-red-600">
Progreso: {ticket.risk_percentage.toFixed(1)}%
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
Quedan: <span class="font-medium text-orange-600">{formatHours(ticket.time_remaining_hours)}</span>
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="mt-4">
<div class="relative">
<div class="overflow-hidden h-2 text-xs flex rounded bg-gray-200">
<div
style="width: {ticket.risk_percentage}%"
class="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center {ticket.risk_percentage >= 95 ? 'bg-red-500' : ticket.risk_percentage >= 90 ? 'bg-orange-500' : ticket.risk_percentage >= 80 ? 'bg-yellow-500' : 'bg-blue-500'}"
></div>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>0%</span>
<span class="text-orange-600 font-medium">{threshold}% (umbral)</span>
<span>100%</span>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-3 flex justify-end gap-3">
<a
href="/tickets/{ticket.ticket.id}"
class="text-sm font-medium text-indigo-600 hover:text-indigo-900"
>
Ver ticket →
</a>
</div>
</div>
{/each}
{/if}
</div>
<!-- Summary Stats -->
{#if !isLoading && atRiskTickets.length > 0}
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<h3 class="text-sm font-medium text-gray-900 mb-4">Resumen</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 text-center">
<div>
<p class="text-sm text-gray-500">Total en Riesgo</p>
<p class="text-2xl font-semibold text-gray-900">{atRiskTickets.length}</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Crítico (≥95%)</p>
<p class="text-2xl font-semibold text-red-600">
{atRiskTickets.filter(t => t.risk_percentage >= 95).length}
</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Alto (≥90%)</p>
<p class="text-2xl font-semibold text-orange-600">
{atRiskTickets.filter(t => t.risk_percentage >= 90 && t.risk_percentage < 95).length}
</p>
</div>
<div>
<p class="text-sm text-gray-500">Riesgo Medio (≥80%)</p>
<p class="text-2xl font-semibold text-yellow-600">
{atRiskTickets.filter(t => t.risk_percentage >= 80 && t.risk_percentage < 90).length}
</p>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,389 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
let isLoading = true;
let violations: any[] = [];
let total = 0;
let page = 1;
let perPage = 20;
let totalPages = 0;
// Filtros
let slaTypeFilter = '';
let categoryFilter = '';
let priorityFilter = '';
let categories: any[] = [];
async function loadViolations() {
isLoading = true;
try {
const params = new URLSearchParams();
params.append('skip', String((page - 1) * perPage));
params.append('limit', String(perPage));
if (slaTypeFilter) params.append('sla_type', slaTypeFilter);
if (categoryFilter) params.append('category_id', categoryFilter);
if (priorityFilter) params.append('priority', priorityFilter);
const data: any = await api.get(`/sla/violations?${params.toString()}`);
violations = data.violations || [];
total = data.total || 0;
totalPages = data.total_pages || 0;
} catch (e: any) {
toast.error(e.message || 'Error cargando violaciones');
violations = [];
} finally {
isLoading = false;
}
}
async function loadCategories() {
try {
categories = await api.get('/categories/');
} catch (e) {
console.error('Error loading categories', e);
}
}
function formatHours(hours: number): string {
if (hours < 1) {
return `${Math.round(hours * 60)} min`;
} else if (hours < 24) {
return `${hours.toFixed(1)} hrs`;
} else {
const days = Math.floor(hours / 24);
const remainingHours = Math.round(hours % 24);
return `${days}d ${remainingHours}h`;
}
}
function getPriorityColor(priority: string): string {
const colors: Record<string, string> = {
'LOW': 'bg-gray-100 text-gray-800',
'MEDIUM': 'bg-blue-100 text-blue-800',
'HIGH': 'bg-orange-100 text-orange-800',
'URGENT': 'bg-red-100 text-red-800'
};
return colors[priority] || 'bg-gray-100 text-gray-800';
}
function getSLATypeLabel(type: string): string {
return type === 'response' ? 'Respuesta' : 'Resolución';
}
function getSLATypeColor(type: string): string {
return type === 'response' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800';
}
function handleFilterChange() {
page = 1;
loadViolations();
}
function nextPage() {
if (page < totalPages) {
page++;
loadViolations();
}
}
function prevPage() {
if (page > 1) {
page--;
loadViolations();
}
}
onMount(() => {
loadCategories();
loadViolations();
});
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex-auto">
<h1 class="text-2xl font-bold text-gray-900">Violaciones SLA</h1>
<p class="mt-2 text-sm text-gray-700">
Tickets que han violado sus SLAs de respuesta o resolución
</p>
</div>
<div class="mt-4 sm:mt-0">
<a
href="/sla"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
← Volver al Dashboard
</a>
</div>
</div>
<!-- Filters -->
<div class="mt-6 bg-white shadow rounded-lg p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label for="slaType" class="block text-sm font-medium text-gray-700">Tipo de SLA</label>
<select
id="slaType"
bind:value={slaTypeFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todos</option>
<option value="response">Respuesta</option>
<option value="resolution">Resolución</option>
</select>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700">Categoría</label>
<select
id="category"
bind:value={categoryFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todas</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<div>
<label for="priority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<select
id="priority"
bind:value={priorityFilter}
on:change={handleFilterChange}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todas</option>
<option value="LOW">Baja</option>
<option value="MEDIUM">Media</option>
<option value="HIGH">Alta</option>
<option value="URGENT">Urgente</option>
</select>
</div>
<div class="flex items-end">
<button
on:click={() => {
slaTypeFilter = '';
categoryFilter = '';
priorityFilter = '';
handleFilterChange();
}}
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Limpiar Filtros
</button>
</div>
</div>
</div>
<!-- Stats Summary -->
<div class="mt-6 bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">
<strong>{total}</strong> violaciones activas encontradas
{#if total > 0}
- Requieren atención inmediata
{/if}
</p>
</div>
</div>
</div>
<!-- Violations Table -->
<div class="mt-6 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">
Ticket
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Categoría
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Tipo SLA
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Prioridad
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Tiempo Vencido
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Asignado a
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr>
<td colspan="7" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<p class="mt-2 text-sm text-gray-500">Cargando violaciones...</p>
</td>
</tr>
{:else if violations.length === 0}
<tr>
<td colspan="7" class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-2 text-sm text-gray-500">Excelente! No hay violaciones de SLA activas</p>
</td>
</tr>
{:else}
{#each violations as violation}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 sm:pl-6">
<div class="flex flex-col">
<a
href="/tickets/{violation.ticket.id}"
class="font-medium text-indigo-600 hover:text-indigo-900"
>
{violation.ticket.ticket_number}
</a>
<span class="text-sm text-gray-500 truncate max-w-xs">
{violation.ticket.subject}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{#if violation.category}
<span class="text-gray-900">{violation.category.name}</span>
<div class="text-xs text-gray-500">
R: {violation.category.sla_response_hours}h |
Res: {violation.category.sla_resolution_hours}h
</div>
{:else}
<span class="text-gray-400">Sin categoría</span>
{/if}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {getSLATypeColor(violation.sla_type)}">
{getSLATypeLabel(violation.sla_type)}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {getPriorityColor(violation.ticket.priority)}">
{violation.ticket.priority}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="font-semibold text-red-600">
{formatHours(violation.hours_overdue)}
</span>
<div class="text-xs text-gray-500">
vencido
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{#if violation.assigned_to}
<div class="flex flex-col">
<span class="text-gray-900">
{violation.assigned_to.first_name} {violation.assigned_to.last_name}
</span>
<span class="text-xs text-gray-500">
{violation.assigned_to.email}
</span>
</div>
{:else}
<span class="text-gray-400 italic">Sin asignar</span>
{/if}
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<a
href="/tickets/{violation.ticket.id}"
class="text-indigo-600 hover:text-indigo-900"
>
Ver ticket →
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg shadow">
<div class="flex flex-1 justify-between sm:hidden">
<button
on:click={prevPage}
disabled={page === 1}
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
on:click={nextPage}
disabled={page === totalPages}
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Mostrando
<span class="font-medium">{(page - 1) * perPage + 1}</span>
a
<span class="font-medium">{Math.min(page * perPage, total)}</span>
de
<span class="font-medium">{total}</span>
resultados
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button
on:click={prevPage}
disabled={page === 1}
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Anterior</span>
</button>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300">
Página {page} de {totalPages}
</span>
<button
on:click={nextPage}
disabled={page === totalPages}
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Siguiente</span>
</button>
</nav>
</div>
</div>
</div>
{/if}
</div>

View File

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