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
This commit is contained in:
@@ -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
661
frontend-internal/src/routes/reports/+page.svelte
Normal file
661
frontend-internal/src/routes/reports/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user