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:
2026-02-26 08:51:46 -07:00
parent 1ccc39732b
commit bd21207aae
9 changed files with 3619 additions and 1297 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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