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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user