- Extraccion de helpers en backend: audit_helpers.py, helpers.py - Modularizacion de schemas en archivos individuales por dominio - Reduccion de audit.py en 953 lineas (74% del archivo) - Reduccion de tickets.py en 655 lineas (60% del archivo) - Expansion de auth.py con recuperacion de contrasenia y tokens - Nuevos modulos: core/email.py, core/cache.py - Reorganizacion de scripts a backend/scripts/ - Frontend: refactorizacion de audit page con array-driven components - Frontend: correccion de 11 errores ortograficos en tickets page - Frontend: proxy Docker corregido en vite.config.js - Frontend: nuevas rutas forgot-password, reset-password, organization, profile - Nuevas utilidades TS: colorUtils.ts, dateFormats.ts - 5 nuevos archivos de tests unitarios en backend/tests/unit/ - Eliminacion de 3 scripts temporales de prueba - Documentacion tecnica: CAMBIOS_v1.10.0.md, OPTIMIZACIONES_RENDIMIENTO.md
270 lines
9.7 KiB
Svelte
270 lines
9.7 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { auth } from '$lib/stores/auth.js';
|
|
import { tickets } from '$lib/stores/tickets.js';
|
|
import { goto } from '$app/navigation';
|
|
import TicketCard from '$lib/components/TicketCard.svelte';
|
|
|
|
let searchQuery = '';
|
|
let statusFilter = '';
|
|
let priorityFilter = '';
|
|
let filteredTickets: any[] = [];
|
|
|
|
onMount(() => {
|
|
// Redirect if not authenticated
|
|
if (!$auth.isAuthenticated) {
|
|
goto('/login');
|
|
return;
|
|
}
|
|
|
|
// Load tickets
|
|
tickets.loadTickets();
|
|
});
|
|
|
|
// Filter tickets based on search and filters
|
|
$: {
|
|
filteredTickets = $tickets.tickets.filter(ticket => {
|
|
const matchesSearch = !searchQuery ||
|
|
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
ticket.id.toLowerCase().includes(searchQuery.toLowerCase());
|
|
|
|
const matchesStatus = !statusFilter || ticket.status === statusFilter;
|
|
const matchesPriority = !priorityFilter || ticket.priority === priorityFilter;
|
|
|
|
return matchesSearch && matchesStatus && matchesPriority;
|
|
});
|
|
}
|
|
|
|
// Get status counts
|
|
$: statusCounts = $tickets.tickets.reduce((acc, ticket) => {
|
|
acc[ticket.status] = (acc[ticket.status] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
function clearFilters() {
|
|
searchQuery = '';
|
|
statusFilter = '';
|
|
priorityFilter = '';
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Mis Tickets - ServiceManager</title>
|
|
</svelte:head>
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- Header -->
|
|
<div class="flex justify-between items-center mb-8">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gray-900">Mis Tickets</h1>
|
|
<p class="text-gray-600 mt-2">
|
|
Gestiona y da seguimiento a todos tus tickets de soporte
|
|
</p>
|
|
</div>
|
|
<a
|
|
href="/tickets/new"
|
|
class="btn-primary px-4 py-2 inline-flex items-center space-x-2"
|
|
>
|
|
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
<span>Crear Ticket</span>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<div class="flex items-center">
|
|
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm font-medium text-gray-500">Total</p>
|
|
<p class="text-2xl font-semibold text-gray-900">{$tickets.tickets.length}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<div class="flex items-center">
|
|
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm font-medium text-gray-500">En Progreso</p>
|
|
<p class="text-2xl font-semibold text-gray-900">
|
|
{statusCounts['IN_PROGRESS'] || 0}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<div class="flex items-center">
|
|
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm font-medium text-gray-500">Esperando</p>
|
|
<p class="text-2xl font-semibold text-gray-900">
|
|
{statusCounts['WAITING_CUSTOMER'] || 0}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<div class="flex items-center">
|
|
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm font-medium text-gray-500">Resueltos</p>
|
|
<p class="text-2xl font-semibold text-gray-900">
|
|
{(statusCounts['RESOLVED'] || 0) + (statusCounts['CLOSED'] || 0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="card mb-8">
|
|
<div class="card-content">
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<label for="search" class="form-label">Buscar</label>
|
|
<input
|
|
id="search"
|
|
type="text"
|
|
class="form-input"
|
|
placeholder="Buscar por título, descripción o ID..."
|
|
bind:value={searchQuery}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="status-filter" class="form-label">Estado</label>
|
|
<select
|
|
id="status-filter"
|
|
class="form-input"
|
|
bind:value={statusFilter}
|
|
>
|
|
<option value="">Todos los estados</option>
|
|
<option value="NEW">Nuevo</option>
|
|
<option value="IN_PROGRESS">En Progreso</option>
|
|
<option value="WAITING_CUSTOMER">Esperando Cliente</option>
|
|
<option value="RESOLVED">Resuelto</option>
|
|
<option value="CLOSED">Cerrado</option>
|
|
<option value="REOPENED">Reabierto</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="priority-filter" class="form-label">Prioridad</label>
|
|
<select
|
|
id="priority-filter"
|
|
class="form-input"
|
|
bind:value={priorityFilter}
|
|
>
|
|
<option value="">Todas las prioridades</option>
|
|
<option value="LOW">Baja</option>
|
|
<option value="MEDIUM">Media</option>
|
|
<option value="HIGH">Alta</option>
|
|
<option value="URGENT">Urgente</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex items-end">
|
|
<button
|
|
on:click={clearFilters}
|
|
class="btn-secondary px-4 py-2 w-full"
|
|
>
|
|
Limpiar Filtros
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tickets List -->
|
|
<div class="space-y-6">
|
|
{#if $tickets.isLoading}
|
|
<div class="text-center py-12">
|
|
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
|
|
<p class="text-gray-600">Cargando tickets...</p>
|
|
</div>
|
|
{:else if $tickets.error}
|
|
<div class="text-center py-12">
|
|
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
|
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar tickets</h3>
|
|
<p class="text-gray-600 mb-4">{$tickets.error}</p>
|
|
<button
|
|
on:click={() => tickets.loadTickets()}
|
|
class="btn-primary px-4 py-2"
|
|
>
|
|
Reintentar
|
|
</button>
|
|
</div>
|
|
{:else if filteredTickets.length === 0}
|
|
<div class="text-center py-12">
|
|
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
|
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
|
{$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'}
|
|
</h3>
|
|
<p class="text-gray-600 mb-4">
|
|
{$tickets.tickets.length === 0
|
|
? 'Crea tu primer ticket para comenzar'
|
|
: 'Intenta ajustar los filtros de búsqueda'}
|
|
</p>
|
|
{#if $tickets.tickets.length === 0}
|
|
<a href="/tickets/new" class="btn-primary px-4 py-2">
|
|
Crear Ticket
|
|
</a>
|
|
{:else}
|
|
<button on:click={clearFilters} class="btn-secondary px-4 py-2">
|
|
Limpiar Filtros
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-4">
|
|
{#each filteredTickets as ticket (ticket.id)}
|
|
<TicketCard {ticket} />
|
|
{/each}
|
|
</div>
|
|
|
|
{#if filteredTickets.length !== $tickets.tickets.length}
|
|
<div class="text-center py-4 text-sm text-gray-500">
|
|
Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div> |