Simplificar llamadas a la api, hacer que si se pierde conexion salga de sesion, añadir preview de pdf
This commit is contained in:
parent
7f11d34e83
commit
9e93d9c9c6
|
|
@ -12,6 +12,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
// Tracker de cambios
|
||||
const changeTracker = new FormChangeTracker();
|
||||
let savedSuccessfully = false;
|
||||
let activePreviewBlobUrl = null;
|
||||
|
||||
// Formatear fecha para input type="date"
|
||||
const formatDateForInput = (dateString) => {
|
||||
|
|
@ -318,6 +319,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
|
||||
<div class="modal-footer">
|
||||
<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>' : ''}
|
||||
${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>
|
||||
|
|
@ -358,6 +360,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
const validateBtn = modal.querySelector('.btn-validate');
|
||||
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
|
||||
const downloadBtn = modal.querySelector('.btn-download-invoice');
|
||||
downloadBtn?.addEventListener('click', handleDownloadPdf);
|
||||
|
|
@ -428,6 +434,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
}
|
||||
}
|
||||
|
||||
if (activePreviewBlobUrl) {
|
||||
URL.revokeObjectURL(activePreviewBlobUrl);
|
||||
activePreviewBlobUrl = null;
|
||||
}
|
||||
|
||||
changeTracker.cleanup();
|
||||
modal.remove();
|
||||
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
|
||||
const toggleAddLineForm = () => {
|
||||
const form = modal.querySelector('.add-line-form');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import './style.css'
|
||||
import { initRouter } from './router.js'
|
||||
import { initSessionManager } from './services/session.js'
|
||||
|
||||
initSessionManager()
|
||||
initRouter()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { FormChangeTracker } from '../components/ConfirmExitModal.js';
|
||||
import { navigationGuards } from '../router.js';
|
||||
import { apiGet, apiPost } from '../services/apiClient.js';
|
||||
|
||||
export function renderCreateInvoicePage() {
|
||||
const container = document.createElement('div');
|
||||
|
|
@ -77,14 +78,7 @@ export function renderCreateInvoicePage() {
|
|||
|
||||
async function loadClients() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
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();
|
||||
const clients = await apiGet('/api/Clients?limit=1000');
|
||||
|
||||
clientSelect.innerHTML = '<option value="">Seleccionar cliente...</option>';
|
||||
clients.forEach(client => {
|
||||
|
|
@ -296,22 +290,7 @@ export function renderCreateInvoicePage() {
|
|||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Guardando...';
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
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();
|
||||
const invoiceId = await apiPost('/api/Invoices', formData);
|
||||
|
||||
// Marcar como guardado exitosamente para evitar el modal de confirmación
|
||||
savedSuccessfully = true;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { auth } from '../services/auth.js';
|
||||
import { InvoiceModal } from '../components/InvoiceModal.js';
|
||||
import { apiGet } from '../services/apiClient.js';
|
||||
import {
|
||||
Chart,
|
||||
BarController,
|
||||
|
|
@ -12,19 +13,8 @@ import {
|
|||
|
||||
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() {
|
||||
const response = await fetch(`${API_BASE_URL}/api/Invoices?limit=500&page=1`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Error al cargar facturas');
|
||||
const data = await response.json();
|
||||
const data = await apiGet('/api/Invoices?limit=500&page=1');
|
||||
return Array.isArray(data) ? data : data.data || data.invoices || [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { InvoiceItem } from '../components/InvoiceItem.js';
|
||||
import { InvoiceModal } from '../components/InvoiceModal.js';
|
||||
import { apiGet } from '../services/apiClient.js';
|
||||
|
||||
export function renderFacturasPage() {
|
||||
const container = document.createElement('div');
|
||||
|
|
@ -115,10 +116,8 @@ export function renderFacturasPage() {
|
|||
try {
|
||||
invoicesList.innerHTML = '<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>';
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 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) {
|
||||
url += `&status=${currentFilter}`;
|
||||
|
|
@ -128,19 +127,7 @@ export function renderFacturasPage() {
|
|||
url += `&search=${encodeURIComponent(searchTerm)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(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();
|
||||
const data = await apiGet(url);
|
||||
|
||||
// Manejo de respuesta - obtener todas las facturas
|
||||
allInvoices = Array.isArray(data) ? data : data.data || data.invoices || [];
|
||||
|
|
|
|||
|
|
@ -19,5 +19,15 @@ export function renderLoginPage(onLoginSuccess) {
|
|||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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 {
|
||||
constructor() {
|
||||
this.currentUser = null;
|
||||
|
|
@ -8,25 +26,10 @@ export class Auth {
|
|||
if (!identifier || !password) return false;
|
||||
|
||||
try {
|
||||
const apiUrl = `${import.meta.env.VITE_API_BASE_URL}/api/Auth/login`;
|
||||
const body = { Username: identifier, Password: password };
|
||||
console.log('🔐 Login attempt - Datos enviados:', body);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
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();
|
||||
const data = await apiPost('/api/Auth/login', body, { auth: false });
|
||||
console.log('✅ Respuesta exitosa:', data);
|
||||
|
||||
const { token, user } = data;
|
||||
|
|
@ -39,6 +42,7 @@ export class Auth {
|
|||
this.isAuthenticated = true;
|
||||
localStorage.setItem('user', JSON.stringify(this.currentUser));
|
||||
localStorage.setItem('token', token);
|
||||
window.dispatchEvent(new Event('auth:login'));
|
||||
console.log('✅ Login successful, guardado en localStorage');
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -52,18 +56,25 @@ export class Auth {
|
|||
this.isAuthenticated = false;
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
window.dispatchEvent(new Event('auth:logout'));
|
||||
}
|
||||
|
||||
checkAuth() {
|
||||
const user = localStorage.getItem('user');
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (user && token) {
|
||||
if (user && token && !isTokenExpired(token)) {
|
||||
this.currentUser = JSON.parse(user);
|
||||
this.isAuthenticated = true;
|
||||
} else {
|
||||
if (token && isTokenExpired(token)) {
|
||||
sessionStorage.setItem('session-expired-message', 'Tu sesion expiro. Inicia sesion de nuevo.');
|
||||
}
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
window.dispatchEvent(new Event('auth:logout'));
|
||||
}
|
||||
return this.isAuthenticated;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,9 @@
|
|||
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'
|
||||
};
|
||||
}
|
||||
import { apiGet } from './apiClient.js';
|
||||
|
||||
export async function getClients(limit = 50, page = 1) {
|
||||
const response = await fetch(`${API_BASE_URL}/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();
|
||||
return apiGet(`/api/Clients?limit=${limit}&page=${page}`);
|
||||
}
|
||||
|
||||
export async function getClientById(id) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/Clients/${id}`, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error al obtener el cliente');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return apiGet(`/api/Clients/${id}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +1,51 @@
|
|||
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'
|
||||
};
|
||||
}
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, apiRequest } from './apiClient.js';
|
||||
|
||||
export async function getInvoiceById(id) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error al obtener la factura');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return apiGet(`/api/Invoices/${id}`);
|
||||
}
|
||||
|
||||
export async function updateInvoice(id, data) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, {
|
||||
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');
|
||||
}
|
||||
|
||||
await apiPut(`/api/Invoices/${id}`, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function validateInvoice(id) {
|
||||
const response = await fetch(`${API_BASE_URL}/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');
|
||||
}
|
||||
|
||||
await apiPost(`/api/Invoices/${id}/validate`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateInvoiceStatus(id, status) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/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');
|
||||
}
|
||||
|
||||
await apiPatch(`/api/Invoices/${id}/status`, { status });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function addInvoiceLine(id, lineData) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/lines`, {
|
||||
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();
|
||||
return apiPost(`/api/Invoices/${id}/lines`, lineData);
|
||||
}
|
||||
|
||||
export async function deleteInvoiceLine(invoiceId, lineId) {
|
||||
const response = await fetch(`${API_BASE_URL}/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');
|
||||
}
|
||||
|
||||
await apiDelete(`/api/Invoices/${invoiceId}/lines/${lineId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function addPayment(invoiceId, paymentData) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, {
|
||||
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();
|
||||
return apiPost(`/api/Invoices/${invoiceId}/payments`, paymentData);
|
||||
}
|
||||
|
||||
export async function getPayments(invoiceId) {
|
||||
const response = await fetch(`${API_BASE_URL}/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();
|
||||
return apiGet(`/api/Invoices/${invoiceId}/payments`);
|
||||
}
|
||||
|
||||
export async function downloadInvoicePdf(invoiceNumber) {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/document/invoice/${encodeURIComponent(invoiceNumber)}/pdf`, {
|
||||
const response = await apiRequest(`/api/document/invoice/${encodeURIComponent(invoiceNumber)}/pdf`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/pdf'
|
||||
}
|
||||
headers: { Accept: 'application/pdf' },
|
||||
responseType: 'raw'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || 'Error al descargar la factura');
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/pdf')) {
|
||||
const nonPdfBody = await response.text().catch(() => '');
|
||||
throw new Error(nonPdfBody || 'La API no devolvió un PDF válido.');
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
|
|
@ -145,12 +55,9 @@ export async function deleteInvoice(id) {
|
|||
// Nota: No hay endpoint DELETE en el OpenAPI spec,
|
||||
// pero podríamos usar el endpoint de cambio de estado a "canceled"
|
||||
// o implementarlo si está disponible en el backend
|
||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
await apiDelete(`/api/Invoices/${id}`);
|
||||
} catch (error) {
|
||||
// Si no existe DELETE, intentar cancelar
|
||||
return await updateInvoiceStatus(id, 'canceled');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
109
src/style.css
109
src/style.css
|
|
@ -489,6 +489,115 @@ button:disabled {
|
|||
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 {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
|
|
|
|||
Loading…
Reference in New Issue