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
|
// 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');
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 || [];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue