Release version 1.3.1: Updated backend models and endpoints

This commit is contained in:
2026-02-03 13:54:05 -07:00
parent 0d7cdf51ca
commit 01c3d60694
8 changed files with 1138 additions and 12 deletions

View File

@@ -0,0 +1,481 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
let tickets = [];
let categories = [];
let systems = [];
let users = [];
let isLoading = false;
let showModal = false;
let showEditModal = false;
let showAssignModal = false;
let selectedTicket = null;
// Filtros
let filterStatus = '';
let filterPriority = '';
// Form para editar
let editFormData = {
status: '',
priority: '',
assigned_to: ''
};
let formData = {
subject: '',
description: '',
category_id: '',
affected_system_id: '',
priority: 'MEDIUM'
};
const STATUSES = [
{ value: 'NEW', label: 'Nuevo', color: 'blue' },
{ value: 'TRIAGE', label: 'Triaje', color: 'yellow' },
{ value: 'IN_PROGRESS', label: 'En Progreso', color: 'indigo' },
{ value: 'WAITING_CUSTOMER', label: 'Esperando Cliente', color: 'orange' },
{ value: 'RESOLVED', label: 'Resuelto', color: 'green' },
{ value: 'CLOSED', label: 'Cerrado', color: 'gray' },
{ value: 'REOPENED', label: 'Reabierto', color: 'red' }
];
const PRIORITIES = [
{ value: 'LOW', label: 'Baja', color: 'gray' },
{ value: 'MEDIUM', label: 'Media', color: 'blue' },
{ value: 'HIGH', label: 'Alta', color: 'orange' },
{ value: 'URGENT', label: 'Urgente', color: 'red' }
];
async function loadData() {
isLoading = true;
try {
const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
api.get('/tickets/', {
status: filterStatus || undefined,
// Se pueden agregar más filtros aquí
}),
api.get('/categories/'),
api.get('/systems/'),
api.get('/users/')
]);
tickets = ticketsData;
categories = categoriesData;
systems = systemsData;
users = usersData;
} catch (e) {
toast.error('Error cargando datos: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
}
function openCreateModal() {
selectedTicket = null;
formData = {
subject: '',
description: '',
category_id: '',
affected_system_id: '',
priority: 'MEDIUM'
};
showModal = true;
}
function viewTicket(ticket) {
selectedTicket = ticket;
// TODO: Implementar vista de detalle
toast.info('Vista de detalle próximamente');
}
function openEditModal(ticket) {
selectedTicket = ticket;
editFormData = {
status: ticket.status,
priority: ticket.priority,
assigned_to: ticket.assigned_to || ''
};
showEditModal = true;
}
async function handleUpdate() {
try {
const payload = {};
if (editFormData.status !== selectedTicket.status) {
payload.status = editFormData.status;
}
if (editFormData.priority !== selectedTicket.priority) {
payload.priority = editFormData.priority;
}
if (editFormData.assigned_to !== selectedTicket.assigned_to) {
payload.assigned_to = editFormData.assigned_to || null;
}
if (Object.keys(payload).length === 0) {
toast.info('No hay cambios para guardar');
showEditModal = false;
return;
}
await api.patch(`/tickets/${selectedTicket.id}`, payload);
toast.success('Ticket actualizado exitosamente');
showEditModal = false;
loadData();
} catch (e) {
toast.error(e.message || 'Error actualizando ticket');
}
}
async function handleSubmit() {
try {
const payload = {
subject: formData.subject,
description: formData.description,
category_id: formData.category_id || null,
affected_system_id: formData.affected_system_id || null,
priority: formData.priority
};
await api.post('/tickets/', payload);
toast.success('Ticket creado exitosamente');
showModal = false;
loadData();
} catch (e) {
toast.error(e.message || 'Error creando ticket');
}
}
function getStatusBadge(status) {
const statusObj = STATUSES.find(s => s.value === status);
return statusObj || { label: status, color: 'gray' };
}
function getPriorityBadge(priority) {
const priorityObj = PRIORITIES.find(p => p.value === priority);
return priorityObj || { label: priority, color: 'gray' };
}
function getCategoryName(id) {
if (!id) return '-';
const cat = categories.find(c => c.id === id);
return cat ? cat.name : '-';
}
function getSystemName(id) {
if (!id) return '-';
const sys = systems.find(s => s.id === id);
return sys ? sys.name : '-';
}
function getUserName(id) {
if (!id) return '-';
const user = users.find(u => u.id === id);
return user ? `${user.first_name} ${user.last_name}` : '-';
}
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
async function applyFilters() {
await loadData();
}
onMount(loadData);
</script>
<div class="px-4 py-8 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>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nuevo Ticket
</button>
</div>
</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>
<label for="filterStatus" class="block text-sm font-medium text-gray-700">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"
>
<option value="">Todos</option>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
</select>
</div>
<div>
<label for="filterPriority" class="block text-sm font-medium text-gray-700">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"
>
<option value="">Todas</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="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">
<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">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">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="8" class="text-center py-4">Cargando...</td></tr>
{:else if tickets.length === 0}
<tr><td colspan="8" class="text-center py-4">No hay tickets registrados</td></tr>
{:else}
{#each tickets as ticket}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{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>
<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">
{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">
{getPriorityBadge(ticket.priority).label}
</span>
</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">
{getUserName(ticket.assigned_to)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm 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">
<button on:click={() => viewTicket(ticket)} class="text-indigo-600 hover:text-indigo-900">Ver</button>
<button on:click={() => openEditModal(ticket)} class="text-blue-600 hover:text-blue-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Crear Ticket -->
<Modal open={showModal} title="Nuevo Ticket" on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="subject" class="block text-sm font-medium text-gray-700">Asunto *</label>
<input
type="text"
id="subject"
bind:value={formData.subject}
required
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"
placeholder="Breve descripción del problema"
/>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción *</label>
<textarea
id="description"
bind:value={formData.description}
required
rows="4"
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"
placeholder="Describe el problema en detalle..."
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="category" class="block text-sm font-medium text-gray-700">Categoría</label>
<select
id="category"
bind:value={formData.category_id}
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="">-- Seleccionar --</option>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</div>
<div>
<label for="system" class="block text-sm font-medium text-gray-700">Sistema Afectado</label>
<select
id="system"
bind:value={formData.affected_system_id}
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="">-- Seleccionar --</option>
{#each systems as system}
<option value={system.id}>{system.name}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="priority" class="block text-sm font-medium text-gray-700">Prioridad *</label>
<select
id="priority"
bind:value={formData.priority}
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"
>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button
type="submit"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
Crear Ticket
</button>
<button
type="button"
on:click={() => showModal = false}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancelar
</button>
</div>
</form>
</Modal>
<!-- Modal Editar Ticket -->
<Modal open={showEditModal} title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
<form on:submit|preventDefault={handleUpdate} class="space-y-4">
<div>
<label for="editStatus" class="block text-sm font-medium text-gray-700">Estado</label>
<select
id="editStatus"
bind:value={editFormData.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"
>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
</select>
</div>
<div>
<label for="editPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<select
id="editPriority"
bind:value={editFormData.priority}
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"
>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div>
<label for="editAssigned" class="block text-sm font-medium text-gray-700">Asignar a</label>
<select
id="editAssigned"
bind:value={editFormData.assigned_to}
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="">-- Sin asignar --</option>
{#each users.filter(u => u.role === 'AGENT' || u.role === 'SUPPORT_MANAGER' || u.role === 'ADMIN') as user}
<option value={user.id}>{user.first_name} {user.last_name} ({user.role})</option>
{/each}
</select>
</div>
<div class="bg-gray-50 p-3 rounded">
<p class="text-sm text-gray-600"><strong>Asunto:</strong> {selectedTicket?.subject}</p>
<p class="text-sm text-gray-600 mt-1"><strong>Creado por:</strong> {getUserName(selectedTicket?.created_by)}</p>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button
type="submit"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
Guardar Cambios
</button>
<button
type="button"
on:click={() => showEditModal = false}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancelar
</button>
</div>
</form>
</Modal>