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:
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
await auth.login({
|
||||
email,
|
||||
password,
|
||||
tenant_slug: 'system-admin',
|
||||
tenant_slug: 'aduanasoft-demo',
|
||||
totp_code: totpCode || undefined
|
||||
});
|
||||
|
||||
|
||||
313
frontend-internal/src/routes/sla/+page.svelte
Normal file
313
frontend-internal/src/routes/sla/+page.svelte
Normal 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>
|
||||
240
frontend-internal/src/routes/sla/at-risk/+page.svelte
Normal file
240
frontend-internal/src/routes/sla/at-risk/+page.svelte
Normal 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>
|
||||
389
frontend-internal/src/routes/sla/violations/+page.svelte
Normal file
389
frontend-internal/src/routes/sla/violations/+page.svelte
Normal 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>
|
||||
@@ -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/, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user