Descarga de archivos implementada

This commit is contained in:
2026-02-10 13:39:39 -07:00
parent e766e5b747
commit a8e7af87dc
11 changed files with 832 additions and 449 deletions

View File

@@ -865,10 +865,16 @@ async def download_attachment(
current_tenant: Tenant = Depends(get_current_tenant) current_tenant: Tenant = Depends(get_current_tenant)
): ):
"""Descargar un archivo adjunto""" """Descargar un archivo adjunto"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
try: try:
ticket_uuid = uuid.UUID(ticket_id) ticket_uuid = uuid.UUID(ticket_id)
attachment_uuid = uuid.UUID(attachment_id) attachment_uuid = uuid.UUID(attachment_id)
except ValueError: except ValueError:
logger.error(f"Invalid UUID format - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
raise HTTPException(status_code=400, detail="ID inválido") raise HTTPException(status_code=400, detail="ID inválido")
# Verificar ticket # Verificar ticket
@@ -878,6 +884,7 @@ async def download_attachment(
ticket = result.scalar_one_or_none() ticket = result.scalar_one_or_none()
if not ticket: if not ticket:
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
raise HTTPException(status_code=404, detail="Ticket no encontrado") raise HTTPException(status_code=404, detail="Ticket no encontrado")
# Obtener attachment # Obtener attachment
@@ -888,12 +895,26 @@ async def download_attachment(
attachment = result.scalar_one_or_none() attachment = result.scalar_one_or_none()
if not attachment: if not attachment:
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
raise HTTPException(status_code=404, detail="Adjunto no encontrado") raise HTTPException(status_code=404, detail="Adjunto no encontrado")
logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}")
# Obtener path del archivo # Obtener path del archivo
file_path = file_handler.get_file_path(attachment.file_path) try:
file_path = file_handler.get_file_path(attachment.file_path)
logger.info(f"Absolute file path: {file_path}")
if not file_path.exists():
logger.error(f"File does not exist at path: {file_path}")
raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
except Exception as e:
logger.error(f"Error getting file path: {str(e)}")
raise
# Retornar archivo # Retornar archivo
logger.info(f"Returning file: {attachment.original_filename}")
return FileResponse( return FileResponse(
path=file_path, path=file_path,
filename=attachment.original_filename, filename=attachment.original_filename,

View File

@@ -2,20 +2,20 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
export let showLogo = true; export let showLogo = true;
export let showNavigation = true; export let showNavigation = true;
let isMenuOpen = false; let isMenuOpen = false;
onMount(() => { onMount(() => {
auth.init(); auth.init();
}); });
function toggleMenu() { function toggleMenu() {
isMenuOpen = !isMenuOpen; isMenuOpen = !isMenuOpen;
} }
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
isMenuOpen = false; isMenuOpen = false;
@@ -43,10 +43,16 @@
<!-- Navigation --> <!-- Navigation -->
{#if showNavigation && $auth.isAuthenticated} {#if showNavigation && $auth.isAuthenticated}
<nav class="hidden md:flex space-x-8"> <nav class="hidden md:flex space-x-8">
<a href="/tickets" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"> <a
href="/tickets"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Mis Tickets Mis Tickets
</a> </a>
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"> <a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Crear Ticket Crear Ticket
</a> </a>
</nav> </nav>
@@ -60,7 +66,7 @@
on:click={toggleMenu} on:click={toggleMenu}
class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2" class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2"
tabindex="0" tabindex="0"
on:keydown={(e) => e.key === 'Enter' && toggleMenu()} on:keydown={e => e.key === 'Enter' && toggleMenu()}
> >
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center"> <div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span class="text-primary-600 text-sm font-medium"> <span class="text-primary-600 text-sm font-medium">
@@ -68,13 +74,16 @@
</span> </span>
</div> </div>
<span class="hidden sm:block text-sm"> <span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name} {$auth.user?.first_name}
{$auth.user?.last_name}
</span> </span>
<Icon name="chevronDown" size="w-4 h-4" /> <Icon name="chevronDown" size="w-4 h-4" />
</button> </button>
{#if isMenuOpen} {#if isMenuOpen}
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50"> <div
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50"
>
<div class="py-1"> <div class="py-1">
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200"> <div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email} {$auth.user?.email}
@@ -82,7 +91,7 @@
<a <a
href="/profile" href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => isMenuOpen = false} on:click={() => (isMenuOpen = false)}
> >
Mi Perfil Mi Perfil
</a> </a>
@@ -91,7 +100,7 @@
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="button" role="button"
tabindex="0" tabindex="0"
on:keydown={(e) => e.key === 'Enter' && handleLogout()} on:keydown={e => e.key === 'Enter' && handleLogout()}
> >
Cerrar Sesión Cerrar Sesión
</button> </button>
@@ -100,10 +109,7 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<a <a href="/login" class="text-gray-700 hover:text-primary-600 text-sm font-medium">
href="/login"
class="text-gray-700 hover:text-primary-600 text-sm font-medium"
>
Iniciar Sesión Iniciar Sesión
</a> </a>
{/if} {/if}
@@ -114,10 +120,16 @@
{#if showNavigation && $auth.isAuthenticated} {#if showNavigation && $auth.isAuthenticated}
<div class="md:hidden border-t border-gray-200 py-2"> <div class="md:hidden border-t border-gray-200 py-2">
<nav class="flex space-x-4"> <nav class="flex space-x-4">
<a href="/tickets" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"> <a
href="/tickets"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Mis Tickets Mis Tickets
</a> </a>
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"> <a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Crear Ticket Crear Ticket
</a> </a>
</nav> </nav>
@@ -128,8 +140,5 @@
<!-- Backdrop for mobile menu --> <!-- Backdrop for mobile menu -->
{#if isMenuOpen} {#if isMenuOpen}
<div <div class="fixed inset-0 z-40 md:hidden" on:click={() => (isMenuOpen = false)} />
class="fixed inset-0 z-40 md:hidden" {/if}
on:click={() => isMenuOpen = false}
></div>
{/if}

View File

@@ -328,6 +328,39 @@ function createTicketsStore() {
comments: [], comments: [],
attachments: [] attachments: []
})); }));
},
// Download attachment
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth);
if (!authState.token || !authState.user) {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.user.tenant_id
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Download failed' }));
throw new Error(error.detail || 'Download failed');
}
// Crear blob y descargar
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} }
}; };
} }

View File

@@ -4,14 +4,14 @@
import { tickets } from '$lib/stores/tickets.js'; import { tickets } from '$lib/stores/tickets.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
onMount(() => { onMount(() => {
// Redirect if not authenticated // Redirect if not authenticated
if (!$auth.isAuthenticated) { if (!$auth.isAuthenticated) {
goto('/login'); goto('/login');
return; return;
} }
// Load user's tickets // Load user's tickets
tickets.loadTickets(); tickets.loadTickets();
}); });
@@ -26,102 +26,108 @@
<div class="bg-gradient-to-r from-primary-500 to-primary-600 rounded-lg p-8 text-white mb-8"> <div class="bg-gradient-to-r from-primary-500 to-primary-600 rounded-lg p-8 text-white mb-8">
<div class="max-w-3xl"> <div class="max-w-3xl">
<h1 class="text-3xl font-bold mb-2"> <h1 class="text-3xl font-bold mb-2">
Bienvenido, {$auth.user?.first_name} {$auth.user?.last_name} Bienvenido, {$auth.user?.first_name}
{$auth.user?.last_name}
</h1> </h1>
<p class="text-primary-100 text-lg"> <p class="text-primary-100 text-lg">
Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets, Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets, da seguimiento a
da seguimiento a los existentes y mantente actualizado con el estado de tus solicitudes. los existentes y mantente actualizado con el estado de tus solicitudes.
</p> </p>
</div> </div>
</div> </div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<a <a href="/tickets/new" class="card hover:shadow-lg transition-shadow group cursor-pointer">
href="/tickets/new"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center"> <div class="card-content text-center">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-primary-200 transition-colors"> <div
class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-primary-200 transition-colors"
>
<Icon name="plus" size="w-6 h-6" className="text-primary-600" /> <Icon name="plus" size="w-6 h-6" className="text-primary-600" />
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Crear Ticket</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">Crear Ticket</h3>
<p class="text-gray-600 text-sm"> <p class="text-gray-600 text-sm">Reporta un problema o solicita soporte técnico</p>
Reporta un problema o solicita soporte técnico
</p>
</div> </div>
</a> </a>
<a <a href="/tickets" class="card hover:shadow-lg transition-shadow group cursor-pointer">
href="/tickets"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center"> <div class="card-content text-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-blue-200 transition-colors"> <div
class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-blue-200 transition-colors"
>
<Icon name="ticket" size="w-6 h-6" className="text-blue-600" /> <Icon name="ticket" size="w-6 h-6" className="text-blue-600" />
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Mis Tickets</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">Mis Tickets</h3>
<p class="text-gray-600 text-sm"> <p class="text-gray-600 text-sm">Consulta el estado de todos tus tickets</p>
Consulta el estado de todos tus tickets
</p>
</div> </div>
</a> </a>
<a <a href="/profile" class="card hover:shadow-lg transition-shadow group cursor-pointer">
href="/profile"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center"> <div class="card-content text-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-green-200 transition-colors"> <div
class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-green-200 transition-colors"
>
<Icon name="user" size="w-6 h-6" className="text-green-600" /> <Icon name="user" size="w-6 h-6" className="text-green-600" />
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Mi Perfil</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">Mi Perfil</h3>
<p class="text-gray-600 text-sm"> <p class="text-gray-600 text-sm">Actualiza tu información personal</p>
Actualiza tu información personal
</p>
</div> </div>
</a> </a>
</div> </div>
<!-- Recent Tickets --> <!-- Recent Tickets -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Tickets Recientes</h2> <h2 class="text-xl font-semibold text-gray-900">Tickets Recientes</h2>
<p class="text-gray-600 mt-1">Últimos tickets que has creado o actualizado</p> <p class="text-gray-600 mt-1">Últimos tickets que has creado o actualizado</p>
</div> </div>
<div class="card-content"> <div class="card-content">
{#if $tickets.isLoading} {#if $tickets.isLoading}
<div class="text-center py-8"> <div class="text-center py-8">
<div class="spinner w-8 h-8 mx-auto mb-4"></div> <div class="spinner w-8 h-8 mx-auto mb-4" />
<p class="text-gray-600">Cargando tickets...</p> <p class="text-gray-600">Cargando tickets...</p>
</div> </div>
{:else if $tickets.error} {:else if $tickets.error}
<div class="text-center py-8"> <div class="text-center py-8">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4"> <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"> <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" /> <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> </svg>
</div> </div>
<p class="text-gray-600 mb-4">Error al cargar los tickets</p> <p class="text-gray-600 mb-4">Error al cargar los tickets</p>
<button <button on:click={() => tickets.loadTickets()} class="btn-primary px-4 py-2">
on:click={() => tickets.loadTickets()}
class="btn-primary px-4 py-2"
>
Reintentar Reintentar
</button> </button>
</div> </div>
{:else if $tickets.tickets.length === 0} {:else if $tickets.tickets.length === 0}
<div class="text-center py-8"> <div class="text-center py-8">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4"> <div
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4"
<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
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> </svg>
</div> </div>
<p class="text-gray-600 mb-4">No tienes tickets creados</p> <p class="text-gray-600 mb-4">No tienes tickets creados</p>
<a href="/tickets/new" class="btn-primary px-4 py-2"> <a href="/tickets/new" class="btn-primary px-4 py-2"> Crear tu primer ticket </a>
Crear tu primer ticket
</a>
</div> </div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
@@ -144,17 +150,23 @@
</div> </div>
<div class="ml-4"> <div class="ml-4">
<span class="badge-{ticket.status.toLowerCase().replace('_', '-')}"> <span class="badge-{ticket.status.toLowerCase().replace('_', '-')}">
{ticket.status === 'NEW' ? 'Nuevo' : {ticket.status === 'NEW'
ticket.status === 'IN_PROGRESS' ? 'En Progreso' : ? 'Nuevo'
ticket.status === 'WAITING_FOR_CLIENT' ? 'Esperando Cliente' : : ticket.status === 'IN_PROGRESS'
ticket.status === 'RESOLVED' ? 'Resuelto' : ? 'En Progreso'
ticket.status === 'CLOSED' ? 'Cerrado' : 'Reabierto'} : ticket.status === 'WAITING_FOR_CLIENT'
? 'Esperando Cliente'
: ticket.status === 'RESOLVED'
? 'Resuelto'
: ticket.status === 'CLOSED'
? 'Cerrado'
: 'Reabierto'}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/each} {/each}
{#if $tickets.tickets.length > 5} {#if $tickets.tickets.length > 5}
<div class="text-center pt-4 border-t border-gray-200"> <div class="text-center pt-4 border-t border-gray-200">
<a href="/tickets" class="btn-secondary px-4 py-2"> <a href="/tickets" class="btn-secondary px-4 py-2">
@@ -176,4 +188,4 @@
overflow: hidden; overflow: hidden;
line-clamp: 2; /* Propiedad estándar para compatibilidad */ line-clamp: 2; /* Propiedad estándar para compatibilidad */
} }
</style> </style>

View File

@@ -4,7 +4,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
let email = ''; let email = '';
let password = ''; let password = '';
let totpCode = ''; let totpCode = '';
@@ -12,23 +12,23 @@
let showTwoFactor = false; let showTwoFactor = false;
let errorMessage = ''; let errorMessage = '';
let showPassword = false; let showPassword = false;
onMount(() => { onMount(() => {
// Redirect if already authenticated // Redirect if already authenticated
if ($auth.isAuthenticated) { if ($auth.isAuthenticated) {
goto('/'); goto('/');
} }
}); });
async function handleLogin() { async function handleLogin() {
if (!email || !password) { if (!email || !password) {
errorMessage = 'Por favor completa todos los campos'; errorMessage = 'Por favor completa todos los campos';
return; return;
} }
isLoading = true; isLoading = true;
errorMessage = ''; errorMessage = '';
try { try {
await auth.login({ await auth.login({
email, email,
@@ -36,12 +36,12 @@
tenant_slug: 'aduanasoft', // Default tenant for now tenant_slug: 'aduanasoft', // Default tenant for now
totp_code: totpCode || undefined totp_code: totpCode || undefined
}); });
toast.success('¡Bienvenido! Has iniciado sesión correctamente'); toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/'); goto('/');
} catch (error: any) { } catch (error: any) {
console.error('Login error:', error); console.error('Login error:', error);
// Check if 2FA is required // Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) { if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true; showTwoFactor = true;
@@ -54,7 +54,7 @@
isLoading = false; isLoading = false;
} }
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') { if (event.key === 'Enter') {
handleLogin(); handleLogin();
@@ -62,182 +62,213 @@
} }
</script> </script>
<div class="min-h-screen flex font-sans bg-white overflow-hidden"> <div class="min-h-screen flex font-sans bg-white overflow-hidden">
<!-- Left Side: Hero Image & Overlay (55% width) --> <!-- Left Side: Hero Image & Overlay (55% width) -->
<div class="hidden lg:flex w-[55%] relative bg-gray-900"> <div class="hidden lg:flex w-[55%] relative bg-gray-900">
<!-- Background Image --> <!-- Background Image -->
<div <div
class="absolute inset-0 bg-cover bg-center z-0" class="absolute inset-0 bg-cover bg-center z-0"
style="background-image: url('/images/SOPORTE.webp'); opacity: 1;" style="background-image: url('/images/SOPORTE.webp'); opacity: 1;"
></div> />
<!-- Gradient Overlay --> <!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-br from-[#1e3a8a]/75 to-[#172554]/75 z-10"></div> <div class="absolute inset-0 bg-gradient-to-br from-[#1e3a8a]/75 to-[#172554]/75 z-10" />
<div class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent z-10"></div> <div
class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent z-10"
/>
<!-- Content --> <!-- Content -->
<div class="relative z-20 w-full h-full flex flex-col justify-between p-16 text-white"> <div class="relative z-20 w-full h-full flex flex-col justify-between p-16 text-white">
<!-- Top Logo (Left) --> <!-- Top Logo (Left) -->
<div class="flex flex-col"> <div class="flex flex-col">
<img src="/images/Logo%20AS%20blanco(1).png" alt="AduanaSoft" class="h-32 w-auto object-contain self-start drop-shadow-lg" /> <img
</div> src="/images/Logo%20AS%20blanco(1).png"
alt="AduanaSoft"
class="h-32 w-auto object-contain self-start drop-shadow-lg"
/>
</div>
<!-- Main Hero Text --> <!-- Main Hero Text -->
<div class="space-y-4 mb-12"> <div class="space-y-4 mb-12">
<h2 class="text-5xl font-extrabold tracking-tight drop-shadow-xl leading-tight"> <h2 class="text-5xl font-extrabold tracking-tight drop-shadow-xl leading-tight">
Control Total <br/> Control Total <br />
de Servicios de TI de Servicios de TI
</h2> </h2>
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md"> <p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y reciba asistencia especializada para garantizar la continuidad de su operación. Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y
</p> reciba asistencia especializada para garantizar la continuidad de su operación.
</div> </p>
</div>
<!-- Bottom Footer --> <!-- Bottom Footer -->
<div class="text-xs font-bold tracking-[0.2em] text-blue-200/60 uppercase"> <div class="text-xs font-bold tracking-[0.2em] text-blue-200/60 uppercase">
ServiceManager Enterprise Platform ServiceManager Enterprise Platform
</div> </div>
</div> </div>
</div> </div>
<!-- Right Side: Login Form (45% width) --> <!-- Right Side: Login Form (45% width) -->
<div class="w-full lg:w-[45%] flex flex-col justify-center items-center p-8 lg:p-16 bg-white relative"> <div
class="w-full lg:w-[45%] flex flex-col justify-center items-center p-8 lg:p-16 bg-white relative"
>
<div class="w-full max-w-md space-y-8"> <div class="w-full max-w-md space-y-8">
<!-- Logo & Header --> <!-- Logo & Header -->
<div class="text-center space-y-2"> <div class="text-center space-y-2">
<h2 class="text-3xl font-bold text-gray-900">Bienvenido</h2> <h2 class="text-3xl font-bold text-gray-900">Bienvenido</h2>
<p class="text-gray-500 text-sm">Ingrese a su cuenta corporativa</p> <p class="text-gray-500 text-sm">Ingrese a su cuenta corporativa</p>
</div>
<!-- Form -->
<form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8">
{#if errorMessage}
<div
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
>
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
{errorMessage}
</div>
{/if}
{#if !showTwoFactor}
<div class="space-y-5">
<!-- Email Input -->
<div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700"
>Correo Electrónico</label
>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon
name="mail"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div>
<input
id="email"
type="email"
bind:value={email}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-3 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="admin@aduanasoft.com"
required
disabled={isLoading}
/>
</div>
</div>
<!-- Password Input -->
<div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700"
>Contraseña</label
>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon
name="lock"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div>
{#if showPassword}
<input
id="password"
type="text"
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
required
disabled={isLoading}
/>
{:else}
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
required
disabled={isLoading}
/>
{/if}
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
on:click={() => (showPassword = !showPassword)}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer"
/>
<label
for="remember-me"
class="ml-2 block text-sm text-gray-500 cursor-pointer select-none"
>Recordar en este equipo</label
>
</div>
<a
href="/forgot-password"
class="text-sm font-medium text-blue-600 hover:text-blue-500"
>
Olvide mi clave
</a>
</div>
</div>
{:else}
<!-- 2FA Input -->
<div class="space-y-4 animate-slide-up">
<label for="code" class="block text-sm font-medium text-gray-700 text-center"
>Código de Verificación (2FA)</label
>
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
</div>
<input
id="code"
type="text"
bind:value={totpCode}
on:keydown={handleKeyDown}
class="block w-full pl-10 py-3 text-center tracking-[0.5em] font-mono text-lg border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent"
placeholder="000000"
maxlength="6"
required
disabled={isLoading}
/>
</div>
</div>
{/if}
<div class="pt-2">
<button
type="submit"
class="w-full flex justify-center py-3.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-bold text-white bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
Procesando...
{:else}
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
{/if}
</button>
</div> </div>
<!-- Form --> <div class="mt-8 text-center text-xs text-gray-400">
<form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8"> © 2026 Aduanasoft. Acceso exclusivo autorizado.
{#if errorMessage} </div>
<div class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"> </form>
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
{errorMessage}
</div>
{/if}
{#if !showTwoFactor}
<div class="space-y-5">
<!-- Email Input -->
<div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700">Correo Electrónico</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="mail" class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
</div>
<input
id="email"
type="email"
bind:value={email}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-3 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="admin@aduanasoft.com"
required
disabled={isLoading}
/>
</div>
</div>
<!-- Password Input -->
<div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700">Contraseña</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="lock" class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
</div>
{#if showPassword}
<input
id="password"
type="text"
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
required
disabled={isLoading}
/>
{:else}
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
required
disabled={isLoading}
/>
{/if}
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
on:click={() => showPassword = !showPassword}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input id="remember-me" name="remember-me" type="checkbox" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer">
<label for="remember-me" class="ml-2 block text-sm text-gray-500 cursor-pointer select-none">Recordar en este equipo</label>
</div>
<a href="/forgot-password" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Olvide mi clave
</a>
</div>
</div>
{:else}
<!-- 2FA Input -->
<div class="space-y-4 animate-slide-up">
<label for="code" class="block text-sm font-medium text-gray-700 text-center">Código de Verificación (2FA)</label>
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
</div>
<input
id="code"
type="text"
bind:value={totpCode}
on:keydown={handleKeyDown}
class="block w-full pl-10 py-3 text-center tracking-[0.5em] font-mono text-lg border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent"
placeholder="000000"
maxlength="6"
required
disabled={isLoading}
/>
</div>
</div>
{/if}
<div class="pt-2">
<button
type="submit"
class="w-full flex justify-center py-3.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-bold text-white bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
Procesando...
{:else}
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
{/if}
</button>
</div>
<div class="mt-8 text-center text-xs text-gray-400">
© 2026 Aduanasoft. Acceso exclusivo autorizado.
</div>
</form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -430,7 +430,11 @@
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-md font-medium transition-colors" disabled={isUpdatingProfile}> <button
type="submit"
class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-md font-medium transition-colors"
disabled={isUpdatingProfile}
>
{#if isUpdatingProfile} {#if isUpdatingProfile}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" /> <div class="spinner w-4 h-4" />
@@ -624,7 +628,9 @@
<h3 class="font-medium text-gray-900 mb-4">Representantes</h3> <h3 class="font-medium text-gray-900 mb-4">Representantes</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label class="form-label" for="company_representative">Encargado/Representante</label> <label class="form-label" for="company_representative"
>Encargado/Representante</label
>
<input <input
id="company_representative" id="company_representative"
type="text" type="text"
@@ -979,7 +985,11 @@
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-md font-medium transition-colors" disabled={isChangingPassword}> <button
type="submit"
class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-md font-medium transition-colors"
disabled={isChangingPassword}
>
{#if isChangingPassword} {#if isChangingPassword}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" /> <div class="spinner w-4 h-4" />
@@ -1081,7 +1091,7 @@
border-color: #d1d5db; border-color: #d1d5db;
color: #2563eb; color: #2563eb;
} }
.form-checkbox:focus { .form-checkbox:focus {
ring-color: #3b82f6; ring-color: #3b82f6;
border-color: #3b82f6; border-color: #3b82f6;

View File

@@ -5,7 +5,7 @@
import { tickets } from '$lib/stores/tickets.js'; import { tickets } from '$lib/stores/tickets.js';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
let ticketId: string; let ticketId: string;
let newComment = ''; let newComment = '';
let isSubmittingComment = false; let isSubmittingComment = false;
@@ -15,16 +15,16 @@
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let isUploading = false; let isUploading = false;
let pollingInterval: any = null; let pollingInterval: any = null;
let showAttachments = true; // Variable para controlar la visibilidad de attachments let showAttachments = true; // Variable para controlar la visibilidad de attachments
async function loadComments() { async function loadComments() {
try { try {
await tickets.reloadComments(ticketId); await tickets.reloadComments(ticketId);
} catch (error) { } catch (error) {
// No mostrar error en polling silencioso // No mostrar error en polling silencioso
console.error('Error recargando comentarios:', error); console.error('Error recargando comentarios:', error);
}
} }
}
onMount(() => { onMount(() => {
// Redirect if not authenticated // Redirect if not authenticated
@@ -32,25 +32,25 @@
goto('/login'); goto('/login');
return; return;
} }
ticketId = $page.params.id; ticketId = $page.params.id;
if (ticketId) { if (ticketId) {
tickets.loadTicket(ticketId); tickets.loadTicket(ticketId);
// Polling cada 3 segundos // Polling cada 3 segundos
pollingInterval = setInterval(() => { pollingInterval = setInterval(() => {
loadComments(); loadComments();
}, 3000); }, 3000);
}
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
} }
};
}); // Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
});
// Format date // Format date
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('es-ES', { return new Date(dateString).toLocaleString('es-ES', {
@@ -61,7 +61,7 @@
minute: '2-digit' minute: '2-digit'
}); });
} }
// Status mapping // Status mapping
const statusConfig = { const statusConfig = {
NEW: { label: 'Nuevo', class: 'badge-new' }, NEW: { label: 'Nuevo', class: 'badge-new' },
@@ -71,7 +71,7 @@
CLOSED: { label: 'Cerrado', class: 'badge-closed' }, CLOSED: { label: 'Cerrado', class: 'badge-closed' },
REOPENED: { label: 'Reabierto', class: 'badge-reopened' } REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
}; };
// Priority mapping // Priority mapping
const priorityConfig = { const priorityConfig = {
LOW: { label: 'Baja', class: 'badge-priority-low' }, LOW: { label: 'Baja', class: 'badge-priority-low' },
@@ -79,10 +79,10 @@
HIGH: { label: 'Alta', class: 'badge-priority-high' }, HIGH: { label: 'Alta', class: 'badge-priority-high' },
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' } URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
}; };
async function handleAddComment() { async function handleAddComment() {
if (!newComment.trim()) return; if (!newComment.trim()) return;
isSubmittingComment = true; isSubmittingComment = true;
try { try {
await tickets.addComment(ticketId, newComment.trim()); await tickets.addComment(ticketId, newComment.trim());
@@ -94,34 +94,39 @@
isSubmittingComment = false; isSubmittingComment = false;
} }
} }
async function handleFileUpload(event: Event) { async function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const file = target.files?.[0]; const file = target.files?.[0];
if (!file) return; if (!file) return;
// Validate file size (max 10MB) // Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) { if (file.size > 10 * 1024 * 1024) {
toast.error('El archivo es demasiado grande. Máximo 10MB'); toast.error('El archivo es demasiado grande. Máximo 10MB');
target.value = ''; target.value = '';
return; return;
} }
// Validate file type // Validate file type
const allowedTypes = [ const allowedTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/jpeg',
'application/pdf', 'text/plain', 'application/msword', 'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]; ];
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
toast.error('Tipo de archivo no permitido'); toast.error('Tipo de archivo no permitido');
target.value = ''; target.value = '';
return; return;
} }
isUploading = true; isUploading = true;
try { try {
await tickets.uploadAttachment(ticketId, file); await tickets.uploadAttachment(ticketId, file);
@@ -133,11 +138,21 @@
isUploading = false; isUploading = false;
} }
} }
async function handleDownloadAttachment(attachment: any) {
try {
await tickets.downloadAttachment(ticketId, attachment.id, attachment.original_filename);
toast.success('Descarga iniciada');
} catch (error: any) {
console.error('Download error:', error);
toast.error(error.message || 'Error al descargar el archivo');
}
}
function handleCloseTicket() { function handleCloseTicket() {
showCloseDialog = true; showCloseDialog = true;
} }
async function confirmCloseTicket() { async function confirmCloseTicket() {
isClosingTicket = true; isClosingTicket = true;
try { try {
@@ -151,14 +166,15 @@
isClosingTicket = false; isClosingTicket = false;
} }
} }
function cancelCloseTicket() { function cancelCloseTicket() {
showCloseDialog = false; showCloseDialog = false;
closeResolution = ''; closeResolution = '';
} }
// Check if user can close ticket // Check if user can close ticket
$: canClose = $tickets.currentTicket && $: canClose =
$tickets.currentTicket &&
['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status); ['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status);
</script> </script>
@@ -171,22 +187,24 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{#if $tickets.isLoading} {#if $tickets.isLoading}
<div class="text-center py-12"> <div class="text-center py-12">
<div class="spinner w-8 h-8 mx-auto mb-4"></div> <div class="spinner w-8 h-8 mx-auto mb-4" />
<p class="text-gray-600">Cargando ticket...</p> <p class="text-gray-600">Cargando ticket...</p>
</div> </div>
{:else if $tickets.error} {:else if $tickets.error}
<div class="text-center py-12"> <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"> <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"> <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" /> <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> </svg>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p> <p class="text-gray-600 mb-4">{$tickets.error}</p>
<button <button on:click={() => tickets.loadTicket(ticketId)} class="btn-primary px-4 py-2">
on:click={() => tickets.loadTicket(ticketId)}
class="btn-primary px-4 py-2"
>
Reintentar Reintentar
</button> </button>
</div> </div>
@@ -199,7 +217,7 @@
</svg> </svg>
<span>#{$tickets.currentTicket.id.substring(0, 8)}</span> <span>#{$tickets.currentTicket.id.substring(0, 8)}</span>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content --> <!-- Main Content -->
<div class="lg:col-span-2 space-y-6"> <div class="lg:col-span-2 space-y-6">
@@ -223,7 +241,7 @@
</span> </span>
</div> </div>
</div> </div>
{#if canClose} {#if canClose}
<button <button
on:click={handleCloseTicket} on:click={handleCloseTicket}
@@ -235,14 +253,14 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="prose max-w-none"> <div class="prose max-w-none">
<p class="whitespace-pre-wrap text-gray-700"> <p class="whitespace-pre-wrap text-gray-700">
{$tickets.currentTicket.description} {$tickets.currentTicket.description}
</p> </p>
</div> </div>
{#if $tickets.currentTicket.resolution} {#if $tickets.currentTicket.resolution}
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg"> <div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 class="font-medium text-green-900 mb-2">Resolución:</h4> <h4 class="font-medium text-green-900 mb-2">Resolución:</h4>
@@ -253,7 +271,7 @@
{/if} {/if}
</div> </div>
</div> </div>
<!-- Attachments --> <!-- Attachments -->
{#if $tickets.attachments.length > 0} {#if $tickets.attachments.length > 0}
<div class="card"> <div class="card">
@@ -261,14 +279,18 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2"> <h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<span>Archivos Adjuntos</span> <span>Archivos Adjuntos</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> <span
{$tickets.attachments.length} archivo{$tickets.attachments.length !== 1 ? 's' : ''} class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{$tickets.attachments.length} archivo{$tickets.attachments.length !== 1
? 's'
: ''}
</span> </span>
</h3> </h3>
<button <button
class="text-sm text-gray-500 hover:text-gray-700" class="text-sm text-gray-500 hover:text-gray-700"
title="Ver/Ocultar archivos adjuntos" title="Ver/Ocultar archivos adjuntos"
on:click={() => showAttachments = !showAttachments} on:click={() => (showAttachments = !showAttachments)}
> >
{showAttachments ? 'Ocultar' : 'Ver'} archivos {showAttachments ? 'Ocultar' : 'Ver'} archivos
</button> </button>
@@ -278,11 +300,23 @@
<div class="card-content"> <div class="card-content">
<div class="space-y-3"> <div class="space-y-3">
{#each $tickets.attachments as attachment} {#each $tickets.attachments as attachment}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"> <div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center"> <div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /> class="w-4 h-4 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg> </svg>
</div> </div>
<div> <div>
@@ -290,22 +324,26 @@
{attachment.original_filename} {attachment.original_filename}
</p> </p>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB • {Math.round(attachment.size_bytes / 1024)} KB • Subido por {attachment.uploaded_by_name}
Subido por {attachment.uploaded_by_name}
{formatDate(attachment.uploaded_at)} {formatDate(attachment.uploaded_at)}
</p> </p>
</div> </div>
</div> </div>
<a <button
href="/api/v1/tickets/{ticketId}/attachments/{attachment.id}/download" on:click={() => handleDownloadAttachment(attachment)}
class="btn-ghost p-2 hover:bg-blue-100 rounded-md transition-colors" class="btn-ghost p-2 hover:bg-blue-100 rounded-md transition-colors"
target="_blank"
title="Descargar archivo" title="Descargar archivo"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
</a> </button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -313,7 +351,7 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Comments --> <!-- Comments -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -327,11 +365,18 @@
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
{#each $tickets.comments as comment} {#each $tickets.comments as comment}
{console.log('Comment:', comment)} {console.log('Comment:', comment)}
<div class="flex space-x-3"> <div class="flex space-x-3">
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0"> <div
class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<span class="text-primary-600 text-xs font-medium"> <span class="text-primary-600 text-xs font-medium">
{comment.author_name ? comment.author_name.split(' ').map(n => n[0]).join('') : '??'} {comment.author_name
? comment.author_name
.split(' ')
.map(n => n[0])
.join('')
: '??'}
</span> </span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -356,7 +401,7 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Add Comment Form --> <!-- Add Comment Form -->
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="mt-6 pt-6 border-t border-gray-200">
<div class="space-y-4"> <div class="space-y-4">
@@ -366,8 +411,8 @@
placeholder="Escribe tu comentario o respuesta..." placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment} bind:value={newComment}
disabled={isSubmittingComment} disabled={isSubmittingComment}
></textarea> />
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<input <input
@@ -385,16 +430,21 @@
disabled={isUploading} disabled={isUploading}
> >
{#if isUploading} {#if isUploading}
<div class="spinner w-4 h-4"></div> <div class="spinner w-4 h-4" />
{:else} {:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg> </svg>
{/if} {/if}
<span class="text-sm">Adjuntar archivo</span> <span class="text-sm">Adjuntar archivo</span>
</button> </button>
</div> </div>
<button <button
on:click={handleAddComment} on:click={handleAddComment}
class="btn-primary px-4 py-2" class="btn-primary px-4 py-2"
@@ -402,7 +452,7 @@
> >
{#if isSubmittingComment} {#if isSubmittingComment}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div> <div class="spinner w-4 h-4" />
<span>Enviando...</span> <span>Enviando...</span>
</div> </div>
{:else} {:else}
@@ -415,7 +465,7 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Ticket Info --> <!-- Ticket Info -->
@@ -426,35 +476,44 @@
<div class="card-content space-y-4"> <div class="card-content space-y-4">
<div> <div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt> <dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">#{$tickets.currentTicket.id.substring(0, 8)}</dd> <dd class="text-sm text-gray-900 font-mono">
#{$tickets.currentTicket.id.substring(0, 8)}
</dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Categoría</dt> <dt class="text-sm font-medium text-gray-500">Categoría</dt>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.category_name || 'Sin categoría'}</dd> <dd class="text-sm text-gray-900">
{$tickets.currentTicket.category_name || 'Sin categoría'}
</dd>
</div> </div>
{#if $tickets.currentTicket.assigned_to_name} {#if $tickets.currentTicket.assigned_to_name}
<div> <div>
<dt class="text-sm font-medium text-gray-500">Asignado a</dt> <dt class="text-sm font-medium text-gray-500">Asignado a</dt>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.assigned_to_name}</dd> <dd class="text-sm text-gray-900">{$tickets.currentTicket.assigned_to_name}</dd>
</div> </div>
{/if} {/if}
<div> <div>
<dt class="text-sm font-medium text-gray-500">Creado</dt> <dt class="text-sm font-medium text-gray-500">Creado</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.created_at)}</dd> <dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.created_at)}</dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Última actualización</dt> <dt class="text-sm font-medium text-gray-500">Última actualización</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd> <dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd>
</div> </div>
{#if $tickets.currentTicket.due_date} {#if $tickets.currentTicket.due_date}
<div> <div>
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt> <dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
<dd class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) < new Date() ? 'text-red-600' : ''}"> <dd
class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) <
new Date()
? 'text-red-600'
: ''}"
>
{formatDate($tickets.currentTicket.due_date)} {formatDate($tickets.currentTicket.due_date)}
{#if new Date($tickets.currentTicket.due_date) < new Date()} {#if new Date($tickets.currentTicket.due_date) < new Date()}
<span class="block text-xs text-red-500">¡Vencido!</span> <span class="block text-xs text-red-500">¡Vencido!</span>
@@ -472,29 +531,50 @@
<!-- Close Ticket Dialog --> <!-- Close Ticket Dialog -->
{#if showCloseDialog} {#if showCloseDialog}
<div class="fixed inset-0 z-50 overflow-y-auto"> <div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div
<div class="fixed inset-0 transition-opacity" role="dialog" tabindex="0" on:click={cancelCloseTicket} on:keydown={(e) => e.key === 'Escape' && cancelCloseTicket()}> class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
<div class="absolute inset-0 bg-gray-500 opacity-75"></div> >
<div
class="fixed inset-0 transition-opacity"
role="dialog"
tabindex="0"
on:click={cancelCloseTicket}
on:keydown={e => e.key === 'Escape' && cancelCloseTicket()}
>
<div class="absolute inset-0 bg-gray-500 opacity-75" />
</div> </div>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10"> <div
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> >
<svg
class="h-6 w-6 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> </svg>
</div> </div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900"> <h3 class="text-lg leading-6 font-medium text-gray-900">Cerrar Ticket</h3>
Cerrar Ticket
</h3>
<div class="mt-2"> <div class="mt-2">
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el problema ha sido resuelto satisfactoriamente. ¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el
problema ha sido resuelto satisfactoriamente.
</p> </p>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<label for="close-resolution" class="form-label"> <label for="close-resolution" class="form-label">
Comentario de cierre (opcional) Comentario de cierre (opcional)
@@ -506,7 +586,7 @@
placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..." placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..."
bind:value={closeResolution} bind:value={closeResolution}
disabled={isClosingTicket} disabled={isClosingTicket}
></textarea> />
</div> </div>
</div> </div>
</div> </div>
@@ -520,7 +600,7 @@
> >
{#if isClosingTicket} {#if isClosingTicket}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div> <div class="spinner w-4 h-4" />
<span>Cerrando...</span> <span>Cerrando...</span>
</div> </div>
{:else} {:else}
@@ -539,4 +619,4 @@
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
export let toggleSidebar: () => void; export let toggleSidebar: () => void;
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
} }
let isMenuOpen = false; let isMenuOpen = false;
function toggleMenu() { function toggleMenu() {
isMenuOpen = !isMenuOpen; isMenuOpen = !isMenuOpen;
} }
@@ -23,15 +23,20 @@
class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 lg:hidden" class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 lg:hidden"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg> </svg>
</button> </button>
<div class="hidden lg:block"> <div class="hidden lg:block">
<h1 class="text-lg font-semibold text-gray-900">ServiceManager Internal</h1> <h1 class="text-lg font-semibold text-gray-900">ServiceManager Internal</h1>
</div> </div>
</div> </div>
<!-- Right side --> <!-- Right side -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- User menu --> <!-- User menu -->
@@ -46,15 +51,23 @@
</span> </span>
</div> </div>
<span class="hidden sm:block text-sm"> <span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name} {$auth.user?.first_name}
{$auth.user?.last_name}
</span> </span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M19 9l-7 7-7-7" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
{#if isMenuOpen} {#if isMenuOpen}
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50"> <div
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50"
>
<div class="py-1"> <div class="py-1">
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200"> <div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email} {$auth.user?.email}
@@ -62,7 +75,7 @@
<a <a
href="/profile" href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => isMenuOpen = false} on:click={() => (isMenuOpen = false)}
> >
Mi Perfil Mi Perfil
</a> </a>
@@ -82,11 +95,11 @@
<!-- Backdrop for mobile menu --> <!-- Backdrop for mobile menu -->
{#if isMenuOpen} {#if isMenuOpen}
<div <div
class="fixed inset-0 z-40 lg:hidden" class="fixed inset-0 z-40 lg:hidden"
role="dialog" role="dialog"
tabindex="0" tabindex="0"
on:click={() => isMenuOpen = false} on:click={() => (isMenuOpen = false)}
on:keydown={(e) => e.key === 'Escape' && (isMenuOpen = false)} on:keydown={e => e.key === 'Escape' && (isMenuOpen = false)}
></div> />
{/if} {/if}

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { auth } from '$lib/stores/auth.js';
export let open = false; export let open = false;
// Navigation items based on user role // Navigation items based on user role
$: navigation = getNavigationForRole($auth.user?.role); $: navigation = getNavigationForRole($auth.user?.role);
function getNavigationForRole(role: string | undefined) { function getNavigationForRole(role: string | undefined) {
const baseNavigation = [ const baseNavigation = [
{ {
@@ -20,7 +20,7 @@
icon: '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' icon: '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'
} }
]; ];
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') { if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
baseNavigation.push( baseNavigation.push(
{ {
@@ -45,7 +45,7 @@
} }
); );
} }
if (role === 'ADMIN') { if (role === 'ADMIN') {
baseNavigation.push( baseNavigation.push(
{ {
@@ -65,32 +65,51 @@
} }
); );
} }
return baseNavigation; return baseNavigation;
} }
function isCurrentPage(href: string) { function isCurrentPage(href: string) {
return $page.url.pathname === href || return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
($page.url.pathname.startsWith(href) && href !== '/');
} }
</script> </script>
<!-- Mobile sidebar backdrop --> <!-- Mobile sidebar backdrop -->
{#if open} {#if open}
<div class="fixed inset-0 z-40 lg:hidden"> <div class="fixed inset-0 z-40 lg:hidden">
<div class="fixed inset-0 bg-gray-600 bg-opacity-75" role="dialog" tabindex="0" on:click={() => open = false} on:keydown={(e) => e.key === 'Escape' && (open = false)}></div> <div
class="fixed inset-0 bg-gray-600 bg-opacity-75"
role="dialog"
tabindex="0"
on:click={() => (open = false)}
on:keydown={e => e.key === 'Escape' && (open = false)}
/>
</div> </div>
{/if} {/if}
<!-- Sidebar --> <!-- Sidebar -->
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform {open ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0"> <div
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform {open
? 'translate-x-0'
: '-translate-x-full'} transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0"
>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<!-- Logo --> <!-- Logo -->
<div class="flex items-center justify-between h-16 px-6 bg-primary-600"> <div class="flex items-center justify-between h-16 px-6 bg-primary-600">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" /> class="w-5 h-5 text-primary-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg> </svg>
</div> </div>
<div class="text-white"> <div class="text-white">
@@ -98,28 +117,33 @@
<p class="text-xs text-primary-100">Panel Interno</p> <p class="text-xs text-primary-100">Panel Interno</p>
</div> </div>
</div> </div>
<button <button
on:click={() => open = false} on:click={() => (open = false)}
class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden" class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
<!-- Navigation --> <!-- Navigation -->
<nav class="flex-1 px-4 py-6 space-y-2"> <nav class="flex-1 px-4 py-6 space-y-2">
{#each navigation as item} {#each navigation as item}
<a <a
href={item.href} href={item.href}
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors { class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {isCurrentPage(
isCurrentPage(item.href) item.href
? 'bg-primary-100 text-primary-700' )
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' ? 'bg-primary-100 text-primary-700'
}" : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}"
on:click={() => open = false} on:click={() => (open = false)}
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
@@ -128,7 +152,7 @@
</a> </a>
{/each} {/each}
</nav> </nav>
<!-- User info --> <!-- User info -->
<div class="px-4 py-4 border-t border-gray-200"> <div class="px-4 py-4 border-t border-gray-200">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
@@ -139,15 +163,20 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate"> <p class="text-sm font-medium text-gray-900 truncate">
{$auth.user?.first_name} {$auth.user?.last_name} {$auth.user?.first_name}
{$auth.user?.last_name}
</p> </p>
<p class="text-xs text-gray-500 truncate"> <p class="text-xs text-gray-500 truncate">
{$auth.user?.role === 'ADMIN' ? 'Administrador' : {$auth.user?.role === 'ADMIN'
$auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' : ? 'Administrador'
$auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'} : $auth.user?.role === 'SUPPORT_MANAGER'
? 'Gerente de Soporte'
: $auth.user?.role === 'AGENT'
? 'Agente'
: 'Auditor'}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -9,14 +9,14 @@ interface RequestOptions extends RequestInit {
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> { async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...init } = options; const { params, ...init } = options;
let url = `${API_BASE}${endpoint}`; let url = `${API_BASE}${endpoint}`;
if (params) { if (params) {
// Filtrar parámetros undefined y null // Filtrar parámetros undefined y null
const filteredParams = Object.entries(params) const filteredParams = Object.entries(params)
.filter(([key, value]) => value !== undefined && value !== null && value !== '') .filter(([key, value]) => value !== undefined && value !== null && value !== '')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
if (Object.keys(filteredParams).length > 0) { if (Object.keys(filteredParams).length > 0) {
const searchParams = new URLSearchParams(filteredParams); const searchParams = new URLSearchParams(filteredParams);
url += `?${searchParams.toString()}`; url += `?${searchParams.toString()}`;
@@ -42,9 +42,9 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
if (response.status === 401) { if (response.status === 401) {
// Token expired or invalid // Token expired or invalid
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token'); localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user'); localStorage.removeItem('internal_auth_user');
window.location.href = '/login'; window.location.href = '/login';
} }
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
@@ -56,25 +56,68 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
// Handle empty responses (like 204 No Content) // Handle empty responses (like 204 No Content)
if (response.status === 204) { if (response.status === 204) {
return {} as T; return {} as T;
} }
return response.json(); return response.json();
} }
async function downloadFile(endpoint: string, filename: string): Promise<void> {
const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
const headers = new Headers();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET',
headers
});
if (response.status === 401) {
if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user');
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Download error: ${response.statusText}`);
}
// Crear blob y descargar
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
export const api = { export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) => get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }), request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, body: any) => post: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'POST', body: JSON.stringify(body) }), request<T>(endpoint, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(endpoint: string, body: any) => put: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'PUT', body: JSON.stringify(body) }), request<T>(endpoint, { method: 'PUT', body: JSON.stringify(body) }),
patch: <T>(endpoint: string, body: any) => patch: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }), request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }),
delete: <T>(endpoint: string) => delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }) request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename)
}; };

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/utils/api'; import { page } from '$app/stores';
import { toast } from '$lib/stores/toast'; import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { onMount } from 'svelte';
let ticketId: string; let ticketId: string;
let ticket = null; let ticket = null;
let comments = []; let comments = [];
let attachments = [];
let users = []; let users = [];
let isLoading = false; let isLoading = false;
let newComment = ''; let newComment = '';
@@ -31,25 +32,27 @@
]; ];
async function loadComments() { async function loadComments() {
try { try {
const commentsData = await api.get(`/tickets/${ticketId}/comments`); const commentsData = await api.get(`/tickets/${ticketId}/comments`);
comments = commentsData; comments = commentsData;
} catch (e) { } catch (e) {
// No mostrar error en polling silencioso // No mostrar error en polling silencioso
console.error('Error recargando comentarios:', e); console.error('Error recargando comentarios:', e);
}
} }
}
async function loadData() { async function loadData() {
isLoading = true; isLoading = true;
try { try {
const [ticketData, commentsData, usersData] = await Promise.all([ const [ticketData, commentsData, attachmentsData, usersData] = await Promise.all([
api.get(`/tickets/${ticketId}`), api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`), api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/') api.get('/users/')
]); ]);
ticket = ticketData; ticket = ticketData;
comments = commentsData; comments = commentsData;
attachments = attachmentsData;
users = usersData; users = usersData;
} catch (e) { } catch (e) {
toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido')); toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido'));
@@ -105,30 +108,42 @@
}); });
} }
async function handleDownloadAttachment(attachment: any) {
try {
await api.downloadFile(
`/tickets/${ticketId}/attachments/${attachment.id}/download`,
attachment.original_filename
);
toast.success('Descarga iniciada');
} catch (error) {
console.error('Download error:', error);
toast.error('Error al descargar el archivo');
}
}
onMount(() => { onMount(() => {
ticketId = $page.params.id; ticketId = $page.params.id;
if (ticketId) { if (ticketId) {
loadData(); loadData();
// Polling cada 3 segundos // Polling cada 3 segundos
pollingInterval = setInterval(() => { pollingInterval = setInterval(() => {
loadComments(); loadComments();
}, 3000); }, 3000);
}
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
} }
};
});
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
});
</script> </script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
{#if isLoading} {#if isLoading}
<div class="text-center py-12"> <div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div> <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
<p class="mt-2 text-gray-600">Cargando ticket...</p> <p class="mt-2 text-gray-600">Cargando ticket...</p>
</div> </div>
{:else if ticket} {:else if ticket}
@@ -153,10 +168,18 @@
{ticket.subject || ticket.title} {ticket.subject || ticket.title}
</h1> </h1>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<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"> <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} {getStatusBadge(ticket.status).label}
</span> </span>
<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"> <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} {getPriorityBadge(ticket.priority).label}
</span> </span>
<span class="text-sm text-gray-500"> <span class="text-sm text-gray-500">
@@ -164,7 +187,7 @@
</span> </span>
</div> </div>
</div> </div>
<button <button
on:click={() => goto('/tickets')} on:click={() => goto('/tickets')}
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
@@ -183,6 +206,74 @@
</div> </div>
</div> </div>
<!-- Attachments Section -->
{#if attachments && attachments.length > 0}
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">
Archivos Adjuntos ({attachments.length})
</h3>
</div>
<div class="px-6 py-5">
<div class="space-y-2">
{#each attachments as attachment}
<div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-3">
<div
class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0"
>
<svg
class="w-5 h-5 text-indigo-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{attachment.original_filename}
</p>
<p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB
{#if attachment.uploaded_by_name}
• Subido por {attachment.uploaded_by_name}
{/if}
{#if attachment.uploaded_at}
{formatDate(attachment.uploaded_at)}
{/if}
</p>
</div>
</div>
<button
on:click={() => handleDownloadAttachment(attachment)}
class="inline-flex items-center p-2 text-sm font-medium text-indigo-600 hover:bg-indigo-50 rounded-md transition-colors"
title="Descargar {attachment.original_filename}"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Comments Section --> <!-- Comments Section -->
<div class="bg-white shadow rounded-lg"> <div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200"> <div class="px-6 py-5 border-b border-gray-200">
@@ -197,9 +288,16 @@
<div class="space-y-4"> <div class="space-y-4">
{#each comments as comment} {#each comments as comment}
<div class="flex space-x-3"> <div class="flex space-x-3">
<div class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0"> <div
class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<span class="text-indigo-600 text-xs font-medium"> <span class="text-indigo-600 text-xs font-medium">
{comment.author_name ? comment.author_name.split(' ').map(n => n[0]).join('') : '??'} {comment.author_name
? comment.author_name
.split(' ')
.map(n => n[0])
.join('')
: '??'}
</span> </span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -234,7 +332,7 @@
placeholder="Escribe tu comentario o respuesta..." placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment} bind:value={newComment}
disabled={isSubmittingComment} disabled={isSubmittingComment}
></textarea> />
<div class="flex justify-end"> <div class="flex justify-end">
<button <button
@@ -244,7 +342,9 @@
> >
{#if isSubmittingComment} {#if isSubmittingComment}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> <div
class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"
/>
<span>Enviando...</span> <span>Enviando...</span>
</div> </div>
{:else} {:else}
@@ -268,7 +368,9 @@
<div class="px-6 py-5 space-y-4"> <div class="px-6 py-5 space-y-4">
<div> <div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt> <dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">#{ticket.ticket_number || ticket.id.substring(0, 8)}</dd> <dd class="text-sm text-gray-900 font-mono">
#{ticket.ticket_number || ticket.id.substring(0, 8)}
</dd>
</div> </div>
<div> <div>
@@ -297,4 +399,4 @@
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>