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

@@ -1,14 +1,14 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
export let toggleSidebar: () => void;
function handleLogout() {
auth.logout();
}
let isMenuOpen = false;
function toggleMenu() {
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"
>
<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>
</button>
<div class="hidden lg:block">
<h1 class="text-lg font-semibold text-gray-900">ServiceManager Internal</h1>
</div>
</div>
<!-- Right side -->
<div class="flex items-center space-x-4">
<!-- User menu -->
@@ -46,15 +51,23 @@
</span>
</div>
<span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name}
{$auth.user?.first_name}
{$auth.user?.last_name}
</span>
<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>
</button>
{#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="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email}
@@ -62,7 +75,7 @@
<a
href="/profile"
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
</a>
@@ -82,11 +95,11 @@
<!-- Backdrop for mobile menu -->
{#if isMenuOpen}
<div
class="fixed inset-0 z-40 lg:hidden"
role="dialog"
tabindex="0"
on:click={() => isMenuOpen = false}
on:keydown={(e) => e.key === 'Escape' && (isMenuOpen = false)}
></div>
{/if}
<div
class="fixed inset-0 z-40 lg:hidden"
role="dialog"
tabindex="0"
on:click={() => (isMenuOpen = false)}
on:keydown={e => e.key === 'Escape' && (isMenuOpen = false)}
/>
{/if}

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { page } from '$app/stores';
import { auth } from '$lib/stores/auth.js';
export let open = false;
// Navigation items based on user role
$: navigation = getNavigationForRole($auth.user?.role);
function getNavigationForRole(role: string | undefined) {
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'
}
];
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
baseNavigation.push(
{
@@ -45,7 +45,7 @@
}
);
}
if (role === 'ADMIN') {
baseNavigation.push(
{
@@ -65,32 +65,51 @@
}
);
}
return baseNavigation;
}
function isCurrentPage(href: string) {
return $page.url.pathname === href ||
($page.url.pathname.startsWith(href) && href !== '/');
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
}
</script>
<!-- Mobile sidebar backdrop -->
{#if open}
<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>
{/if}
<!-- 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">
<!-- Logo -->
<div class="flex items-center justify-between h-16 px-6 bg-primary-600">
<div class="flex items-center space-x-2">
<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">
<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
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>
</div>
<div class="text-white">
@@ -98,28 +117,33 @@
<p class="text-xs text-primary-100">Panel Interno</p>
</div>
</div>
<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"
>
<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>
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 px-4 py-6 space-y-2">
{#each navigation as item}
<a
href={item.href}
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {
isCurrentPage(item.href)
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}"
on:click={() => open = false}
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {isCurrentPage(
item.href
)
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}"
on:click={() => (open = false)}
>
<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} />
@@ -128,7 +152,7 @@
</a>
{/each}
</nav>
<!-- User info -->
<div class="px-4 py-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
@@ -139,15 +163,20 @@
</div>
<div class="flex-1 min-w-0">
<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 class="text-xs text-gray-500 truncate">
{$auth.user?.role === 'ADMIN' ? 'Administrador' :
$auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
$auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'}
{$auth.user?.role === 'ADMIN'
? 'Administrador'
: $auth.user?.role === 'SUPPORT_MANAGER'
? 'Gerente de Soporte'
: $auth.user?.role === 'AGENT'
? 'Agente'
: 'Auditor'}
</p>
</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> {
const { params, ...init } = options;
let url = `${API_BASE}${endpoint}`;
if (params) {
// Filtrar parámetros undefined y null
const filteredParams = Object.entries(params)
.filter(([key, value]) => value !== undefined && value !== null && value !== '')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
if (Object.keys(filteredParams).length > 0) {
const searchParams = new URLSearchParams(filteredParams);
url += `?${searchParams.toString()}`;
@@ -42,9 +42,9 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
if (response.status === 401) {
// Token expired or invalid
if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user');
window.location.href = '/login';
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user');
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
@@ -56,25 +56,68 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
// Handle empty responses (like 204 No Content)
if (response.status === 204) {
return {} as T;
return {} as T;
}
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 = {
get: <T>(endpoint: string, params?: Record<string, string>) =>
get: <T>(endpoint: string, params?: Record<string, string>) =>
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) }),
put: <T>(endpoint: string, body: any) =>
put: <T>(endpoint: string, body: any) =>
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) }),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' })
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename)
};

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/utils/api';
import { page } from '$app/stores';
import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { onMount } from 'svelte';
let ticketId: string;
let ticket = null;
let comments = [];
let attachments = [];
let users = [];
let isLoading = false;
let newComment = '';
@@ -31,25 +32,27 @@
];
async function loadComments() {
try {
const commentsData = await api.get(`/tickets/${ticketId}/comments`);
comments = commentsData;
} catch (e) {
// No mostrar error en polling silencioso
console.error('Error recargando comentarios:', e);
try {
const commentsData = await api.get(`/tickets/${ticketId}/comments`);
comments = commentsData;
} catch (e) {
// No mostrar error en polling silencioso
console.error('Error recargando comentarios:', e);
}
}
}
async function loadData() {
isLoading = true;
try {
const [ticketData, commentsData, usersData] = await Promise.all([
const [ticketData, commentsData, attachmentsData, usersData] = await Promise.all([
api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/')
]);
ticket = ticketData;
comments = commentsData;
attachments = attachmentsData;
users = usersData;
} catch (e) {
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(() => {
ticketId = $page.params.id;
if (ticketId) {
loadData();
// Polling cada 3 segundos
pollingInterval = setInterval(() => {
loadComments();
}, 3000);
}
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
// Polling cada 3 segundos
pollingInterval = setInterval(() => {
loadComments();
}, 3000);
}
};
});
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
});
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
{#if isLoading}
<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>
</div>
{:else if ticket}
@@ -153,10 +168,18 @@
{ticket.subject || ticket.title}
</h1>
<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}
</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}
</span>
<span class="text-sm text-gray-500">
@@ -164,7 +187,7 @@
</span>
</div>
</div>
<button
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"
@@ -183,6 +206,74 @@
</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 -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200">
@@ -197,9 +288,16 @@
<div class="space-y-4">
{#each comments as comment}
<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">
{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>
</div>
<div class="flex-1 min-w-0">
@@ -234,7 +332,7 @@
placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment}
disabled={isSubmittingComment}
></textarea>
/>
<div class="flex justify-end">
<button
@@ -244,7 +342,9 @@
>
{#if isSubmittingComment}
<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>
</div>
{:else}
@@ -268,7 +368,9 @@
<div class="px-6 py-5 space-y-4">
<div>
<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>
@@ -297,4 +399,4 @@
</div>
</div>
{/if}
</div>
</div>