Simplificar llamadas a la api, hacer que si se pierde conexion salga de sesion, añadir preview de pdf

This commit is contained in:
Алекс 2026-04-16 18:03:10 +02:00
parent 7f11d34e83
commit 9e93d9c9c6
12 changed files with 600 additions and 210 deletions

View File

@ -12,6 +12,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
// Tracker de cambios // Tracker de cambios
const changeTracker = new FormChangeTracker(); const changeTracker = new FormChangeTracker();
let savedSuccessfully = false; let savedSuccessfully = false;
let activePreviewBlobUrl = null;
// Formatear fecha para input type="date" // Formatear fecha para input type="date"
const formatDateForInput = (dateString) => { const formatDateForInput = (dateString) => {
@ -318,6 +319,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<div class="modal-footer"> <div class="modal-footer">
<div class="footer-actions-left"> <div class="footer-actions-left">
${canDownloadPdf ? '<button type="button" class="btn-preview-invoice btn-cancel"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>Vista previa</button>' : ''}
${canDownloadPdf ? '<button type="button" class="btn-download-invoice btn-primary"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Descargar factura</button>' : ''} ${canDownloadPdf ? '<button type="button" class="btn-download-invoice btn-primary"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Descargar factura</button>' : ''}
${isDraft ? '<button type="button" class="btn-validate btn-success"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><polyline points="20 6 9 17 4 12"/></svg>Validar Factura</button>' : ''} ${isDraft ? '<button type="button" class="btn-validate btn-success"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><polyline points="20 6 9 17 4 12"/></svg>Validar Factura</button>' : ''}
</div> </div>
@ -358,6 +360,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
const validateBtn = modal.querySelector('.btn-validate'); const validateBtn = modal.querySelector('.btn-validate');
validateBtn?.addEventListener('click', handleValidate); validateBtn?.addEventListener('click', handleValidate);
// Botón vista previa de factura PDF
const previewBtn = modal.querySelector('.btn-preview-invoice');
previewBtn?.addEventListener('click', handlePreviewPdf);
// Botón descargar factura PDF // Botón descargar factura PDF
const downloadBtn = modal.querySelector('.btn-download-invoice'); const downloadBtn = modal.querySelector('.btn-download-invoice');
downloadBtn?.addEventListener('click', handleDownloadPdf); downloadBtn?.addEventListener('click', handleDownloadPdf);
@ -428,6 +434,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
} }
} }
if (activePreviewBlobUrl) {
URL.revokeObjectURL(activePreviewBlobUrl);
activePreviewBlobUrl = null;
}
changeTracker.cleanup(); changeTracker.cleanup();
modal.remove(); modal.remove();
if (onClose) onClose(); if (onClose) onClose();
@ -552,6 +563,52 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
} }
}; };
// Manejar vista previa del PDF
const handlePreviewPdf = async () => {
if (!invoice?.number) {
alert('No se puede previsualizar la factura porque no tiene número.');
return;
}
const previewBtn = modal.querySelector('.btn-preview-invoice');
const originalHtml = previewBtn?.innerHTML;
const previewWindow = window.open('', '_blank');
if (!previewWindow) {
alert('El navegador bloqueó la ventana emergente. Permite popups para ver la vista previa.');
return;
}
previewWindow.opener = null;
previewWindow.document.write('<!doctype html><html><head><title>Vista previa factura</title></head><body style="font-family: sans-serif; padding: 1rem;">Cargando vista previa...</body></html>');
previewWindow.document.close();
try {
if (previewBtn) {
previewBtn.disabled = true;
previewBtn.textContent = 'Abriendo...';
}
const pdfBlob = await downloadInvoicePdf(invoice.number);
if (activePreviewBlobUrl) {
URL.revokeObjectURL(activePreviewBlobUrl);
}
activePreviewBlobUrl = URL.createObjectURL(pdfBlob);
previewWindow.location.href = activePreviewBlobUrl;
} catch (error) {
console.error('Error al previsualizar factura:', error);
previewWindow.close();
alert('Error al abrir la vista previa: ' + error.message);
} finally {
if (previewBtn) {
previewBtn.disabled = false;
previewBtn.innerHTML = originalHtml;
}
}
};
// Mostrar/ocultar formulario de añadir línea // Mostrar/ocultar formulario de añadir línea
const toggleAddLineForm = () => { const toggleAddLineForm = () => {
const form = modal.querySelector('.add-line-form'); const form = modal.querySelector('.add-line-form');

View File

@ -1,4 +1,6 @@
import './style.css' import './style.css'
import { initRouter } from './router.js' import { initRouter } from './router.js'
import { initSessionManager } from './services/session.js'
initSessionManager()
initRouter() initRouter()

View File

@ -1,5 +1,6 @@
import { FormChangeTracker } from '../components/ConfirmExitModal.js'; import { FormChangeTracker } from '../components/ConfirmExitModal.js';
import { navigationGuards } from '../router.js'; import { navigationGuards } from '../router.js';
import { apiGet, apiPost } from '../services/apiClient.js';
export function renderCreateInvoicePage() { export function renderCreateInvoicePage() {
const container = document.createElement('div'); const container = document.createElement('div');
@ -77,14 +78,7 @@ export function renderCreateInvoicePage() {
async function loadClients() { async function loadClients() {
try { try {
const token = localStorage.getItem('token'); const clients = await apiGet('/api/Clients?limit=1000');
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Clients?limit=1000`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Error al cargar clientes');
const clients = await response.json();
clientSelect.innerHTML = '<option value="">Seleccionar cliente...</option>'; clientSelect.innerHTML = '<option value="">Seleccionar cliente...</option>';
clients.forEach(client => { clients.forEach(client => {
@ -296,22 +290,7 @@ export function renderCreateInvoicePage() {
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Guardando...'; submitBtn.textContent = 'Guardando...';
const token = localStorage.getItem('token'); const invoiceId = await apiPost('/api/Invoices', formData);
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Invoices`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Error al crear');
}
const invoiceId = await response.json();
// Marcar como guardado exitosamente para evitar el modal de confirmación // Marcar como guardado exitosamente para evitar el modal de confirmación
savedSuccessfully = true; savedSuccessfully = true;

View File

@ -1,5 +1,6 @@
import { auth } from '../services/auth.js'; import { auth } from '../services/auth.js';
import { InvoiceModal } from '../components/InvoiceModal.js'; import { InvoiceModal } from '../components/InvoiceModal.js';
import { apiGet } from '../services/apiClient.js';
import { import {
Chart, Chart,
BarController, BarController,
@ -12,19 +13,8 @@ import {
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend); Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend);
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
function getAuthHeaders() {
const token = localStorage.getItem('token');
return { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
}
async function fetchAllInvoices() { async function fetchAllInvoices() {
const response = await fetch(`${API_BASE_URL}/api/Invoices?limit=500&page=1`, { const data = await apiGet('/api/Invoices?limit=500&page=1');
headers: getAuthHeaders()
});
if (!response.ok) throw new Error('Error al cargar facturas');
const data = await response.json();
return Array.isArray(data) ? data : data.data || data.invoices || []; return Array.isArray(data) ? data : data.data || data.invoices || [];
} }

View File

@ -1,5 +1,6 @@
import { InvoiceItem } from '../components/InvoiceItem.js'; import { InvoiceItem } from '../components/InvoiceItem.js';
import { InvoiceModal } from '../components/InvoiceModal.js'; import { InvoiceModal } from '../components/InvoiceModal.js';
import { apiGet } from '../services/apiClient.js';
export function renderFacturasPage() { export function renderFacturasPage() {
const container = document.createElement('div'); const container = document.createElement('div');
@ -115,10 +116,8 @@ export function renderFacturasPage() {
try { try {
invoicesList.innerHTML = '<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>'; invoicesList.innerHTML = '<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>';
const token = localStorage.getItem('token');
// Construir URL con parámetros // Construir URL con parámetros
let url = `${import.meta.env.VITE_API_BASE_URL}/api/Invoices?limit=1000`; let url = '/api/Invoices?limit=1000';
if (currentFilter) { if (currentFilter) {
url += `&status=${currentFilter}`; url += `&status=${currentFilter}`;
@ -128,19 +127,7 @@ export function renderFacturasPage() {
url += `&search=${encodeURIComponent(searchTerm)}`; url += `&search=${encodeURIComponent(searchTerm)}`;
} }
const response = await fetch(url, { const data = await apiGet(url);
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Error al cargar facturas');
}
const data = await response.json();
// Manejo de respuesta - obtener todas las facturas // Manejo de respuesta - obtener todas las facturas
allInvoices = Array.isArray(data) ? data : data.data || data.invoices || []; allInvoices = Array.isArray(data) ? data : data.data || data.invoices || [];

View File

@ -19,5 +19,15 @@ export function renderLoginPage(onLoginSuccess) {
}); });
container.appendChild(loginComponent); container.appendChild(loginComponent);
const sessionExpiredMessage = sessionStorage.getItem('session-expired-message');
if (sessionExpiredMessage) {
const error = container.querySelector('#login-error');
if (error) {
error.textContent = sessionExpiredMessage;
}
sessionStorage.removeItem('session-expired-message');
}
return container; return container;
} }

178
src/services/apiClient.js Normal file
View File

@ -0,0 +1,178 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
let reloginInProgress = false;
function redirectToLogin() {
if (window.location.hash !== '#login') {
window.location.hash = '#login';
}
}
function showConnectionLostNotice(message) {
if (!document?.body) {
alert(message);
redirectToLogin();
return;
}
const existing = document.querySelector('.connection-lost-overlay');
if (existing) {
return;
}
const overlay = document.createElement('div');
overlay.className = 'connection-lost-overlay';
overlay.innerHTML = `
<div class="connection-lost-modal" role="alertdialog" aria-modal="true" aria-labelledby="connection-lost-title">
<h3 id="connection-lost-title">Conexion perdida</h3>
<p>${message}</p>
<p class="connection-lost-sub">Seras redirigido al login en <strong id="connection-lost-countdown">3</strong> segundos.</p>
<button type="button" class="connection-lost-login-btn">Ir al login ahora</button>
</div>
`;
const countdownEl = overlay.querySelector('#connection-lost-countdown');
const loginNowBtn = overlay.querySelector('.connection-lost-login-btn');
let seconds = 3;
const finishRedirect = () => {
clearInterval(timerId);
overlay.remove();
redirectToLogin();
};
const timerId = setInterval(() => {
seconds -= 1;
if (countdownEl) {
countdownEl.textContent = String(Math.max(0, seconds));
}
if (seconds <= 0) {
finishRedirect();
}
}, 1000);
loginNowBtn?.addEventListener('click', finishRedirect);
document.body.appendChild(overlay);
}
function forceRelogin(message) {
if (reloginInProgress) {
return;
}
reloginInProgress = true;
localStorage.removeItem('user');
localStorage.removeItem('token');
sessionStorage.setItem('session-expired-message', message);
window.dispatchEvent(new Event('auth:logout'));
showConnectionLostNotice(message);
}
function buildUrl(path) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
return `${API_BASE_URL}${path.startsWith('/') ? '' : '/'}${path}`;
}
function buildHeaders({ auth = true, headers = {}, hasJsonBody = false }) {
const finalHeaders = { ...headers };
if (auth) {
const token = localStorage.getItem('token');
if (token) {
finalHeaders.Authorization = `Bearer ${token}`;
}
}
if (hasJsonBody && !finalHeaders['Content-Type']) {
finalHeaders['Content-Type'] = 'application/json';
}
return finalHeaders;
}
async function parseError(response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => ({}));
return data.detail || data.message || data.error || `HTTP ${response.status}`;
}
const text = await response.text().catch(() => '');
return text || `HTTP ${response.status}`;
}
export async function apiRequest(path, options = {}) {
const {
method = 'GET',
auth = true,
headers = {},
body,
responseType = 'json'
} = options;
const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData);
let response;
try {
response = await fetch(buildUrl(path), {
method,
headers: buildHeaders({ auth, headers, hasJsonBody: isJsonBody }),
body: isJsonBody ? JSON.stringify(body) : body
});
} catch (error) {
if (auth) {
forceRelogin('Se ha perdido la conexion con el servidor. Debes volver a iniciar sesion.');
}
throw new Error('Se ha perdido la conexion con el servidor.');
}
if (!response.ok) {
if (auth && (response.status === 401 || response.status === 403 || response.status === 503)) {
forceRelogin('Se ha perdido la conexion con el servidor. Debes volver a iniciar sesion.');
}
throw new Error(await parseError(response));
}
if (responseType === 'raw') {
return response;
}
if (response.status === 204) {
return null;
}
if (responseType === 'blob') {
return response.blob();
}
if (responseType === 'text') {
return response.text();
}
return response.json();
}
export function apiGet(path, options = {}) {
return apiRequest(path, { ...options, method: 'GET' });
}
export function apiPost(path, body, options = {}) {
return apiRequest(path, { ...options, method: 'POST', body });
}
export function apiPut(path, body, options = {}) {
return apiRequest(path, { ...options, method: 'PUT', body });
}
export function apiPatch(path, body, options = {}) {
return apiRequest(path, { ...options, method: 'PATCH', body });
}
export function apiDelete(path, options = {}) {
return apiRequest(path, { ...options, method: 'DELETE' });
}

View File

@ -1,3 +1,21 @@
import { apiPost } from './apiClient.js';
function isTokenExpired(token) {
try {
const payloadPart = token.split('.')[1];
if (!payloadPart) return true;
const base64 = payloadPart.replace(/-/g, '+').replace(/_/g, '/');
const decoded = atob(base64);
const payload = JSON.parse(decoded);
if (!payload.exp) return false;
return payload.exp * 1000 <= Date.now();
} catch {
return true;
}
}
export class Auth { export class Auth {
constructor() { constructor() {
this.currentUser = null; this.currentUser = null;
@ -8,25 +26,10 @@ export class Auth {
if (!identifier || !password) return false; if (!identifier || !password) return false;
try { try {
const apiUrl = `${import.meta.env.VITE_API_BASE_URL}/api/Auth/login`;
const body = { Username: identifier, Password: password }; const body = { Username: identifier, Password: password };
console.log('🔐 Login attempt - Datos enviados:', body); console.log('🔐 Login attempt - Datos enviados:', body);
const response = await fetch(apiUrl, { const data = await apiPost('/api/Auth/login', body, { auth: false });
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
console.log('📡 Response status:', response.status, response.ok);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
console.error('❌ Error del servidor:', errorData);
return false;
}
const data = await response.json();
console.log('✅ Respuesta exitosa:', data); console.log('✅ Respuesta exitosa:', data);
const { token, user } = data; const { token, user } = data;
@ -39,6 +42,7 @@ export class Auth {
this.isAuthenticated = true; this.isAuthenticated = true;
localStorage.setItem('user', JSON.stringify(this.currentUser)); localStorage.setItem('user', JSON.stringify(this.currentUser));
localStorage.setItem('token', token); localStorage.setItem('token', token);
window.dispatchEvent(new Event('auth:login'));
console.log('✅ Login successful, guardado en localStorage'); console.log('✅ Login successful, guardado en localStorage');
return true; return true;
} catch (error) { } catch (error) {
@ -52,18 +56,25 @@ export class Auth {
this.isAuthenticated = false; this.isAuthenticated = false;
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('token'); localStorage.removeItem('token');
window.dispatchEvent(new Event('auth:logout'));
} }
checkAuth() { checkAuth() {
const user = localStorage.getItem('user'); const user = localStorage.getItem('user');
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (user && token) { if (user && token && !isTokenExpired(token)) {
this.currentUser = JSON.parse(user); this.currentUser = JSON.parse(user);
this.isAuthenticated = true; this.isAuthenticated = true;
} else { } else {
if (token && isTokenExpired(token)) {
sessionStorage.setItem('session-expired-message', 'Tu sesion expiro. Inicia sesion de nuevo.');
}
this.isAuthenticated = false; this.isAuthenticated = false;
this.currentUser = null; this.currentUser = null;
localStorage.removeItem('user');
localStorage.removeItem('token');
window.dispatchEvent(new Event('auth:logout'));
} }
return this.isAuthenticated; return this.isAuthenticated;
} }

View File

@ -1,35 +1,9 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; import { apiGet } from './apiClient.js';
function getAuthHeaders() {
const token = localStorage.getItem('token');
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
}
export async function getClients(limit = 50, page = 1) { export async function getClients(limit = 50, page = 1) {
const response = await fetch(`${API_BASE_URL}/api/Clients?limit=${limit}&page=${page}`, { return apiGet(`/api/Clients?limit=${limit}&page=${page}`);
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Error al obtener los clientes');
}
return await response.json();
} }
export async function getClientById(id) { export async function getClientById(id) {
const response = await fetch(`${API_BASE_URL}/api/Clients/${id}`, { return apiGet(`/api/Clients/${id}`);
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Error al obtener el cliente');
}
return await response.json();
} }

View File

@ -1,141 +1,51 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; import { apiDelete, apiGet, apiPatch, apiPost, apiPut, apiRequest } from './apiClient.js';
function getAuthHeaders() {
const token = localStorage.getItem('token');
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
}
export async function getInvoiceById(id) { export async function getInvoiceById(id) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, { return apiGet(`/api/Invoices/${id}`);
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Error al obtener la factura');
}
return await response.json();
} }
export async function updateInvoice(id, data) { export async function updateInvoice(id, data) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, { await apiPut(`/api/Invoices/${id}`, data);
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al actualizar la factura');
}
return true; return true;
} }
export async function validateInvoice(id) { export async function validateInvoice(id) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/validate`, { await apiPost(`/api/Invoices/${id}/validate`);
method: 'POST',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al validar la factura');
}
return true; return true;
} }
export async function updateInvoiceStatus(id, status) { export async function updateInvoiceStatus(id, status) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/status`, { await apiPatch(`/api/Invoices/${id}/status`, { status });
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify({ status })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al actualizar el estado');
}
return true; return true;
} }
export async function addInvoiceLine(id, lineData) { export async function addInvoiceLine(id, lineData) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/lines`, { return apiPost(`/api/Invoices/${id}/lines`, lineData);
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(lineData)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al agregar línea');
}
return await response.json();
} }
export async function deleteInvoiceLine(invoiceId, lineId) { export async function deleteInvoiceLine(invoiceId, lineId) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/lines/${lineId}`, { await apiDelete(`/api/Invoices/${invoiceId}/lines/${lineId}`);
method: 'DELETE',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al eliminar línea');
}
return true; return true;
} }
export async function addPayment(invoiceId, paymentData) { export async function addPayment(invoiceId, paymentData) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, { return apiPost(`/api/Invoices/${invoiceId}/payments`, paymentData);
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(paymentData)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al registrar el pago');
}
return await response.json();
} }
export async function getPayments(invoiceId) { export async function getPayments(invoiceId) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, { return apiGet(`/api/Invoices/${invoiceId}/payments`);
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al obtener los pagos');
}
return await response.json();
} }
export async function downloadInvoicePdf(invoiceNumber) { export async function downloadInvoicePdf(invoiceNumber) {
const token = localStorage.getItem('token'); const response = await apiRequest(`/api/document/invoice/${encodeURIComponent(invoiceNumber)}/pdf`, {
const response = await fetch(`${API_BASE_URL}/api/document/invoice/${encodeURIComponent(invoiceNumber)}/pdf`, {
method: 'GET', method: 'GET',
headers: { headers: { Accept: 'application/pdf' },
'Authorization': `Bearer ${token}`, responseType: 'raw'
'Accept': 'application/pdf'
}
}); });
const contentType = response.headers.get('content-type') || '';
if (!response.ok) { if (!contentType.includes('application/pdf')) {
const error = await response.json().catch(() => ({})); const nonPdfBody = await response.text().catch(() => '');
throw new Error(error.detail || 'Error al descargar la factura'); throw new Error(nonPdfBody || 'La API no devolvió un PDF válido.');
} }
return await response.blob(); return await response.blob();
@ -145,12 +55,9 @@ export async function deleteInvoice(id) {
// Nota: No hay endpoint DELETE en el OpenAPI spec, // Nota: No hay endpoint DELETE en el OpenAPI spec,
// pero podríamos usar el endpoint de cambio de estado a "canceled" // pero podríamos usar el endpoint de cambio de estado a "canceled"
// o implementarlo si está disponible en el backend // o implementarlo si está disponible en el backend
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, { try {
method: 'DELETE', await apiDelete(`/api/Invoices/${id}`);
headers: getAuthHeaders() } catch (error) {
});
if (!response.ok) {
// Si no existe DELETE, intentar cancelar // Si no existe DELETE, intentar cancelar
return await updateInvoiceStatus(id, 'canceled'); return await updateInvoiceStatus(id, 'canceled');
} }

186
src/services/session.js Normal file
View File

@ -0,0 +1,186 @@
import { auth } from './auth.js';
const SESSION_TIMEOUT_MS = Number(import.meta.env.VITE_SESSION_TIMEOUT_MS || 15 * 60 * 1000);
const SESSION_WARNING_MS = Number(import.meta.env.VITE_SESSION_WARNING_MS || 60 * 1000);
let inactivityTimer = null;
let warningTimer = null;
let warningCountdownTimer = null;
let warningEndsAt = null;
let warningOverlay = null;
let hasInitialized = false;
let fetchIsPatched = false;
function isAuthenticated() {
return Boolean(localStorage.getItem('token'));
}
function clearTimers() {
if (inactivityTimer) {
clearTimeout(inactivityTimer);
inactivityTimer = null;
}
if (warningTimer) {
clearTimeout(warningTimer);
warningTimer = null;
}
if (warningCountdownTimer) {
clearInterval(warningCountdownTimer);
warningCountdownTimer = null;
}
}
function removeWarningOverlay() {
if (warningOverlay) {
warningOverlay.remove();
warningOverlay = null;
}
warningEndsAt = null;
if (warningCountdownTimer) {
clearInterval(warningCountdownTimer);
warningCountdownTimer = null;
}
}
function getRemainingSeconds() {
if (!warningEndsAt) return 0;
return Math.max(0, Math.ceil((warningEndsAt - Date.now()) / 1000));
}
function forceSessionExpiration(message = 'Tu sesion ha expirado. Inicia sesion de nuevo.') {
removeWarningOverlay();
clearTimers();
auth.logout();
sessionStorage.setItem('session-expired-message', message);
window.location.hash = '#login';
}
function showWarningOverlay() {
if (!isAuthenticated()) return;
removeWarningOverlay();
const overlay = document.createElement('div');
overlay.className = 'session-warning-overlay';
overlay.innerHTML = `
<div class="session-warning-modal" role="dialog" aria-modal="true" aria-labelledby="session-warning-title">
<h3 id="session-warning-title">Tu sesion va a expirar</h3>
<p>Por inactividad, se cerrara tu sesion automaticamente en <strong id="session-warning-countdown"></strong>.</p>
<div class="session-warning-actions">
<button type="button" class="session-warning-logout">Cerrar sesion ahora</button>
<button type="button" class="session-warning-continue">Seguir conectado</button>
</div>
</div>
`;
const continueBtn = overlay.querySelector('.session-warning-continue');
const logoutBtn = overlay.querySelector('.session-warning-logout');
const countdownEl = overlay.querySelector('#session-warning-countdown');
continueBtn?.addEventListener('click', () => {
removeWarningOverlay();
resetSessionTimers();
});
logoutBtn?.addEventListener('click', () => {
forceSessionExpiration('Sesion finalizada por inactividad.');
});
warningEndsAt = Date.now() + SESSION_WARNING_MS;
const updateCountdown = () => {
const seconds = getRemainingSeconds();
const mins = String(Math.floor(seconds / 60)).padStart(2, '0');
const secs = String(seconds % 60).padStart(2, '0');
if (countdownEl) {
countdownEl.textContent = `${mins}:${secs}`;
}
if (seconds <= 0) {
forceSessionExpiration();
}
};
updateCountdown();
warningCountdownTimer = setInterval(updateCountdown, 1000);
warningOverlay = overlay;
document.body.appendChild(overlay);
}
function resetSessionTimers() {
clearTimers();
if (!isAuthenticated()) {
removeWarningOverlay();
return;
}
const warningDelay = Math.max(SESSION_TIMEOUT_MS - SESSION_WARNING_MS, 1000);
warningTimer = setTimeout(showWarningOverlay, warningDelay);
inactivityTimer = setTimeout(() => forceSessionExpiration(), SESSION_TIMEOUT_MS);
}
function patchFetchFor401AndActivity() {
if (fetchIsPatched) return;
const originalFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const requestUrl = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '');
const isLoginEndpoint = requestUrl.includes('/api/Auth/login');
if (!isLoginEndpoint && isAuthenticated()) {
resetSessionTimers();
}
const response = await originalFetch(...args);
if (response.status === 401 && !isLoginEndpoint && isAuthenticated()) {
forceSessionExpiration('Tu sesion ha expirado o ya no es valida. Inicia sesion de nuevo.');
}
return response;
};
fetchIsPatched = true;
}
function bindActivityListeners() {
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
const onActivity = () => {
if (isAuthenticated()) {
resetSessionTimers();
}
};
events.forEach(eventName => {
window.addEventListener(eventName, onActivity, { passive: true });
});
}
function bindAuthListeners() {
window.addEventListener('auth:login', () => {
removeWarningOverlay();
resetSessionTimers();
});
window.addEventListener('auth:logout', () => {
removeWarningOverlay();
clearTimers();
});
}
export function initSessionManager() {
if (hasInitialized) return;
patchFetchFor401AndActivity();
bindActivityListeners();
bindAuthListeners();
resetSessionTimers();
hasInitialized = true;
}

View File

@ -489,6 +489,115 @@ button:disabled {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
} }
/* ============================================
Session Warning Modal
============================================ */
.session-warning-overlay {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 3000;
padding: var(--space-4);
}
.session-warning-modal {
width: 100%;
max-width: 430px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: var(--space-6);
}
.session-warning-modal h3 {
margin: 0 0 var(--space-3) 0;
font-size: 1.15rem;
}
.session-warning-modal p {
margin: 0;
color: var(--text-secondary);
}
.session-warning-actions {
margin-top: var(--space-5);
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
/* ============================================
Connection Lost Modal
============================================ */
.connection-lost-overlay {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 4000;
padding: var(--space-4);
}
.connection-lost-modal {
width: 100%;
max-width: 460px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: var(--space-6);
}
.connection-lost-modal h3 {
margin: 0 0 var(--space-2) 0;
color: var(--danger);
}
.connection-lost-modal p {
margin: 0;
color: var(--text-secondary);
}
.connection-lost-sub {
margin-top: var(--space-3) !important;
font-size: 0.92rem;
}
.connection-lost-login-btn {
margin-top: var(--space-5);
width: 100%;
}
.session-warning-logout {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.session-warning-logout:hover {
background: var(--gray-100);
}
.session-warning-continue {
background: var(--primary);
}
@media (max-width: 640px) {
.session-warning-actions {
flex-direction: column;
}
.session-warning-actions button {
width: 100%;
}
}
.login-button { .login-button {
width: 100%; width: 100%;
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);