fix(frontend): Fix client API calls and improve profile UI

- Fixed proxy configuration in vite.config.js (servicemanager-backend -> backend)
- Added X-Tenant-ID header to client-profile API calls
- Improved error handling with proper authentication checks
- Updated profile page UI to white theme (removed icons and colors)
- Changed all btn-primary buttons to white theme styling
- Enhanced API call error handling in tickets store
This commit is contained in:
2026-02-09 13:28:16 -07:00
parent d4ff32dac7
commit ac0cb6f132
3 changed files with 121 additions and 90 deletions

View File

@@ -1,8 +1,14 @@
import { writable, get } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth'; import { auth } from './auth';
// Types // Types
interface FastAPIValidationError {
loc: (string | number)[];
msg: string;
type: string;
}
export interface Ticket { export interface Ticket {
id: string; id: string;
title: string; title: string;
@@ -73,12 +79,17 @@ const initialState: TicketsState = {
// API helper function // API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) { async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth); const authState = get(auth);
if (!authState.token || !authState.user) {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1${endpoint}`, { const response = await fetch(`/api/v1${endpoint}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`, 'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.user.tenant_id,
...options.headers ...options.headers
} }
}); });
@@ -88,12 +99,12 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
try { try {
const error = await response.json(); const error = await response.json();
console.error('❌ API Error Response:', error); console.error('❌ API Error Response:', error);
// Manejar diferentes formatos de error de FastAPI // Manejar diferentes formatos de error de FastAPI
if (error.detail) { if (error.detail) {
if (Array.isArray(error.detail)) { if (Array.isArray(error.detail)) {
// Errores de validación de FastAPI // Errores de validación de FastAPI
errorMessage = error.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', '); errorMessage = error.detail.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`).join(', ');
} else if (typeof error.detail === 'string') { } else if (typeof error.detail === 'string') {
errorMessage = error.detail; errorMessage = error.detail;
} else { } else {
@@ -105,7 +116,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
} catch (e) { } catch (e) {
errorMessage = `HTTP ${response.status}: ${response.statusText}`; errorMessage = `HTTP ${response.status}: ${response.statusText}`;
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }
@@ -122,15 +133,15 @@ function createTicketsStore() {
// Load user's tickets // Load user's tickets
loadTickets: async () => { loadTickets: async () => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null })); update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try { try {
const tickets = await apiCall('/tickets/'); const tickets = await apiCall('/tickets/');
update((state: TicketsState) => ({ ...state, tickets, isLoading: false })); update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets' error: error instanceof Error ? error.message : 'Failed to load tickets'
})); }));
} }
}, },
@@ -138,7 +149,7 @@ function createTicketsStore() {
// Load specific ticket with details // Load specific ticket with details
loadTicket: async (ticketId: string) => { loadTicket: async (ticketId: string) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null })); update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try { try {
const [ticket, comments, attachments] = await Promise.all([ const [ticket, comments, attachments] = await Promise.all([
apiCall(`/tickets/${ticketId}`), apiCall(`/tickets/${ticketId}`),
@@ -146,18 +157,18 @@ function createTicketsStore() {
apiCall(`/tickets/${ticketId}/attachments`) apiCall(`/tickets/${ticketId}/attachments`)
]); ]);
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
currentTicket: ticket, currentTicket: ticket,
comments, comments,
attachments, attachments,
isLoading: false isLoading: false
})); }));
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket' error: error instanceof Error ? error.message : 'Failed to load ticket'
})); }));
} }
}, },
@@ -176,38 +187,38 @@ function createTicketsStore() {
// Create new ticket // Create new ticket
createTicket: async (ticket: CreateTicketRequest) => { createTicket: async (ticket: CreateTicketRequest) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null })); update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try { try {
// Mapear campos del frontend al formato del backend // Mapear campos del frontend al formato del backend
const ticketData = { const ticketData = {
subject: ticket.title, // ← Backend espera "subject" no "title" subject: ticket.title, // ← Backend espera "subject" no "title"
description: ticket.description, description: ticket.description,
category_id: ticket.category_id, category_id: ticket.category_id,
priority: ticket.priority, priority: ticket.priority,
system_id: null // ← Opcional system_id: null // ← Opcional
}; };
console.log('Sending ticket data:', ticketData);
console.log('Sending ticket data:', ticketData);
const newTicket = await apiCall('/tickets/', { const newTicket = await apiCall('/tickets/', {
method: 'POST', method: 'POST',
body: JSON.stringify(ticketData) body: JSON.stringify(ticketData)
}); });
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
tickets: [newTicket, ...state.tickets], tickets: [newTicket, ...state.tickets],
isLoading: false isLoading: false
})); }));
return newTicket; return newTicket;
} catch (error) { } catch (error) {
console.error('Create ticket error:', error); console.error('Create ticket error:', error);
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket' error: error instanceof Error ? error.message : 'Failed to create ticket'
})); }));
throw error; throw error;
} }
@@ -221,16 +232,16 @@ function createTicketsStore() {
body: JSON.stringify({ content }) body: JSON.stringify({ content })
}); });
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
comments: [...state.comments, comment] comments: [...state.comments, comment]
})); }));
return comment; return comment;
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
error: error instanceof Error ? error.message : 'Failed to add comment' error: error instanceof Error ? error.message : 'Failed to add comment'
})); }));
throw error; throw error;
} }
@@ -243,10 +254,16 @@ function createTicketsStore() {
formData.append('file', file); formData.append('file', file);
const authState = get(auth); const authState = get(auth);
if (!authState.token || !authState.user) {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, { const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${authState.token}` 'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.user.tenant_id
}, },
body: formData body: formData
}); });
@@ -256,18 +273,19 @@ function createTicketsStore() {
throw new Error(error.detail || 'Upload failed'); throw new Error(error.detail || 'Upload failed');
} }
const attachment = await response.json(); const result = await response.json();
const attachment = result.data || result;
update((state: TicketsState) => ({
...state, update((state: TicketsState) => ({
attachments: [...state.attachments, attachment] ...state,
attachments: [...state.attachments, attachment]
})); }));
return attachment; return attachment;
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment' error: error instanceof Error ? error.message : 'Failed to upload attachment'
})); }));
throw error; throw error;
} }
@@ -289,9 +307,9 @@ function createTicketsStore() {
return updatedTicket; return updatedTicket;
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
error: error instanceof Error ? error.message : 'Failed to close ticket' error: error instanceof Error ? error.message : 'Failed to close ticket'
})); }));
throw error; throw error;
} }
@@ -304,11 +322,11 @@ function createTicketsStore() {
// Clear current ticket // Clear current ticket
clearCurrentTicket: () => { clearCurrentTicket: () => {
update((state: TicketsState) => ({ update((state: TicketsState) => ({
...state, ...state,
currentTicket: null, currentTicket: null,
comments: [], comments: [],
attachments: [] attachments: []
})); }));
} }
}; };

View File

@@ -77,9 +77,15 @@
async function loadBusinessProfile() { async function loadBusinessProfile() {
try { try {
if (!$auth.token || !$auth.user) {
console.warn('Usuario no autenticado');
return;
}
const response = await fetch('/api/v1/client-profile/', { const response = await fetch('/api/v1/client-profile/', {
headers: { headers: {
Authorization: `Bearer ${$auth.token}` Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id
} }
}); });
@@ -91,6 +97,12 @@
businessProfile[key] = profile[key]; businessProfile[key] = profile[key];
} }
}); });
} else if (response.status === 404) {
// No hay perfil aún, esto es normal para nuevos clientes
console.info('No se encontró perfil empresarial existente');
} else {
const error = await response.json().catch(() => ({ detail: 'Error desconocido' }));
console.error('Error al cargar perfil empresarial:', error);
} }
} catch (error) { } catch (error) {
console.warn('No se pudo cargar el perfil empresarial:', error); console.warn('No se pudo cargar el perfil empresarial:', error);
@@ -261,7 +273,8 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}` Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id
}, },
body: JSON.stringify(profileData) body: JSON.stringify(profileData)
}); });
@@ -305,51 +318,51 @@
<button <button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab === class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'personal' 'personal'
? 'border-primary-500 text-primary-600' ? 'border-white text-white'
: ''}" : ''}"
on:click={() => (activeTab = 'personal')} on:click={() => (activeTab = 'personal')}
> >
👤 Personal Personal
</button> </button>
<button <button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab === class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'general' 'general'
? 'border-primary-500 text-primary-600' ? 'border-white text-white'
: ''}" : ''}"
on:click={() => (activeTab = 'general')} on:click={() => (activeTab = 'general')}
> >
🏢 General General
</button> </button>
<button <button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab === class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'contact' 'contact'
? 'border-primary-500 text-primary-600' ? 'border-white text-white'
: ''}" : ''}"
on:click={() => (activeTab = 'contact')} on:click={() => (activeTab = 'contact')}
> >
📞 Contacto Contacto
</button> </button>
<button <button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab === class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'security' 'security'
? 'border-primary-500 text-primary-600' ? 'border-white text-white'
: ''}" : ''}"
on:click={() => (activeTab = 'security')} on:click={() => (activeTab = 'security')}
> >
🔐 Seguridad Seguridad
</button> </button>
<button <button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab === class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'account' 'account'
? 'border-primary-500 text-primary-600' ? 'border-white text-white'
: ''}" : ''}"
on:click={() => (activeTab = 'account')} on:click={() => (activeTab = 'account')}
> >
Cuenta Cuenta
</button> </button>
</nav> </nav>
</div> </div>
@@ -417,7 +430,7 @@
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="btn-primary px-6 py-2" 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" />
@@ -517,7 +530,7 @@
</div> </div>
<!-- Ubicación --> <!-- Ubicación -->
<div class="bg-blue-50 p-4 rounded-lg"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">Ubicación</h3> <h3 class="font-medium text-gray-900 mb-4">Ubicación</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div> <div>
@@ -602,7 +615,7 @@
</div> </div>
<!-- Representantes --> <!-- Representantes -->
<div class="bg-purple-50 p-4 rounded-lg"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<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>
@@ -628,7 +641,7 @@
</div> </div>
<!-- Configuración --> <!-- Configuración -->
<div class="bg-green-50 p-4 rounded-lg"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">Configuración</h3> <h3 class="font-medium text-gray-900 mb-4">Configuración</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>
@@ -680,7 +693,7 @@
<div class="flex justify-end pt-6"> <div class="flex justify-end pt-6">
<button <button
type="submit" type="submit"
class="btn-primary px-8 py-2" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-8 py-2 rounded-md font-medium transition-colors"
disabled={isSavingBusinessProfile} disabled={isSavingBusinessProfile}
> >
{#if isSavingBusinessProfile} {#if isSavingBusinessProfile}
@@ -689,7 +702,7 @@
<span>Guardando...</span> <span>Guardando...</span>
</div> </div>
{:else} {:else}
💾 Guardar Perfil Empresarial Guardar Perfil Empresarial
{/if} {/if}
</button> </button>
</div> </div>
@@ -709,8 +722,8 @@
<div class="card-content"> <div class="card-content">
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6"> <form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
<!-- Teléfonos --> <!-- Teléfonos -->
<div class="bg-blue-50 p-4 rounded-lg"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">📞 Teléfonos</h3> <h3 class="font-medium text-gray-900 mb-4">Teléfonos</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">Teléfono Principal</label> <label class="form-label">Teléfono Principal</label>
@@ -766,8 +779,8 @@
</div> </div>
<!-- Emails --> <!-- Emails -->
<div class="bg-green-50 p-4 rounded-lg"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">✉️ Correos Electrónicos</h3> <h3 class="font-medium text-gray-900 mb-4">Correos Electrónicos</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">Email Principal</label> <label class="form-label">Email Principal</label>
@@ -798,8 +811,8 @@
</div> </div>
<!-- Web y Horarios --> <!-- Web y Horarios -->
<div class="bg-purple-50 p-4 rounded-lg"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">🌐 Web y Horarios</h3> <h3 class="font-medium text-gray-900 mb-4">Web y Horarios</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">Página Web</label> <label class="form-label">Página Web</label>
@@ -836,7 +849,7 @@
<div class="flex justify-end pt-6"> <div class="flex justify-end pt-6">
<button <button
type="submit" type="submit"
class="btn-primary px-8 py-2" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-8 py-2 rounded-md font-medium transition-colors"
disabled={isSavingBusinessProfile} disabled={isSavingBusinessProfile}
> >
{#if isSavingBusinessProfile} {#if isSavingBusinessProfile}
@@ -845,7 +858,7 @@
<span>Guardando...</span> <span>Guardando...</span>
</div> </div>
{:else} {:else}
💾 Guardar Información de Contacto Guardar Información de Contacto
{/if} {/if}
</button> </button>
</div> </div>
@@ -959,7 +972,7 @@
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="btn-primary px-6 py-2" 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

@@ -8,7 +8,7 @@ export default defineConfig({
host: '0.0.0.0', host: '0.0.0.0',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://servicemanager-backend:8000', target: 'http://backend:8000',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
} }