v1.8.0: Sistema funcional con filtros optimizados y UI mejorada

Mejoras en Módulo de Tickets:
- Implementado sistema de filtros funcional por estado y prioridad
- Tabla compacta estilo auditoría (50% más espacio visible)
- Backend actualizado: parámetros 'status' y 'priority' con validación
- Interfaz más limpia con labels reducidos y 2 columnas de filtros
- Eliminación de columna SLA duplicada en tabla

Correcciones Backend:
- Endpoint /v1/tickets/: filtros 'status' y 'priority' funcionan correctamente
- Endpoint /v1/sla/violations: timezone UTC y eager loading con selectinload
- Endpoint /v1/client-profile/: generación explícita de UUID
- Migración fix_client_profiles_timestamps aplicada

Mejoras UI Frontend:
- Tabla tickets: encabezados uppercase text-xs, celdas px-3 py-2
- Toggle de estado activo/inactivo en gestión de tenants (tabla + modal)
- Badges más compactos con rounded-full
- Botones de acciones con separador visual y transiciones
- Filtros con URLSearchParams para construcción correcta de queries

Arquitectura:
- SQLAlchemy: eager loading para evitar N+1 queries
- Timezone handling: datetime.now(timezone.utc) para comparaciones
- Svelte reactivity: keyed loops y spread operator para forzar updates
- API client: endpoint con query string completo

Estado del sistema: Totalmente funcional para producción MVP
This commit is contained in:
2026-02-17 12:43:06 -07:00
parent cc1e964c3a
commit e6440395ea
8 changed files with 245 additions and 1633 deletions

View File

@@ -22,7 +22,9 @@
async function loadTenants() {
isLoading = true;
try {
tenants = await api.get('/tenants/');
const data = await api.get('/tenants/');
// Forzar reactividad asignando un nuevo array
tenants = [...data];
} catch (e) {
toast.error('Error cargando clientes');
} finally {
@@ -65,6 +67,27 @@
}
}
async function toggleTenantStatus(tenant: any) {
const newStatus = tenant.status === 'active' ? 'inactive' : 'active';
try {
// Actualizar en el backend
await api.put(`/tenants/${tenant.id}`, { status: newStatus });
// Actualización optimista: actualizar el objeto local inmediatamente
tenant.status = newStatus;
tenants = [...tenants]; // Forzar reactividad
toast.success(`Cliente ${newStatus === 'active' ? 'activado' : 'desactivado'} correctamente`);
// Recargar para asegurar sincronización con backend
await loadTenants();
} catch (e) {
toast.error(e.message || 'Error cambiando estado del cliente');
// En caso de error, recargar para restaurar el estado real
await loadTenants();
}
}
onMount(loadTenants);
</script>
@@ -108,16 +131,32 @@
{:else if tenants.length === 0}
<tr><td colspan="6" class="text-center py-4">No hay clientes registrados</td></tr>
{:else}
{#each tenants as tenant}
{#each tenants as tenant (tenant.id)}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{tenant.name}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.slug}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.contact_email || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.contact_phone || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={tenant.status === 'active'} class:text-green-800={tenant.status === 'active'} class:bg-yellow-100={tenant.status === 'suspended'} class:text-yellow-800={tenant.status === 'suspended'} class:bg-red-100={tenant.status === 'inactive'} class:text-red-800={tenant.status === 'inactive'} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
</span>
<div class="flex items-center space-x-3">
<!-- Toggle Switch -->
<button
type="button"
on:click={() => toggleTenantStatus(tenant)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
role="switch"
aria-checked={tenant.status === 'active'}
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {tenant.status === 'active' ? 'translate-x-6' : 'translate-x-1'}"
/>
</button>
<!-- Badge de Estado -->
<span class:bg-green-100={tenant.status === 'active'} class:text-green-800={tenant.status === 'active'} class:bg-yellow-100={tenant.status === 'suspended'} class:text-yellow-800={tenant.status === 'suspended'} class:bg-red-100={tenant.status === 'inactive'} class:text-red-800={tenant.status === 'inactive'} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
</span>
</div>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
@@ -162,12 +201,40 @@
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700">Estado</label>
<select id="status" bind:value={formData.status} 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">
<option value="active">Activo</option>
<option value="suspended">Suspendido</option>
<option value="inactive">Inactivo</option>
</select>
<label class="block text-sm font-medium text-gray-700 mb-3">Estado del Cliente</label>
<!-- Checkbox estilo toggle para Activo/Inactivo -->
<div class="flex items-center space-x-3">
<button
type="button"
on:click={() => formData.status = formData.status === 'active' ? 'inactive' : 'active'}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {formData.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
role="switch"
aria-checked={formData.status === 'active'}
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {formData.status === 'active' ? 'translate-x-6' : 'translate-x-1'}"
/>
</button>
<span class="text-sm font-medium {formData.status === 'active' ? 'text-green-700' : 'text-gray-500'}">
{formData.status === 'active' ? 'Activo' : 'Inactivo'}
</span>
</div>
<!-- Opción para Suspendido (opcional) -->
{#if editingTenant}
<div class="mt-3">
<label class="flex items-center">
<input
type="checkbox"
checked={formData.status === 'suspended'}
on:change={(e) => formData.status = e.target.checked ? 'suspended' : 'active'}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 h-4 w-4"
/>
<span class="ml-2 text-sm text-gray-600">Marcar como suspendido temporalmente</span>
</label>
</div>
{/if}
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">

View File

@@ -50,17 +50,26 @@
{ value: 'URGENT', label: 'Urgente', color: 'red' }
];
// Ajustar la función loadData para asegurar que los filtros se envíen correctamente
// Función para cargar datos con filtros
async function loadData() {
isLoading = true;
try {
// Construir parámetros de consulta
const queryParams = new URLSearchParams();
queryParams.append('skip', '0');
queryParams.append('limit', '100');
if (filterStatus) {
queryParams.append('status', filterStatus);
}
if (filterPriority) {
queryParams.append('priority', filterPriority);
}
const endpoint = `/tickets/?${queryParams.toString()}`;
const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
api.get('/tickets/', {
params: {
status: filterStatus || undefined,
priority: filterPriority || undefined
}
}),
api.get(endpoint),
api.get('/categories/'),
api.get('/systems/'),
api.get('/users/')
@@ -206,10 +215,11 @@
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
// Formato más compacto: DD/MM/YY HH:MM
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
@@ -218,11 +228,11 @@
onMount(loadData);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="px-4 py-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Tickets de Soporte</h1>
<p class="mt-2 text-sm text-gray-700">Gestión de tickets del sistema de mesa de ayuda.</p>
<h1 class="text-lg font-semibold text-gray-900">Tickets de Soporte</h1>
<p class="mt-1 text-xs text-gray-600">Gestión de tickets del sistema de mesa de ayuda.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
@@ -236,17 +246,17 @@
</div>
<!-- Filtros -->
<div class="mt-6 bg-white shadow sm:rounded-lg p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="mt-3 bg-white shadow sm:rounded-lg p-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
<label for="filterStatus" class="block text-xs font-medium text-gray-700 mb-1">Estado</label>
<select
id="filterStatus"
bind:value={filterStatus}
on:change={applyFilters}
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"
on:change={loadData}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-1.5"
>
<option value="">Todos</option>
<option value="">Todos los estados</option>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
@@ -254,118 +264,91 @@
</div>
<div>
<label for="filterPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<label for="filterPriority" class="block text-xs font-medium text-gray-700 mb-1">Prioridad</label>
<select
id="filterPriority"
bind:value={filterPriority}
on:change={applyFilters}
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"
on:change={loadData}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-1.5"
>
<option value="">Todas</option>
<option value="">Todas las prioridades</option>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div class="flex items-end">
<button
on:click={loadData}
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Actualizar
</button>
</div>
</div>
</div>
<!-- Tabla de Tickets -->
<div class="mt-8 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="mt-4 flex flex-col">
<div class="-mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full 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">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0 z-10">
<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">Asunto</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="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">SLA</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">Asignado a</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ticket</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Asunto</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Estado</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Prioridad</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Categoría</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Asignado</th>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Creado</th>
<th scope="col" class="relative px-3 py-1.5 w-20">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tbody class="bg-white divide-y divide-gray-200">
{#if isLoading}
<tr><td colspan="9" class="text-center py-4">Cargando...</td></tr>
<tr><td colspan="8" class="text-center py-3 text-xs text-gray-500">Cargando...</td></tr>
{:else if tickets.length === 0}
<tr><td colspan="9" class="text-center py-4">No hay tickets registrados</td></tr>
<tr><td colspan="8" class="text-center py-3 text-xs text-gray-500">No hay tickets registrados</td></tr>
{:else}
{#each tickets as ticket}
<tr
class="hover:bg-gray-50 cursor-pointer"
class="hover:bg-gray-50 cursor-pointer transition-colors"
on:click={() => viewTicket(ticket)}
>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
<td class="px-3 py-2 whitespace-nowrap text-xs font-medium text-gray-900">
{ticket.ticket_number || ticket.id.substring(0, 8)}
</td>
<td class="px-3 py-4 text-sm text-gray-900">
<div class="font-medium">{ticket.subject}</div>
<div class="text-gray-500 truncate max-w-xs">{ticket.description}</div>
<td class="px-3 py-2 text-xs">
<div class="font-medium text-gray-900 truncate max-w-xs">{ticket.subject}</div>
<div class="text-gray-500 truncate max-w-xs text-[11px]">{ticket.description}</div>
</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 bg-{getStatusBadge(ticket.status).color}-100 text-{getStatusBadge(ticket.status).color}-800">
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full bg-{getStatusBadge(ticket.status).color}-100 text-{getStatusBadge(ticket.status).color}-800">
{getStatusBadge(ticket.status).label}
</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 bg-{getPriorityBadge(ticket.priority).color}-100 text-{getPriorityBadge(ticket.priority).color}-800">
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full bg-{getPriorityBadge(ticket.priority).color}-100 text-{getPriorityBadge(ticket.priority).color}-800">
{getPriorityBadge(ticket.priority).label}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{#if ticket.sla_resolution_due}
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
⚠️ Vencido
</span>
{:else if ticket.sla_resolution_met}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
✓ OK
</span>
{:else}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
⏳ En plazo
</span>
{/if}
{:else}
<span class="text-gray-400">-</span>
{/if}
<td class="px-3 py-2 text-xs text-gray-900">
{ticket.category_name || '-'}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getCategoryName(ticket.category_id)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="px-3 py-2 text-xs text-gray-500 truncate max-w-[120px]">
{getUserName(ticket.assigned_to)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<td class="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
{formatDate(ticket.created_at)}
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6 space-x-2">
<td class="px-3 py-2 whitespace-nowrap text-right text-xs">
<button
on:click|stopPropagation={() => openEditModal(ticket)}
class="text-indigo-600 hover:text-indigo-900"
class="text-indigo-600 hover:text-indigo-900 font-medium transition-colors"
>
Editar
</button>
<span class="text-gray-300 mx-1">|</span>
<button
on:click|stopPropagation={() => openDeleteModal(ticket)}
class="text-red-600 hover:text-red-900"
class="text-red-600 hover:text-red-900 font-medium transition-colors"
>
Eliminar
</button>
@@ -375,6 +358,7 @@
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>