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
try:
file_path = file_handler.get_file_path(attachment.file_path) 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

@@ -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"
on:click={() => isMenuOpen = false}
></div>
{/if} {/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

@@ -26,59 +26,51 @@
<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>
@@ -93,35 +85,49 @@
<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,11 +150,17 @@
</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>

View File

@@ -62,36 +62,41 @@
} }
</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
src="/images/Logo%20AS%20blanco(1).png"
alt="AduanaSoft"
class="h-32 w-auto object-contain self-start drop-shadow-lg"
/>
</div> </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
reciba asistencia especializada para garantizar la continuidad de su operación.
</p> </p>
</div> </div>
@@ -103,8 +108,9 @@
</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">
@@ -115,7 +121,9 @@
<!-- Form --> <!-- Form -->
<form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8"> <form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8">
{#if errorMessage} {#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"> <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" /> <Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
{errorMessage} {errorMessage}
</div> </div>
@@ -125,10 +133,15 @@
<div class="space-y-5"> <div class="space-y-5">
<!-- Email Input --> <!-- Email Input -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700">Correo Electrónico</label> <label for="email" class="block text-sm font-semibold text-gray-700"
>Correo Electrónico</label
>
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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" /> <Icon
name="mail"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div> </div>
<input <input
id="email" id="email"
@@ -145,10 +158,15 @@
<!-- Password Input --> <!-- Password Input -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700">Contraseña</label> <label for="password" class="block text-sm font-semibold text-gray-700"
>Contraseña</label
>
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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" /> <Icon
name="lock"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div> </div>
{#if showPassword} {#if showPassword}
<input <input
@@ -176,7 +194,7 @@
<button <button
type="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" 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} on:click={() => (showPassword = !showPassword)}
> >
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" /> <Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
</button> </button>
@@ -185,19 +203,32 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <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"> <input
<label for="remember-me" class="ml-2 block text-sm text-gray-500 cursor-pointer select-none">Recordar en este equipo</label> 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> </div>
<a href="/forgot-password" class="text-sm font-medium text-blue-600 hover:text-blue-500"> <a
href="/forgot-password"
class="text-sm font-medium text-blue-600 hover:text-blue-500"
>
Olvide mi clave Olvide mi clave
</a> </a>
</div> </div>
</div> </div>
{:else} {:else}
<!-- 2FA Input --> <!-- 2FA Input -->
<div class="space-y-4 animate-slide-up"> <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> <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> <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="relative">

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" />

View File

@@ -24,7 +24,7 @@
// 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
@@ -49,7 +49,7 @@
clearInterval(pollingInterval); clearInterval(pollingInterval);
} }
}; };
}); });
// Format date // Format date
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
@@ -109,8 +109,13 @@
// 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'
@@ -134,6 +139,16 @@
} }
} }
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;
} }
@@ -158,7 +173,8 @@
} }
// 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>
@@ -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>
@@ -329,9 +367,16 @@
{#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">
@@ -366,7 +411,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-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">
@@ -385,10 +430,15 @@
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>
@@ -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}
@@ -426,12 +476,16 @@
<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}
@@ -454,7 +508,12 @@
{#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,26 +531,47 @@
<!-- 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>
@@ -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}

View File

@@ -23,7 +23,12 @@
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>
@@ -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>
@@ -86,7 +99,7 @@
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,6 +1,6 @@
<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;
@@ -70,27 +70,46 @@
} }
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">
@@ -100,11 +119,16 @@
</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>
@@ -114,12 +138,12 @@
{#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' ? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' : '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} />
@@ -139,12 +163,17 @@
</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>

View File

@@ -62,6 +62,46 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
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 }),
@@ -76,5 +116,8 @@ export const api = {
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 = '';
@@ -38,18 +39,20 @@
// 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,6 +108,19 @@
}); });
} }
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) {
@@ -121,14 +137,13 @@
clearInterval(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">
@@ -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>