mejorar la interfaz
This commit is contained in:
parent
c24564dc35
commit
9c75ff154e
|
|
@ -3,9 +3,9 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/doli.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>doli-front</title>
|
<title>Doli App</title>
|
||||||
<link rel="stylesheet" href="/src/styles/modal.css" />
|
<link rel="stylesheet" href="/src/styles/modal.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#0d9488"/>
|
||||||
|
<stop offset="100%" stop-color="#1a2332"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="32" height="32" rx="8" fill="url(#bg)"/>
|
||||||
|
<path d="M19 5H11a2 2 0 0 0-2 2v18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V10l-4-5z" fill="none" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="19,5 19,10 23,10" fill="none" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="13" y1="15" x2="19" y2="15" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<line x1="13" y1="19" x2="19" y2="19" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<line x1="13" y1="23" x2="16" y2="23" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 989 B |
|
|
@ -1,45 +0,0 @@
|
||||||
/**
|
|
||||||
* Muestra un toast de aviso cuando se bloquea la navegación
|
|
||||||
* @param {string} message - Mensaje a mostrar
|
|
||||||
* @param {number} duration - Duración en ms (default: 3000)
|
|
||||||
*/
|
|
||||||
export function showBlockedNavigationToast(message = 'Guarda los cambios antes de salir', duration = 3000) {
|
|
||||||
// Eliminar toast existente si hay uno
|
|
||||||
const existingToast = document.querySelector('.blocked-nav-toast');
|
|
||||||
if (existingToast) {
|
|
||||||
existingToast.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'blocked-nav-toast';
|
|
||||||
|
|
||||||
toast.innerHTML = /*html*/`
|
|
||||||
<div class="blocked-nav-toast-content">
|
|
||||||
<svg class="blocked-nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
<span class="blocked-nav-message">${message}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// Animar entrada
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
toast.classList.add('visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-cerrar después del tiempo especificado
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.remove('visible');
|
|
||||||
toast.classList.add('hiding');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.remove();
|
|
||||||
}, 300);
|
|
||||||
}, duration);
|
|
||||||
|
|
||||||
return toast;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { escapeHtml } from '../utils/escapeHtml.js';
|
||||||
|
|
||||||
// Helper para mostrar N/A si el valor es nulo/vacío/inválido
|
// Helper para mostrar N/A si el valor es nulo/vacío/inválido
|
||||||
function displayValue(value) {
|
function displayValue(value) {
|
||||||
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
|
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
|
||||||
return value;
|
return escapeHtml(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientItem(client, onView) {
|
export function ClientItem(client, onView) {
|
||||||
|
|
@ -50,7 +52,7 @@ export function ClientItem(client, onView) {
|
||||||
item.innerHTML = /*html*/`
|
item.innerHTML = /*html*/`
|
||||||
<td class="client-avatar-cell">
|
<td class="client-avatar-cell">
|
||||||
<div class="client-avatar" style="background-color: ${getAvatarColor()}">
|
<div class="client-avatar" style="background-color: ${getAvatarColor()}">
|
||||||
${getInitials()}
|
${escapeHtml(getInitials())}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="client-name">
|
<td class="client-name">
|
||||||
|
|
@ -68,7 +70,7 @@ export function ClientItem(client, onView) {
|
||||||
</td>
|
</td>
|
||||||
<td class="client-phone">${displayValue(client.phone)}</td>
|
<td class="client-phone">${displayValue(client.phone)}</td>
|
||||||
<td class="client-actions">
|
<td class="client-actions">
|
||||||
<button class="btn-action btn-view" data-client-id="${client.id}" title="Ver detalles">
|
<button class="btn-action btn-view" data-client-id="${client.id}" title="Ver detalles" aria-label="Ver detalles de ${displayValue(client.name)}">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
<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"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Diálogo de confirmación genérico reutilizable.
|
||||||
|
* Reemplaza todos los confirm() nativos del proyecto.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* import { showConfirmDialog } from '../components/ConfirmDialog.js';
|
||||||
|
* const ok = await showConfirmDialog({
|
||||||
|
* title: '¿Eliminar factura?',
|
||||||
|
* message: 'Esta acción no se puede deshacer.',
|
||||||
|
* confirmText: 'Eliminar',
|
||||||
|
* cancelText: 'Cancelar',
|
||||||
|
* variant: 'danger' // 'danger' | 'warning' | 'primary'
|
||||||
|
* });
|
||||||
|
* if (ok) { ... }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function showConfirmDialog({
|
||||||
|
title = '¿Estás seguro?',
|
||||||
|
message = '',
|
||||||
|
confirmText = 'Confirmar',
|
||||||
|
cancelText = 'Cancelar',
|
||||||
|
variant = 'danger'
|
||||||
|
} = {}) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'confirm-dialog-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', title);
|
||||||
|
|
||||||
|
const variantColors = {
|
||||||
|
danger: { bg: 'rgba(220, 38, 38, 0.1)', color: '#dc2626', btnBg: '#dc2626', btnHover: '#b91c1c' },
|
||||||
|
warning: { bg: 'rgba(217, 119, 6, 0.1)', color: '#d97706', btnBg: '#d97706', btnHover: '#b45309' },
|
||||||
|
primary: { bg: 'rgba(13, 148, 136, 0.1)', color: '#0d9488', btnBg: '#0d9488', btnHover: '#0f766e' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantIcons = {
|
||||||
|
danger: /*html*/`<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
|
||||||
|
warning: /*html*/`<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
|
||||||
|
primary: /*html*/`<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`
|
||||||
|
};
|
||||||
|
|
||||||
|
const vc = variantColors[variant] || variantColors.danger;
|
||||||
|
const icon = variantIcons[variant] || variantIcons.danger;
|
||||||
|
|
||||||
|
overlay.innerHTML = /*html*/`
|
||||||
|
<div class="confirm-dialog-card">
|
||||||
|
<div class="confirm-dialog-icon" style="background: ${vc.bg}; color: ${vc.color};">
|
||||||
|
${icon}
|
||||||
|
</div>
|
||||||
|
<h3 class="confirm-dialog-title"></h3>
|
||||||
|
<p class="confirm-dialog-message"></p>
|
||||||
|
<div class="confirm-dialog-actions">
|
||||||
|
<button class="confirm-dialog-btn-cancel" aria-label="${cancelText}">${cancelText}</button>
|
||||||
|
<button class="confirm-dialog-btn-confirm" style="background: ${vc.btnBg};" aria-label="${confirmText}">${confirmText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Set text with textContent (safe from XSS)
|
||||||
|
overlay.querySelector('.confirm-dialog-title').textContent = title;
|
||||||
|
overlay.querySelector('.confirm-dialog-message').textContent = message;
|
||||||
|
|
||||||
|
const close = (result) => {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
setTimeout(() => overlay.remove(), 250);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.querySelector('.confirm-dialog-btn-cancel').addEventListener('click', () => close(false));
|
||||||
|
overlay.querySelector('.confirm-dialog-btn-confirm').addEventListener('click', () => close(true));
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) close(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trap focus
|
||||||
|
overlay.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') close(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||||||
|
|
||||||
|
// Focus the cancel button by default (safer option)
|
||||||
|
overlay.querySelector('.confirm-dialog-btn-cancel').focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -25,10 +25,10 @@ export function ConfirmExitModal(options = {}) {
|
||||||
modal.innerHTML = /*html*/`
|
modal.innerHTML = /*html*/`
|
||||||
<div class="confirm-exit-modal">
|
<div class="confirm-exit-modal">
|
||||||
<div class="confirm-exit-icon">
|
<div class="confirm-exit-icon">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
<path d="M12 2L1 21h22L12 2z" fill="#f59e0b" stroke="#d97706" stroke-width="1" stroke-linejoin="round"/>
|
||||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
<rect x="11" y="9" width="2" height="5" rx="1" fill="white"/>
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
<circle cx="12" cy="16.5" r="1.2" fill="white"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="confirm-exit-title">${title}</h3>
|
<h3 class="confirm-exit-title">${title}</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { escapeHtml } from '../utils/escapeHtml.js';
|
||||||
|
|
||||||
export function InvoiceItem(invoice, onView) {
|
export function InvoiceItem(invoice, onView) {
|
||||||
const item = document.createElement('tr');
|
const item = document.createElement('tr');
|
||||||
item.className = 'invoice-item';
|
item.className = 'invoice-item';
|
||||||
|
|
@ -41,21 +43,21 @@ export function InvoiceItem(invoice, onView) {
|
||||||
};
|
};
|
||||||
|
|
||||||
item.innerHTML = /*html*/`
|
item.innerHTML = /*html*/`
|
||||||
<td class="invoice-number">${invoice.number}</td>
|
<td class="invoice-number">${escapeHtml(invoice.number)}</td>
|
||||||
<td class="invoice-status">
|
<td class="invoice-status">
|
||||||
<span class="status-badge ${getStatusClass(invoice.status)}">
|
<span class="status-badge ${getStatusClass(invoice.status)}" role="status">
|
||||||
${getStatusText(invoice.status)}
|
${getStatusText(invoice.status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="invoice-client">
|
<td class="invoice-client">
|
||||||
<span class="client-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
|
<span class="client-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
|
||||||
${invoice.clientName || 'Sin nombre'}
|
${escapeHtml(invoice.clientName || 'Sin nombre')}
|
||||||
</td>
|
</td>
|
||||||
<td class="invoice-date">${formatDate(invoice.date)}</td>
|
<td class="invoice-date">${formatDate(invoice.date)}</td>
|
||||||
<td class="invoice-total">${formatCurrency(invoice.total)}</td>
|
<td class="invoice-total">${formatCurrency(invoice.total)}</td>
|
||||||
<td class="invoice-remain">${formatCurrency(invoice.remainToPay)}</td>
|
<td class="invoice-remain">${formatCurrency(invoice.remainToPay)}</td>
|
||||||
<td class="invoice-actions">
|
<td class="invoice-actions">
|
||||||
<button class="btn-action btn-view" data-invoice-id="${invoice.id}" title="Ver/Editar detalles">
|
<button class="btn-action btn-view" data-invoice-id="${invoice.id}" title="Ver/Editar detalles" aria-label="Ver detalles de factura ${escapeHtml(invoice.number)}">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments } from '../services/invoices.js';
|
import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments, deleteInvoice } from '../services/invoices.js';
|
||||||
import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js';
|
import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js';
|
||||||
|
import { showToast } from './Toast.js';
|
||||||
|
import { showConfirmDialog } from './ConfirmDialog.js';
|
||||||
|
import { escapeHtml } from '../utils/escapeHtml.js';
|
||||||
|
|
||||||
export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.className = 'modal-overlay';
|
modal.className = 'modal-overlay';
|
||||||
|
modal.setAttribute('role', 'dialog');
|
||||||
|
modal.setAttribute('aria-modal', 'true');
|
||||||
|
modal.setAttribute('aria-label', 'Detalles de factura');
|
||||||
|
|
||||||
let invoice = null;
|
let invoice = null;
|
||||||
let payments = [];
|
let payments = [];
|
||||||
|
|
@ -55,7 +61,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
modalContent.innerHTML = `
|
modalContent.innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>Cargando...</h2>
|
<h2>Cargando...</h2>
|
||||||
<button class="btn-close">×</button>
|
<button class="btn-close" aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="loading">Cargando detalles de la factura...</div>
|
<div class="loading">Cargando detalles de la factura...</div>
|
||||||
|
|
@ -68,7 +74,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
modalContent.innerHTML = `
|
modalContent.innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>Error</h2>
|
<h2>Error</h2>
|
||||||
<button class="btn-close">×</button>
|
<button class="btn-close" aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="error">No se pudo cargar la factura</div>
|
<div class="error">No se pudo cargar la factura</div>
|
||||||
|
|
@ -83,12 +89,12 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
modalContent.innerHTML = `
|
modalContent.innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">
|
<div class="modal-title">
|
||||||
<h2>${invoice.number || 'Nueva Factura'}</h2>
|
<h2>${escapeHtml(invoice.number || 'Nueva Factura')}</h2>
|
||||||
<span class="status-badge status-${invoice.status}">
|
<span class="status-badge status-${invoice.status}">
|
||||||
${getStatusText(invoice.status)}
|
${getStatusText(invoice.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-close">×</button>
|
<button class="btn-close" aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
@ -103,7 +109,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="number"
|
name="number"
|
||||||
value="${invoice.number || ''}"
|
value="${escapeHtml(invoice.number || '')}"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -126,6 +132,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
type="date"
|
type="date"
|
||||||
name="expireDate"
|
name="expireDate"
|
||||||
value="${formatDateForInput(invoice.expireDate)}"
|
value="${formatDateForInput(invoice.expireDate)}"
|
||||||
|
min="${formatDateForInput(invoice.date)}"
|
||||||
${!canEdit ? 'disabled' : ''}
|
${!canEdit ? 'disabled' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,14 +151,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; 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>Nota Pública</label>
|
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; 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>Nota Pública</label>
|
||||||
<textarea name="note_public" rows="3" placeholder="Visible para el cliente" ${!canEdit ? 'disabled' : ''}>${invoice.notePublic || ''}</textarea>
|
<textarea name="note_public" rows="3" placeholder="Visible para el cliente" ${!canEdit ? 'disabled' : ''}>${escapeHtml(invoice.notePublic || '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Nota Privada</label>
|
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Nota Privada</label>
|
||||||
<textarea name="note_private" rows="3" placeholder="Solo uso interno" ${!canEdit ? 'disabled' : ''}>${invoice.notePrivate || ''}</textarea>
|
<textarea name="note_private" rows="3" placeholder="Solo uso interno" ${!canEdit ? 'disabled' : ''}>${escapeHtml(invoice.notePrivate || '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -210,7 +217,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
${invoice.lines && invoice.lines.length > 0
|
${invoice.lines && invoice.lines.length > 0
|
||||||
? invoice.lines.map(line => `
|
? invoice.lines.map(line => `
|
||||||
<tr data-line-id="${line.id}">
|
<tr data-line-id="${line.id}">
|
||||||
<td>${line.description || ''}</td>
|
<td>${escapeHtml(line.description || '')}</td>
|
||||||
<td>${line.quantity || 0}</td>
|
<td>${line.quantity || 0}</td>
|
||||||
<td>${formatCurrency(line.unitPrice)}</td>
|
<td>${formatCurrency(line.unitPrice)}</td>
|
||||||
<td>${line.taxRate || 0}%</td>
|
<td>${line.taxRate || 0}%</td>
|
||||||
|
|
@ -296,7 +303,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Fecha de pago</label>
|
<label>Fecha de pago</label>
|
||||||
<input type="date" id="payment-date" value="${new Date().toISOString().split('T')[0]}" />
|
<input type="date" id="payment-date" value="${new Date().toISOString().split('T')[0]}" min="${formatDateForInput(invoice.date)}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|
@ -320,9 +327,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<div class="footer-actions-left">
|
<div class="footer-actions-left">
|
||||||
${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="2" 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="2" 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-delete-invoice btn-danger-outline"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>Eliminar</button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-actions-right">
|
<div class="footer-actions-right">
|
||||||
<button type="button" class="btn-cancel-modal">Cancelar</button>
|
<button type="button" class="btn-cancel-modal">Cerrar</button>
|
||||||
${canEdit ? '<button type="button" class="btn-save btn-primary">Guardar Cambios</button>' : ''}
|
${canEdit ? '<button type="button" class="btn-save btn-primary">Guardar Cambios</button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -386,6 +394,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
const payBtn = modal.querySelector('#btn-pay');
|
const payBtn = modal.querySelector('#btn-pay');
|
||||||
payBtn?.addEventListener('click', handlePayment);
|
payBtn?.addEventListener('click', handlePayment);
|
||||||
|
|
||||||
|
// Botón eliminar factura (solo borradores)
|
||||||
|
const deleteInvoiceBtn = modal.querySelector('.btn-delete-invoice');
|
||||||
|
deleteInvoiceBtn?.addEventListener('click', handleDeleteInvoice);
|
||||||
|
|
||||||
|
// Botón pasar a borrador (validadas / impagadas)
|
||||||
|
const cancelInvoiceBtn = modal.querySelector('.btn-cancel-invoice');
|
||||||
|
cancelInvoiceBtn?.addEventListener('click', handleCancelInvoice);
|
||||||
|
|
||||||
// Click fuera del modal - muestra modal si hay cambios
|
// Click fuera del modal - muestra modal si hay cambios
|
||||||
modal.addEventListener('click', (e) => {
|
modal.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) {
|
if (e.target === modal) {
|
||||||
|
|
@ -442,6 +458,15 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
notePrivate: formData.get('note_private') || undefined
|
notePrivate: formData.get('note_private') || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validar que fecha de vencimiento no sea anterior a la fecha de factura
|
||||||
|
if (data.expireDate) {
|
||||||
|
const invoiceDateInput = modal.querySelector('input[name="date"]');
|
||||||
|
if (invoiceDateInput && invoiceDateInput.value && data.expireDate < invoiceDateInput.value) {
|
||||||
|
showToast('La fecha de vencimiento no puede ser anterior a la fecha de factura.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filtrar valores undefined
|
// Filtrar valores undefined
|
||||||
Object.keys(data).forEach(key => {
|
Object.keys(data).forEach(key => {
|
||||||
if (data[key] === undefined || data[key] === '') {
|
if (data[key] === undefined || data[key] === '') {
|
||||||
|
|
@ -460,12 +485,12 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
savedSuccessfully = true;
|
savedSuccessfully = true;
|
||||||
changeTracker.markAsSaved();
|
changeTracker.markAsSaved();
|
||||||
|
|
||||||
alert('Factura actualizada correctamente');
|
showToast('Factura actualizada correctamente', 'success');
|
||||||
if (onUpdate) onUpdate();
|
if (onUpdate) onUpdate();
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al guardar:', error);
|
console.error('Error al guardar:', error);
|
||||||
alert('Error al guardar la factura: ' + error.message);
|
showToast('Error al guardar la factura: ' + error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
const saveBtn = modal.querySelector('.btn-save');
|
const saveBtn = modal.querySelector('.btn-save');
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
|
|
@ -477,9 +502,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
|
|
||||||
// Manejar validación
|
// Manejar validación
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
if (!confirm('¿Estás seguro de que quieres validar esta factura? No podrás editarla completamente después.')) {
|
const confirmed = await showConfirmDialog({
|
||||||
return;
|
title: '¿Validar factura?',
|
||||||
}
|
message: 'Una vez validada, no podrás editarla completamente. Esta acción no se puede deshacer.',
|
||||||
|
confirmText: 'Validar',
|
||||||
|
cancelText: 'Cancelar',
|
||||||
|
variant: 'warning'
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validateBtn = modal.querySelector('.btn-validate');
|
const validateBtn = modal.querySelector('.btn-validate');
|
||||||
|
|
@ -492,7 +522,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
savedSuccessfully = true;
|
savedSuccessfully = true;
|
||||||
changeTracker.markAsSaved();
|
changeTracker.markAsSaved();
|
||||||
|
|
||||||
alert('Factura validada correctamente');
|
showToast('Factura validada correctamente', 'success');
|
||||||
if (onUpdate) onUpdate();
|
if (onUpdate) onUpdate();
|
||||||
|
|
||||||
// Recargar la factura para mostrar el nuevo estado
|
// Recargar la factura para mostrar el nuevo estado
|
||||||
|
|
@ -502,7 +532,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
savedSuccessfully = false;
|
savedSuccessfully = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al validar:', error);
|
console.error('Error al validar:', error);
|
||||||
alert('Error al validar la factura: ' + error.message);
|
showToast('Error al validar la factura: ' + error.message, 'error');
|
||||||
|
|
||||||
const validateBtn = modal.querySelector('.btn-validate');
|
const validateBtn = modal.querySelector('.btn-validate');
|
||||||
if (validateBtn) {
|
if (validateBtn) {
|
||||||
|
|
@ -537,22 +567,22 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
const taxRate = parseFloat(modal.querySelector('#line-tax').value);
|
const taxRate = parseFloat(modal.querySelector('#line-tax').value);
|
||||||
|
|
||||||
if (!description) {
|
if (!description) {
|
||||||
alert('La descripción es obligatoria');
|
showToast('La descripción es obligatoria', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!quantity || quantity <= 0) {
|
if (!quantity || quantity <= 0) {
|
||||||
alert('La cantidad debe ser mayor que 0');
|
showToast('La cantidad debe ser mayor que 0', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unitPrice < 0) {
|
if (unitPrice < 0) {
|
||||||
alert('El precio no puede ser negativo');
|
showToast('El precio no puede ser negativo', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (taxRate < 0 || taxRate > 100) {
|
if (taxRate < 0 || taxRate > 100) {
|
||||||
alert('El IVA debe estar entre 0 y 100');
|
showToast('El IVA debe estar entre 0 y 100', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -568,7 +598,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
taxRate
|
taxRate
|
||||||
});
|
});
|
||||||
|
|
||||||
alert('Línea añadida correctamente');
|
showToast('Línea añadida correctamente', 'success');
|
||||||
toggleAddLineForm();
|
toggleAddLineForm();
|
||||||
|
|
||||||
// Recargar y recapturar estado inicial
|
// Recargar y recapturar estado inicial
|
||||||
|
|
@ -576,7 +606,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
changeTracker.markAsSaved();
|
changeTracker.markAsSaved();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al añadir línea:', error);
|
console.error('Error al añadir línea:', error);
|
||||||
alert('Error al añadir línea: ' + error.message);
|
showToast('Error al añadir línea: ' + error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
const saveBtn = modal.querySelector('.btn-save-line');
|
const saveBtn = modal.querySelector('.btn-save-line');
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
|
|
@ -588,10 +618,22 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
|
|
||||||
// Eliminar línea de factura
|
// Eliminar línea de factura
|
||||||
const handleDeleteLine = async (lineId) => {
|
const handleDeleteLine = async (lineId) => {
|
||||||
if (!confirm('¿Estás seguro de que quieres eliminar esta línea?')) {
|
// No permitir eliminar la última línea
|
||||||
|
const lineRows = modal.querySelectorAll('.lines-table tbody tr[data-line-id]');
|
||||||
|
if (lineRows.length <= 1) {
|
||||||
|
showToast('La factura debe tener al menos una línea.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmed = await showConfirmDialog({
|
||||||
|
title: '¿Eliminar línea?',
|
||||||
|
message: 'Esta línea se eliminará de la factura.',
|
||||||
|
confirmText: 'Eliminar',
|
||||||
|
cancelText: 'Cancelar',
|
||||||
|
variant: 'danger'
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Deshabilitar el botón mientras se elimina
|
// Deshabilitar el botón mientras se elimina
|
||||||
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
|
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
|
||||||
|
|
@ -607,7 +649,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
changeTracker.markAsSaved();
|
changeTracker.markAsSaved();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al eliminar línea:', error);
|
console.error('Error al eliminar línea:', error);
|
||||||
alert('Error al eliminar línea: ' + error.message);
|
showToast('Error al eliminar línea: ' + error.message, 'error');
|
||||||
|
|
||||||
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
|
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
|
@ -632,7 +674,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
let amount = null;
|
let amount = null;
|
||||||
if (!isNaN(rawAmount) && rawAmount > 0) {
|
if (!isNaN(rawAmount) && rawAmount > 0) {
|
||||||
if (rawAmount > remainToPay) {
|
if (rawAmount > remainToPay) {
|
||||||
alert(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`);
|
showToast(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`, 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
amount = rawAmount;
|
amount = rawAmount;
|
||||||
|
|
@ -640,7 +682,13 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
|
|
||||||
const paymentDate = dateInput.value;
|
const paymentDate = dateInput.value;
|
||||||
if (!paymentDate) {
|
if (!paymentDate) {
|
||||||
alert('La fecha de pago es obligatoria');
|
showToast('La fecha de pago es obligatoria', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceDateInput = modal.querySelector('input[name="date"]');
|
||||||
|
if (invoiceDateInput && invoiceDateInput.value && paymentDate < invoiceDateInput.value) {
|
||||||
|
showToast('La fecha de pago no puede ser anterior a la fecha de factura.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -664,14 +712,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
changeTracker.markAsSaved();
|
changeTracker.markAsSaved();
|
||||||
|
|
||||||
const displayAmount = amount ? `${amount.toFixed(2)} €` : `${remainToPay.toFixed(2)} € (total)`;
|
const displayAmount = amount ? `${amount.toFixed(2)} €` : `${remainToPay.toFixed(2)} € (total)`;
|
||||||
alert(`Pago de ${displayAmount} registrado correctamente`);
|
showToast(`Pago de ${displayAmount} registrado correctamente`, 'success');
|
||||||
|
|
||||||
if (onUpdate) onUpdate();
|
if (onUpdate) onUpdate();
|
||||||
await loadInvoice();
|
await loadInvoice();
|
||||||
savedSuccessfully = false;
|
savedSuccessfully = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al registrar pago:', error);
|
console.error('Error al registrar pago:', error);
|
||||||
alert('Error al registrar el pago: ' + error.message);
|
showToast('Error al registrar el pago: ' + error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
const payBtn = modal.querySelector('#btn-pay');
|
const payBtn = modal.querySelector('#btn-pay');
|
||||||
if (payBtn) {
|
if (payBtn) {
|
||||||
|
|
@ -681,6 +729,35 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Eliminar factura (solo borradores)
|
||||||
|
const handleDeleteInvoice = async () => {
|
||||||
|
const confirmed = await showConfirmDialog({
|
||||||
|
title: '¿Eliminar factura?',
|
||||||
|
message: `Se eliminará la factura ${invoice?.number || ''}. Esta acción no se puede deshacer.`,
|
||||||
|
confirmText: 'Eliminar',
|
||||||
|
cancelText: 'Cancelar',
|
||||||
|
variant: 'danger'
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const btn = modal.querySelector('.btn-delete-invoice');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Eliminando...'; }
|
||||||
|
|
||||||
|
await deleteInvoice(invoiceId);
|
||||||
|
savedSuccessfully = true;
|
||||||
|
changeTracker.markAsSaved();
|
||||||
|
showToast('Factura eliminada correctamente', 'success');
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
modal.remove();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al eliminar factura:', error);
|
||||||
|
showToast('Error al eliminar la factura: ' + error.message, 'error');
|
||||||
|
const btn = modal.querySelector('.btn-delete-invoice');
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>Eliminar'; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Cargar factura
|
// Cargar factura
|
||||||
const loadInvoice = async () => {
|
const loadInvoice = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,70 @@ export function renderLogin(onLogin) {
|
||||||
|
|
||||||
loginContainer.innerHTML = `
|
loginContainer.innerHTML = `
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h2 class="login-title">Iniciar Sesión</h2>
|
<div class="login-logo">
|
||||||
|
<div class="login-logo-icon">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="login-brand">Doli App</h1>
|
||||||
|
<p class="login-subtitle">Panel de facturación · Dolibarr</p>
|
||||||
|
</div>
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="identifier">Usuario o email</label>
|
<label for="identifier">
|
||||||
<input type="text" id="identifier" name="identifier" required autocomplete="username">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
Usuario o email
|
||||||
|
</label>
|
||||||
|
<input type="text" id="identifier" name="identifier" required autocomplete="username" placeholder="Introduce tu usuario">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Contraseña</label>
|
<label for="password">
|
||||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="Introduce tu contraseña">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="login-button">Ingresar</button>
|
<button type="submit" class="login-button" id="login-btn">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -3px; margin-right: 6px;" class="login-icon"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||||
|
<span class="login-btn-text">Iniciar Sesión</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="login-error" class="login-error"></p>
|
<p id="login-error" class="login-error"></p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
loginContainer.querySelector('#login-form').addEventListener('submit', (e) => {
|
const form = loginContainer.querySelector('#login-form');
|
||||||
|
const loginBtn = loginContainer.querySelector('#login-btn');
|
||||||
|
const loginIcon = loginBtn.querySelector('.login-icon');
|
||||||
|
const loginBtnText = loginBtn.querySelector('.login-btn-text');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Disable form while loading
|
||||||
|
const inputs = form.querySelectorAll('input');
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
inputs.forEach(i => i.disabled = true);
|
||||||
|
loginIcon.style.display = 'none';
|
||||||
|
loginBtnText.textContent = 'Iniciando sesión...';
|
||||||
|
|
||||||
|
// Add spinner
|
||||||
|
const spinner = document.createElement('span');
|
||||||
|
spinner.className = 'spinner';
|
||||||
|
loginBtn.insertBefore(spinner, loginBtnText);
|
||||||
|
|
||||||
const email = loginContainer.querySelector('#identifier').value;
|
const email = loginContainer.querySelector('#identifier').value;
|
||||||
const password = loginContainer.querySelector('#password').value;
|
const password = loginContainer.querySelector('#password').value;
|
||||||
onLogin(email, password);
|
|
||||||
|
try {
|
||||||
|
await onLogin(email, password);
|
||||||
|
} finally {
|
||||||
|
// Re-enable form
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
inputs.forEach(i => i.disabled = false);
|
||||||
|
spinner.remove();
|
||||||
|
loginIcon.style.display = '';
|
||||||
|
loginBtnText.textContent = 'Iniciar Sesión';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return loginContainer;
|
return loginContainer;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* Muestra un overlay a pantalla completa avisando que la sesión ha expirado
|
||||||
|
* o que se ha perdido la conexión con el servidor.
|
||||||
|
*/
|
||||||
|
let overlayVisible = false;
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
lock: /*html*/`<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
<circle cx="12" cy="16" r="1"/>
|
||||||
|
</svg>`,
|
||||||
|
disconnect: /*html*/`<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||||
|
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
|
||||||
|
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
|
||||||
|
<path d="M10.71 5.05A16 16 0 0 1 22.56 9"/>
|
||||||
|
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
|
||||||
|
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||||
|
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||||
|
</svg>`
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'expired' | 'disconnected'} type - Tipo de aviso
|
||||||
|
*/
|
||||||
|
export function showSessionExpiredOverlay(type = 'expired') {
|
||||||
|
// Evitar mostrar múltiples overlays
|
||||||
|
if (overlayVisible) return;
|
||||||
|
overlayVisible = true;
|
||||||
|
|
||||||
|
const isDisconnected = type === 'disconnected';
|
||||||
|
const title = isDisconnected ? 'Sin conexión' : 'Sesión expirada';
|
||||||
|
const message = isDisconnected
|
||||||
|
? 'No se pudo conectar con el servidor. Comprueba que el backend está en funcionamiento e inicia sesión de nuevo.'
|
||||||
|
: 'Tu sesión ha caducado. Por favor, inicia sesión de nuevo.';
|
||||||
|
const icon = isDisconnected ? ICONS.disconnect : ICONS.lock;
|
||||||
|
const iconClass = isDisconnected ? 'session-expired-icon disconnected' : 'session-expired-icon';
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'session-expired-overlay';
|
||||||
|
overlay.innerHTML = /*html*/`
|
||||||
|
<div class="session-expired-card">
|
||||||
|
<div class="${iconClass}">
|
||||||
|
${icon}
|
||||||
|
</div>
|
||||||
|
<h2 class="session-expired-title">${title}</h2>
|
||||||
|
<p class="session-expired-message">${message}</p>
|
||||||
|
<button class="session-expired-btn" id="session-expired-login-btn">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||||
|
<polyline points="10 17 15 12 10 7"/>
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Iniciar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Animar entrada
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('#session-expired-login-btn').addEventListener('click', () => {
|
||||||
|
overlayVisible = false;
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.remove();
|
||||||
|
window.location.hash = '#login';
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,10 @@ export function createSidebar(pages, currentPage) {
|
||||||
const sidebarHeader = document.createElement('div');
|
const sidebarHeader = document.createElement('div');
|
||||||
sidebarHeader.className = 'sidebar-header';
|
sidebarHeader.className = 'sidebar-header';
|
||||||
sidebarHeader.innerHTML = /*html*/`
|
sidebarHeader.innerHTML = /*html*/`
|
||||||
<h2>Doli App</h2>
|
<a href="#dashboard" class="sidebar-logo-link">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="sidebar-logo-icon"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
|
||||||
|
<h2>Doli App</h2>
|
||||||
|
</a>
|
||||||
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Toggle sidebar">
|
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Toggle sidebar">
|
||||||
<span class="toggle-icon">☰</span>
|
<span class="toggle-icon">☰</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -14,6 +17,7 @@ export function createSidebar(pages, currentPage) {
|
||||||
|
|
||||||
const nav = document.createElement('nav');
|
const nav = document.createElement('nav');
|
||||||
nav.className = 'sidebar-nav';
|
nav.className = 'sidebar-nav';
|
||||||
|
nav.setAttribute('aria-label', 'Navegación principal');
|
||||||
|
|
||||||
const navList = document.createElement('ul');
|
const navList = document.createElement('ul');
|
||||||
navList.className = 'sidebar-menu';
|
navList.className = 'sidebar-menu';
|
||||||
|
|
@ -25,6 +29,7 @@ export function createSidebar(pages, currentPage) {
|
||||||
link.className = 'sidebar-link';
|
link.className = 'sidebar-link';
|
||||||
if (page.route === currentPage) {
|
if (page.route === currentPage) {
|
||||||
link.classList.add('active');
|
link.classList.add('active');
|
||||||
|
link.setAttribute('aria-current', 'page');
|
||||||
}
|
}
|
||||||
link.innerHTML = /*html*/`
|
link.innerHTML = /*html*/`
|
||||||
<span class="sidebar-icon">${page.icon || '📄'}</span>
|
<span class="sidebar-icon">${page.icon || '📄'}</span>
|
||||||
|
|
@ -42,6 +47,8 @@ export function createSidebar(pages, currentPage) {
|
||||||
const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle');
|
const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle');
|
||||||
toggleBtn.addEventListener('click', () => {
|
toggleBtn.addEventListener('click', () => {
|
||||||
sidebar.classList.toggle('collapsed');
|
sidebar.classList.toggle('collapsed');
|
||||||
|
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||||
|
toggleBtn.setAttribute('aria-expanded', !isCollapsed);
|
||||||
});
|
});
|
||||||
|
|
||||||
return sidebar;
|
return sidebar;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Sistema de notificaciones Toast reutilizable.
|
||||||
|
* Reemplaza todos los alert() del proyecto.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* import { showToast } from '../components/Toast.js';
|
||||||
|
* showToast('Factura creada correctamente', 'success');
|
||||||
|
* showToast('Error al guardar', 'error');
|
||||||
|
* showToast('Atención: campo vacío', 'warning');
|
||||||
|
* showToast('Información adicional', 'info');
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
success: /*html*/`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
|
||||||
|
error: /*html*/`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
|
||||||
|
warning: /*html*/`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
|
||||||
|
info: /*html*/`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`
|
||||||
|
};
|
||||||
|
|
||||||
|
const TITLES = {
|
||||||
|
success: 'Éxito',
|
||||||
|
error: 'Error',
|
||||||
|
warning: 'Atención',
|
||||||
|
info: 'Información'
|
||||||
|
};
|
||||||
|
|
||||||
|
let container = null;
|
||||||
|
|
||||||
|
function getContainer() {
|
||||||
|
if (!container || !document.body.contains(container)) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'toast-container';
|
||||||
|
container.setAttribute('role', 'alert');
|
||||||
|
container.setAttribute('aria-live', 'polite');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Muestra una notificación toast.
|
||||||
|
* @param {string} message - Mensaje a mostrar
|
||||||
|
* @param {'success'|'error'|'warning'|'info'} type - Tipo de notificación
|
||||||
|
* @param {object} options - Opciones adicionales
|
||||||
|
* @param {string} options.title - Título personalizado
|
||||||
|
* @param {number} options.duration - Duración en ms (default: 4000, 0 = no auto-cerrar)
|
||||||
|
*/
|
||||||
|
export function showToast(message, type = 'info', options = {}) {
|
||||||
|
const { title = TITLES[type], duration = 4000 } = options;
|
||||||
|
const toastContainer = getContainer();
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.style.position = 'relative';
|
||||||
|
toast.setAttribute('role', 'status');
|
||||||
|
|
||||||
|
toast.innerHTML = /*html*/`
|
||||||
|
<div class="toast-icon">${ICONS[type]}</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
<p class="toast-title">${title}</p>
|
||||||
|
<p class="toast-message"></p>
|
||||||
|
</div>
|
||||||
|
<button class="toast-close" aria-label="Cerrar notificación">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
${duration > 0 ? '<div class="toast-progress"></div>' : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Set message with textContent to avoid XSS
|
||||||
|
toast.querySelector('.toast-message').textContent = message;
|
||||||
|
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toast.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
if (duration > 0) {
|
||||||
|
const progress = toast.querySelector('.toast-progress');
|
||||||
|
if (progress) {
|
||||||
|
progress.style.width = '100%';
|
||||||
|
progress.style.transitionDuration = `${duration}ms`;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
progress.style.width = '0%';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeToast = () => {
|
||||||
|
toast.classList.remove('visible');
|
||||||
|
toast.classList.add('removing');
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
toast.querySelector('.toast-close').addEventListener('click', removeToast);
|
||||||
|
|
||||||
|
// Auto remove
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(removeToast, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toast;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export function setupCounter(element) {
|
|
||||||
let counter = 0
|
|
||||||
const setCounter = (count) => {
|
|
||||||
counter = count
|
|
||||||
element.innerHTML = `count is ${counter}`
|
|
||||||
}
|
|
||||||
element.addEventListener('click', () => setCounter(counter + 1))
|
|
||||||
setCounter(0)
|
|
||||||
}
|
|
||||||
14
src/main.js
14
src/main.js
|
|
@ -1,4 +1,16 @@
|
||||||
import './style.css'
|
/* Modular CSS imports */
|
||||||
|
import './styles/base.css'
|
||||||
|
import './styles/login.css'
|
||||||
|
import './styles/sidebar.css'
|
||||||
|
import './styles/dashboard.css'
|
||||||
|
import './styles/facturas.css'
|
||||||
|
import './styles/create-invoice.css'
|
||||||
|
import './styles/clientes.css'
|
||||||
|
import './styles/settings.css'
|
||||||
|
import './styles/overlays.css'
|
||||||
|
import './styles/modal.css'
|
||||||
|
import './styles/toast.css'
|
||||||
|
|
||||||
import { initRouter } from './router.js'
|
import { initRouter } from './router.js'
|
||||||
|
|
||||||
initRouter()
|
initRouter()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { ClientItem } from '../components/ClientItem.js';
|
import { ClientItem } from '../components/ClientItem.js';
|
||||||
import { getClients } from '../services/clients.js';
|
import { getClients } from '../services/clients.js';
|
||||||
|
import { escapeHtml } from '../utils/escapeHtml.js';
|
||||||
|
|
||||||
export function renderClientesPage() {
|
export function renderClientesPage() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
|
|
@ -19,7 +20,7 @@ export function renderClientesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clientes-filters">
|
<div class="clientes-filters">
|
||||||
<input type="search" placeholder="Buscar clientes..." class="search-input" />
|
<input type="search" placeholder="Buscar clientes..." class="search-input" aria-label="Buscar clientes" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clients-table-container">
|
<div class="clients-table-container">
|
||||||
|
|
@ -96,7 +97,15 @@ export function renderClientesPage() {
|
||||||
|
|
||||||
// Si no hay clientes
|
// Si no hay clientes
|
||||||
if (clients.length === 0) {
|
if (clients.length === 0) {
|
||||||
clientsList.innerHTML = '<tr><td colspan="7" class="no-clients">No hay clientes disponibles</td></tr>';
|
clientsList.innerHTML = `<tr><td colspan="7">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>No hay clientes</h3>
|
||||||
|
<p>${searchTerm ? 'No se encontraron clientes con ese término de búsqueda.' : 'No hay clientes registrados en el sistema.'}</p>
|
||||||
|
</div>
|
||||||
|
</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +127,7 @@ export function renderClientesPage() {
|
||||||
// Función helper para mostrar N/A si el valor es nulo/vacío/inválido
|
// Función helper para mostrar N/A si el valor es nulo/vacío/inválido
|
||||||
function displayValue(value) {
|
function displayValue(value) {
|
||||||
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
|
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
|
||||||
return value;
|
return escapeHtml(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtener texto de estado del cliente
|
// Obtener texto de estado del cliente
|
||||||
|
|
@ -155,7 +164,7 @@ export function renderClientesPage() {
|
||||||
<div class="client-modal">
|
<div class="client-modal">
|
||||||
<div class="client-modal-header">
|
<div class="client-modal-header">
|
||||||
<h2>${displayValue(client.name)}</h2>
|
<h2>${displayValue(client.name)}</h2>
|
||||||
<button class="btn-close-modal">✕</button>
|
<button class="btn-close-modal" aria-label="Cerrar">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="client-modal-body">
|
<div class="client-modal-body">
|
||||||
<div class="client-info-section">
|
<div class="client-info-section">
|
||||||
|
|
@ -204,6 +213,20 @@ export function renderClientesPage() {
|
||||||
if (e.target === modal) modal.remove();
|
if (e.target === modal) modal.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cerrar con Escape
|
||||||
|
const handleEscape = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
modal.remove();
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
|
||||||
|
// Accesibilidad
|
||||||
|
modal.querySelector('.client-modal').setAttribute('role', 'dialog');
|
||||||
|
modal.querySelector('.client-modal').setAttribute('aria-modal', 'true');
|
||||||
|
modal.querySelector('.client-modal').setAttribute('aria-label', `Detalles de ${displayValue(client.name)}`);
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,11 +235,19 @@ export function renderClientesPage() {
|
||||||
const clientsList = container.querySelector('.clients-list');
|
const clientsList = container.querySelector('.clients-list');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
clientsList.innerHTML = '<tr><td colspan="7" class="loading">Cargando clientes...</td></tr>';
|
clientsList.innerHTML = Array.from({length: 6}, () => `
|
||||||
|
<tr class="skeleton-row">
|
||||||
|
<td><div class="skeleton" style="width:42px;height:42px;border-radius:50%"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-lg" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-sm" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-sm" style="height:24px;border-radius:12px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-lg" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-md" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-sm" style="height:16px"></div></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
const data = await getClients(1000, 1);
|
const data = await getClients(1000, 1);
|
||||||
|
|
||||||
// Manejo de respuesta
|
|
||||||
allClients = Array.isArray(data) ? data : data.data || data.clients || [];
|
allClients = Array.isArray(data) ? data : data.data || data.clients || [];
|
||||||
filteredClients = [...allClients];
|
filteredClients = [...allClients];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { FormChangeTracker } from '../components/ConfirmExitModal.js';
|
import { FormChangeTracker } from '../components/ConfirmExitModal.js';
|
||||||
import { navigationGuards } from '../router.js';
|
import { navigationGuards } from '../router.js';
|
||||||
|
import { authFetch } from '../services/auth.js';
|
||||||
|
import { showToast } from '../components/Toast.js';
|
||||||
|
|
||||||
export function renderCreateInvoicePage() {
|
export function renderCreateInvoicePage() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
|
|
@ -102,7 +104,7 @@ export function renderCreateInvoicePage() {
|
||||||
async function loadClients() {
|
async function loadClients() {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Clients?limit=1000`, {
|
const response = await authFetch(`${import.meta.env.VITE_API_BASE_URL}/api/Clients?limit=1000`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -134,6 +136,19 @@ export function renderCreateInvoicePage() {
|
||||||
expireDate.setDate(expireDate.getDate() + 30);
|
expireDate.setDate(expireDate.getDate() + 30);
|
||||||
container.querySelector('#expireDate').value = expireDate.toISOString().split('T')[0];
|
container.querySelector('#expireDate').value = expireDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// La fecha de vencimiento no puede ser anterior a la fecha de factura
|
||||||
|
const dateInput = container.querySelector('#date');
|
||||||
|
const expireDateInput = container.querySelector('#expireDate');
|
||||||
|
expireDateInput.min = dateInput.value;
|
||||||
|
|
||||||
|
dateInput.addEventListener('change', () => {
|
||||||
|
expireDateInput.min = dateInput.value;
|
||||||
|
// Si la fecha de vencimiento actual es anterior a la nueva fecha, ajustarla
|
||||||
|
if (expireDateInput.value && expireDateInput.value < dateInput.value) {
|
||||||
|
expireDateInput.value = dateInput.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Event listeners para botones de calendario
|
// Event listeners para botones de calendario
|
||||||
container.querySelectorAll('.date-picker-btn').forEach(btn => {
|
container.querySelectorAll('.date-picker-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
|
|
@ -225,6 +240,11 @@ export function renderCreateInvoicePage() {
|
||||||
taxInput.addEventListener('input', updateTotal);
|
taxInput.addEventListener('input', updateTotal);
|
||||||
|
|
||||||
lineDiv.querySelector('.btn-delete-compact').addEventListener('click', () => {
|
lineDiv.querySelector('.btn-delete-compact').addEventListener('click', () => {
|
||||||
|
const currentLines = linesContainer.querySelectorAll('.line-row-compact');
|
||||||
|
if (currentLines.length <= 1) {
|
||||||
|
showToast('La factura debe tener al menos una línea.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
lineDiv.remove();
|
lineDiv.remove();
|
||||||
updateGrandTotal();
|
updateGrandTotal();
|
||||||
});
|
});
|
||||||
|
|
@ -284,13 +304,13 @@ export function renderCreateInvoicePage() {
|
||||||
const clientIdValue = parseInt(container.querySelector('#clientId').value);
|
const clientIdValue = parseInt(container.querySelector('#clientId').value);
|
||||||
|
|
||||||
if (!clientIdValue || isNaN(clientIdValue) || clientIdValue < 1) {
|
if (!clientIdValue || isNaN(clientIdValue) || clientIdValue < 1) {
|
||||||
alert('Selecciona un cliente');
|
showToast('Selecciona un cliente', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = container.querySelectorAll('.line-row-compact');
|
const lines = container.querySelectorAll('.line-row-compact');
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
alert('Añade al menos una línea');
|
showToast('Añade al menos una línea', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,19 +333,19 @@ export function renderCreateInvoicePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidDate(dateValue)) {
|
if (!isValidDate(dateValue)) {
|
||||||
alert('La fecha de factura no es válida. Introduce una fecha entre los años 2000 y 2100.');
|
showToast('La fecha de factura no es válida. Introduce una fecha entre los años 2000 y 2100.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidDate(expireDateValue)) {
|
if (!isValidDate(expireDateValue)) {
|
||||||
alert('La fecha de vencimiento no es válida. Introduce una fecha entre los años 2000 y 2100.');
|
showToast('La fecha de vencimiento no es válida. Introduce una fecha entre los años 2000 y 2100.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateObj = new Date(dateValue);
|
const dateObj = new Date(dateValue);
|
||||||
const expireDateObj = new Date(expireDateValue);
|
const expireDateObj = new Date(expireDateValue);
|
||||||
if (expireDateObj < dateObj) {
|
if (expireDateObj < dateObj) {
|
||||||
alert('La fecha de vencimiento no puede ser anterior a la fecha de factura.');
|
showToast('La fecha de vencimiento no puede ser anterior a la fecha de factura.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,7 +379,7 @@ export function renderCreateInvoicePage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasEmptyLine) {
|
if (hasEmptyLine) {
|
||||||
alert('Completa todos los campos de las líneas');
|
showToast('Completa todos los campos de las líneas', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -369,7 +389,7 @@ export function renderCreateInvoicePage() {
|
||||||
submitBtn.textContent = 'Guardando...';
|
submitBtn.textContent = 'Guardando...';
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Invoices`, {
|
const response = await authFetch(`${import.meta.env.VITE_API_BASE_URL}/api/Invoices`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
|
|
@ -391,12 +411,12 @@ export function renderCreateInvoicePage() {
|
||||||
navigationGuards.unregister();
|
navigationGuards.unregister();
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
|
||||||
alert(`Factura creada (ID: ${invoiceId})`);
|
showToast(`Factura creada (ID: ${invoiceId})`, 'success');
|
||||||
window.location.hash = '#invoices';
|
window.location.hash = '#invoices';
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert(error.message || 'Error al crear la factura');
|
showToast(error.message || 'Error al crear la factura', 'error');
|
||||||
|
|
||||||
const submitBtn = container.querySelector('.btn-submit-compact');
|
const submitBtn = container.querySelector('.btn-submit-compact');
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { auth } from '../services/auth.js';
|
import { auth, authFetch } from '../services/auth.js';
|
||||||
|
import { escapeHtml } from '../utils/escapeHtml.js';
|
||||||
|
|
||||||
export function renderDashboard() {
|
export function renderDashboard() {
|
||||||
const user = auth.getUser();
|
const user = auth.getUser();
|
||||||
|
|
@ -7,18 +8,132 @@ export function renderDashboard() {
|
||||||
|
|
||||||
container.innerHTML = /*html*/`
|
container.innerHTML = /*html*/`
|
||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
<h1>Bienvenido, ${user?.email || 'Usuario'}</h1>
|
<div>
|
||||||
<button id="logout-button" class="logout-button">Cerrar Sesión</button>
|
<h1>Bienvenido, ${escapeHtml(user?.identifier || user?.email || 'Usuario')}</h1>
|
||||||
|
<p class="dashboard-subtitle">Resumen de tu actividad en Dolibarr</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-content">
|
|
||||||
<p>Dashboard protegido</p>
|
<div class="dashboard-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-invoices">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value" id="stat-total"><div class="skeleton" style="width:50px;height:28px;display:inline-block"></div></span>
|
||||||
|
<span class="stat-label">Total Facturas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-paid">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value" id="stat-paid"><div class="skeleton" style="width:40px;height:28px;display:inline-block"></div></span>
|
||||||
|
<span class="stat-label">Pagadas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-unpaid">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value" id="stat-unpaid"><div class="skeleton" style="width:40px;height:28px;display:inline-block"></div></span>
|
||||||
|
<span class="stat-label">Pendientes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-clients">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value" id="stat-clients"><div class="skeleton" style="width:40px;height:28px;display:inline-block"></div></span>
|
||||||
|
<span class="stat-label">Clientes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<h3>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -3px; margin-right: 6px;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
Acciones rápidas
|
||||||
|
</h3>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<a href="#create-invoice" class="quick-action-btn">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Nueva Factura
|
||||||
|
</a>
|
||||||
|
<a href="#invoices" class="quick-action-btn secondary">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
Ver Facturas
|
||||||
|
</a>
|
||||||
|
<a href="#clients" class="quick-action-btn secondary">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
|
||||||
|
Ver Clientes
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<h3>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -3px; margin-right: 6px;"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||||
|
Información
|
||||||
|
</h3>
|
||||||
|
<div class="dashboard-info-list">
|
||||||
|
<div class="dashboard-info-row">
|
||||||
|
<span>Plataforma</span>
|
||||||
|
<span class="dashboard-info-value">Dolibarr ERP</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-info-row">
|
||||||
|
<span>Usuario</span>
|
||||||
|
<span class="dashboard-info-value">${escapeHtml(user?.identifier || user?.email || 'N/A')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-info-row">
|
||||||
|
<span>Estado</span>
|
||||||
|
<span class="status-badge status-paid">Conectado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.querySelector('#logout-button').addEventListener('click', () => {
|
// Cargar estadísticas
|
||||||
auth.logout();
|
loadDashboardStats(container);
|
||||||
window.location.hash = '#login';
|
|
||||||
});
|
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDashboardStats(container) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const apiUrl = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cargar facturas y clientes en paralelo
|
||||||
|
const [invoicesRes, clientsRes] = await Promise.allSettled([
|
||||||
|
authFetch(`${apiUrl}/api/Invoices?limit=1000`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||||
|
}),
|
||||||
|
authFetch(`${apiUrl}/api/Clients?limit=1000`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (invoicesRes.status === 'fulfilled' && invoicesRes.value.ok) {
|
||||||
|
const invoices = await invoicesRes.value.json();
|
||||||
|
const list = Array.isArray(invoices) ? invoices : [];
|
||||||
|
const paid = list.filter(i => i.status === 'paid' || i.status === '2').length;
|
||||||
|
const unpaid = list.filter(i => i.status === 'unpaid' || i.status === '1').length;
|
||||||
|
|
||||||
|
container.querySelector('#stat-total').textContent = list.length;
|
||||||
|
container.querySelector('#stat-paid').textContent = paid;
|
||||||
|
container.querySelector('#stat-unpaid').textContent = unpaid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientsRes.status === 'fulfilled' && clientsRes.value.ok) {
|
||||||
|
const clients = await clientsRes.value.json();
|
||||||
|
container.querySelector('#stat-clients').textContent = Array.isArray(clients) ? clients.length : 0;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando stats del dashboard:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { authFetch } from '../services/auth.js';
|
||||||
|
|
||||||
export function renderFacturasPage() {
|
export function renderFacturasPage() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
|
|
@ -20,8 +21,8 @@ export function renderFacturasPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="facturas-filters">
|
<div class="facturas-filters">
|
||||||
<input type="search" placeholder="Buscar facturas..." class="search-input" />
|
<input type="search" placeholder="Buscar por número o cliente..." class="search-input" aria-label="Buscar facturas" />
|
||||||
<select class="filter-status">
|
<select class="filter-status" aria-label="Filtrar por estado">
|
||||||
<option value="">Todos los estados</option>
|
<option value="">Todos los estados</option>
|
||||||
<option value="draft">Borrador</option>
|
<option value="draft">Borrador</option>
|
||||||
<option value="paid">Pagada</option>
|
<option value="paid">Pagada</option>
|
||||||
|
|
@ -89,7 +90,16 @@ export function renderFacturasPage() {
|
||||||
|
|
||||||
// Si no hay facturas
|
// Si no hay facturas
|
||||||
if (invoices.length === 0) {
|
if (invoices.length === 0) {
|
||||||
invoicesList.innerHTML = '<tr><td colspan="7" class="no-invoices">No hay facturas disponibles</td></tr>';
|
invoicesList.innerHTML = `<tr><td colspan="7">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>No hay facturas</h3>
|
||||||
|
<p>${searchTerm || currentFilter ? 'No se encontraron facturas con los filtros aplicados.' : 'Aún no has creado ninguna factura. \u00a1Crea tu primera factura!'}</p>
|
||||||
|
${!searchTerm && !currentFilter ? '<button class="btn-empty-action" onclick="window.location.hash=\'#create-invoice\'">+ Nueva Factura</button>' : ''}
|
||||||
|
</div>
|
||||||
|
</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,11 +124,19 @@ export function renderFacturasPage() {
|
||||||
const invoicesList = container.querySelector('.invoices-list');
|
const invoicesList = container.querySelector('.invoices-list');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
invoicesList.innerHTML = '<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>';
|
invoicesList.innerHTML = Array.from({length: 6}, () => `
|
||||||
|
<tr class="skeleton-row">
|
||||||
|
<td><div class="skeleton skeleton-cell w-md" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-sm" style="height:24px;border-radius:12px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-lg" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-md" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-sm" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-sm" style="height:16px"></div></td>
|
||||||
|
<td><div class="skeleton skeleton-cell w-sm" style="height:16px"></div></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
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 = `${import.meta.env.VITE_API_BASE_URL}/api/Invoices?limit=1000`;
|
||||||
|
|
||||||
if (currentFilter) {
|
if (currentFilter) {
|
||||||
|
|
@ -129,7 +147,7 @@ export function renderFacturasPage() {
|
||||||
url += `&search=${encodeURIComponent(searchTerm)}`;
|
url += `&search=${encodeURIComponent(searchTerm)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await authFetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
|
|
@ -145,7 +163,18 @@ export function renderFacturasPage() {
|
||||||
|
|
||||||
// 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 || [];
|
||||||
filteredInvoices = [...allInvoices].reverse();
|
|
||||||
|
// Filtrado local adicional por nombre de cliente (la API puede no soportarlo)
|
||||||
|
if (searchTerm && allInvoices.length > 0) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
filteredInvoices = allInvoices.filter(inv => {
|
||||||
|
const numberMatch = inv.number?.toLowerCase().includes(term);
|
||||||
|
const clientMatch = inv.clientName?.toLowerCase().includes(term);
|
||||||
|
return numberMatch || clientMatch;
|
||||||
|
}).reverse();
|
||||||
|
} else {
|
||||||
|
filteredInvoices = [...allInvoices].reverse();
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Total de facturas cargadas: ${allInvoices.length}`);
|
console.log(`Total de facturas cargadas: ${allInvoices.length}`);
|
||||||
|
|
||||||
|
|
@ -193,13 +222,7 @@ export function renderFacturasPage() {
|
||||||
// Debounce para no hacer muchas llamadas
|
// Debounce para no hacer muchas llamadas
|
||||||
clearTimeout(searchInput.debounceTimer);
|
clearTimeout(searchInput.debounceTimer);
|
||||||
searchInput.debounceTimer = setTimeout(() => {
|
searchInput.debounceTimer = setTimeout(() => {
|
||||||
// Solo aplicar búsqueda si contiene al menos un número
|
searchTerm = inputValue;
|
||||||
const hasNumber = /\d/.test(inputValue);
|
|
||||||
if (hasNumber) {
|
|
||||||
searchTerm = inputValue;
|
|
||||||
} else {
|
|
||||||
searchTerm = ''; // Si es muy corto, mostrar todas
|
|
||||||
}
|
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}, 500); // Espera 500ms después de que el usuario deja de escribir
|
}, 500); // Espera 500ms después de que el usuario deja de escribir
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,30 @@
|
||||||
import { auth } from '../services/auth.js';
|
import { auth } from '../services/auth.js';
|
||||||
import { renderLogin } from '../components/Login.js';
|
import { renderLogin } from '../components/Login.js';
|
||||||
|
import { showSessionExpiredOverlay } from '../components/SessionExpiredOverlay.js';
|
||||||
|
|
||||||
export function renderLoginPage(onLoginSuccess) {
|
export function renderLoginPage(onLoginSuccess) {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'login-page';
|
container.className = 'login-page';
|
||||||
|
|
||||||
const loginComponent = renderLogin(async (email, password) => {
|
const loginComponent = renderLogin(async (email, password) => {
|
||||||
if (await auth.login(email, password)) {
|
try {
|
||||||
if (onLoginSuccess) {
|
if (await auth.login(email, password)) {
|
||||||
onLoginSuccess();
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess();
|
||||||
|
} else {
|
||||||
|
window.location.hash = '#dashboard';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.hash = '#dashboard';
|
const error = container.querySelector('#login-error');
|
||||||
|
error.textContent = 'Credenciales inválidas';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === 'NETWORK_ERROR') {
|
||||||
|
showSessionExpiredOverlay('disconnected');
|
||||||
|
} else {
|
||||||
|
const errorEl = container.querySelector('#login-error');
|
||||||
|
errorEl.textContent = 'Error inesperado. Inténtalo de nuevo.';
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const error = container.querySelector('#login-error');
|
|
||||||
error.textContent = 'Credenciales inválidas';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { renderDashboard } from './DashboardPage.js';
|
||||||
import { renderFacturasPage } from './Facturas.js';
|
import { renderFacturasPage } from './Facturas.js';
|
||||||
import { renderCreateInvoicePage } from './CreateInvoicePage.js';
|
import { renderCreateInvoicePage } from './CreateInvoicePage.js';
|
||||||
import { renderClientesPage } from './ClientesPage.js';
|
import { renderClientesPage } from './ClientesPage.js';
|
||||||
|
import { authFetch } from '../services/auth.js';
|
||||||
|
import { showConfirmDialog } from '../components/ConfirmDialog.js';
|
||||||
|
import { showToast } from '../components/Toast.js';
|
||||||
|
|
||||||
// Registry of all pages available in the application
|
// Registry of all pages available in the application
|
||||||
export const pagesRegistry = [
|
export const pagesRegistry = [
|
||||||
|
|
@ -45,7 +48,127 @@ export const pagesRegistry = [
|
||||||
showInSidebar: true,
|
showInSidebar: true,
|
||||||
render: () => {
|
render: () => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.innerHTML = '<h1>Configuración</h1><p>Página en construcción...</p>';
|
div.className = 'settings-page';
|
||||||
|
|
||||||
|
const apiUrl = import.meta.env.VITE_API_BASE_URL || 'No configurada';
|
||||||
|
|
||||||
|
div.innerHTML = /*html*/`
|
||||||
|
<div class="settings-header">
|
||||||
|
<h1>Configuración</h1>
|
||||||
|
<p class="settings-subtitle">Ajustes de la aplicación y sesión</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<!-- Sesión -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-icon session-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Sesión</h3>
|
||||||
|
<div class="settings-info">
|
||||||
|
<div class="settings-info-row">
|
||||||
|
<span>Estado:</span>
|
||||||
|
<span class="status-badge status-paid">Activa</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-info-row">
|
||||||
|
<span>Token:</span>
|
||||||
|
<span class="settings-token">${localStorage.getItem('token') ? '••••••••' + localStorage.getItem('token').slice(-8) : 'No disponible'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-settings btn-danger-settings" id="btn-logout">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
Cerrar Sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-icon api-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Conexión API</h3>
|
||||||
|
<div class="settings-info">
|
||||||
|
<div class="settings-info-row">
|
||||||
|
<span>URL:</span>
|
||||||
|
<span class="settings-api-url">${apiUrl}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-info-row">
|
||||||
|
<span>Estado:</span>
|
||||||
|
<span class="settings-api-status" id="api-status">Comprobando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-settings btn-outline-settings" id="btn-check-api">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
|
Comprobar Conexión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Acerca de -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-icon about-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Acerca de</h3>
|
||||||
|
<div class="settings-info">
|
||||||
|
<div class="settings-info-row">
|
||||||
|
<span>Aplicación:</span>
|
||||||
|
<strong>Doli App</strong>
|
||||||
|
</div>
|
||||||
|
<div class="settings-info-row">
|
||||||
|
<span>Versión:</span>
|
||||||
|
<span>1.0.0</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-info-row">
|
||||||
|
<span>Backend:</span>
|
||||||
|
<span>Dolibarr BFF</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
div.querySelector('#btn-logout').addEventListener('click', async () => {
|
||||||
|
const confirmed = await showConfirmDialog({
|
||||||
|
title: '¿Cerrar sesión?',
|
||||||
|
message: 'Se cerrará tu sesión y tendrás que volver a iniciar sesión.',
|
||||||
|
confirmText: 'Cerrar sesión',
|
||||||
|
cancelText: 'Cancelar',
|
||||||
|
variant: 'warning'
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
window.location.hash = '#login';
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check API
|
||||||
|
async function checkApi() {
|
||||||
|
const statusEl = div.querySelector('#api-status');
|
||||||
|
statusEl.textContent = 'Comprobando...';
|
||||||
|
statusEl.className = 'settings-api-status';
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await authFetch(`${apiUrl}/api/Invoices?limit=1`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
statusEl.textContent = '✓ Conectado';
|
||||||
|
statusEl.classList.add('api-ok');
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = '✗ Error ' + res.status;
|
||||||
|
statusEl.classList.add('api-error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
statusEl.textContent = '✗ Sin conexión';
|
||||||
|
statusEl.classList.add('api-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.querySelector('#btn-check-api').addEventListener('click', checkApi);
|
||||||
|
checkApi();
|
||||||
|
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
export function renderTestPage() {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.className = 'test-page';
|
|
||||||
|
|
||||||
container.innerHTML = /*html*/`
|
|
||||||
<h1>Test Page</h1>
|
|
||||||
<p>This is a test page to verify sidebar navigation functionality.</p>
|
|
||||||
<div class="test-content">
|
|
||||||
<p>The sidebar should be visible and allow navigation between different pages.</p>
|
|
||||||
<button id="test-btn" class="test-button">Test Button</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.querySelector('#test-btn').addEventListener('click', () => {
|
|
||||||
alert('Test button clicked!');
|
|
||||||
});
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
@ -34,8 +34,26 @@ export function initRouter() {
|
||||||
const app = document.querySelector('#app');
|
const app = document.querySelector('#app');
|
||||||
let isNavigating = false;
|
let isNavigating = false;
|
||||||
let pendingHash = null;
|
let pendingHash = null;
|
||||||
|
let tokenValidated = false;
|
||||||
|
|
||||||
async function navigate(forceNavigate = false) {
|
async function navigate(forceNavigate = false) {
|
||||||
|
// En la primera navegación, intentar restaurar sesión con token existente
|
||||||
|
if (!tokenValidated && !DEV_MODE) {
|
||||||
|
tokenValidated = true;
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token && !auth.isAuthenticated) {
|
||||||
|
const valid = await auth.validateToken();
|
||||||
|
if (valid) {
|
||||||
|
// Token válido, redirigir al dashboard si estaban en login
|
||||||
|
const currentHash = window.location.hash || '#login';
|
||||||
|
if (currentHash === '#login' || currentHash === '' || currentHash === '#') {
|
||||||
|
window.location.hash = '#dashboard';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hash = window.location.hash || (DEV_MODE ? '#dashboard' : '#login');
|
const hash = window.location.hash || (DEV_MODE ? '#dashboard' : '#login');
|
||||||
const route = hash.substring(1);
|
const route = hash.substring(1);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,51 @@
|
||||||
|
import { showSessionExpiredOverlay } from '../components/SessionExpiredOverlay.js';
|
||||||
|
|
||||||
export class Auth {
|
export class Auth {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.currentUser = null;
|
this.currentUser = null;
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si el token almacenado sigue siendo válido
|
||||||
|
* haciendo una petición ligera al backend.
|
||||||
|
* Si es válido, restaura la sesión. Si no, limpia localStorage.
|
||||||
|
*/
|
||||||
|
async validateToken() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const user = localStorage.getItem('user');
|
||||||
|
if (!token || !user) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = `${import.meta.env.VITE_API_BASE_URL}/api/Invoices?limit=1`;
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.currentUser = JSON.parse(user);
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
console.log('🔄 Sesión restaurada con token existente');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Token inválido o expirado, limpiando sesión');
|
||||||
|
this.logout();
|
||||||
|
showSessionExpiredOverlay();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ No se pudo validar el token (sin conexión):', error.message);
|
||||||
|
// Si no hay conexión, mantenemos la sesión por si vuelve
|
||||||
|
this.currentUser = JSON.parse(user);
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async login(identifier, password) {
|
async login(identifier, password) {
|
||||||
if (!identifier || !password) return false;
|
if (!identifier || !password) return false;
|
||||||
|
|
||||||
|
|
@ -43,6 +85,10 @@ export class Auth {
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Login error:', error);
|
console.error('❌ Login error:', error);
|
||||||
|
// Diferenciar error de red de otros errores
|
||||||
|
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||||
|
throw new Error('NETWORK_ERROR');
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -78,3 +124,30 @@ export class Auth {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auth = new Auth();
|
export const auth = new Auth();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper global de fetch que detecta respuestas 401
|
||||||
|
* y errores de red, mostrando el overlay correspondiente.
|
||||||
|
*/
|
||||||
|
export async function authFetch(url, options = {}) {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(url, options);
|
||||||
|
} catch (error) {
|
||||||
|
// Error de red: backend caído o sin conexión
|
||||||
|
console.warn('🔌 Sin conexión con el servidor:', error.message);
|
||||||
|
auth.logout();
|
||||||
|
showSessionExpiredOverlay('disconnected');
|
||||||
|
throw new Error('No se pudo conectar con el servidor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.warn('🔒 Sesión expirada');
|
||||||
|
auth.logout();
|
||||||
|
showSessionExpiredOverlay('expired');
|
||||||
|
throw new Error('Sesión expirada. Por favor, inicia sesión de nuevo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { authFetch } from './auth.js';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
function getAuthHeaders() {
|
function getAuthHeaders() {
|
||||||
|
|
@ -9,7 +11,7 @@ function getAuthHeaders() {
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Clients?limit=${limit}&page=${page}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
@ -20,16 +22,3 @@ export async function getClients(limit = 50, page = 1) {
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { authFetch } from './auth.js';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
function getAuthHeaders() {
|
function getAuthHeaders() {
|
||||||
|
|
@ -9,7 +11,7 @@ function getAuthHeaders() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInvoiceById(id) {
|
export async function getInvoiceById(id) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${id}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
@ -22,7 +24,7 @@ export async function getInvoiceById(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateInvoice(id, data) {
|
export async function updateInvoice(id, data) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
|
|
@ -37,7 +39,7 @@ export async function updateInvoice(id, data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateInvoice(id) {
|
export async function validateInvoice(id) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/validate`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${id}/validate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
@ -51,7 +53,7 @@ export async function validateInvoice(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateInvoiceStatus(id, status) {
|
export async function updateInvoiceStatus(id, status) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/status`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${id}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
|
|
@ -66,7 +68,7 @@ export async function updateInvoiceStatus(id, status) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addInvoiceLine(id, lineData) {
|
export async function addInvoiceLine(id, lineData) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/lines`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${id}/lines`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify(lineData)
|
body: JSON.stringify(lineData)
|
||||||
|
|
@ -81,7 +83,7 @@ export async function addInvoiceLine(id, lineData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInvoiceLine(invoiceId, lineId) {
|
export async function deleteInvoiceLine(invoiceId, lineId) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/lines/${lineId}`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/lines/${lineId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
@ -95,7 +97,7 @@ export async function deleteInvoiceLine(invoiceId, lineId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addPayment(invoiceId, paymentData) {
|
export async function addPayment(invoiceId, paymentData) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify(paymentData)
|
body: JSON.stringify(paymentData)
|
||||||
|
|
@ -110,7 +112,7 @@ export async function addPayment(invoiceId, paymentData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPayments(invoiceId) {
|
export async function getPayments(invoiceId) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
@ -127,7 +129,7 @@ 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}`, {
|
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1818
src/style.css
1818
src/style.css
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,347 @@
|
||||||
|
/* ===================== BASE STYLES & CSS VARIABLES ===================== */
|
||||||
|
:root {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
color: #1a2332;
|
||||||
|
background-color: #f0f4f8;
|
||||||
|
|
||||||
|
--primary: #0d9488;
|
||||||
|
--primary-hover: #0f766e;
|
||||||
|
--primary-light: rgba(13, 148, 136, 0.1);
|
||||||
|
--success: #059669;
|
||||||
|
--success-hover: #047857;
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-hover: #b45309;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-hover: #b91c1c;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--text-primary: #1a2332;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--sidebar-bg: #1a2332;
|
||||||
|
--sidebar-text: #94a3b8;
|
||||||
|
--sidebar-active: #0d9488;
|
||||||
|
--sidebar-hover: rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.25s, border-color 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared status badges */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paid {
|
||||||
|
background-color: #d1fae5;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-overdue {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-draft {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-validated {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-unpaid {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-canceled {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #000000;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared form group */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared button styles */
|
||||||
|
.btn-view {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading/error shared */
|
||||||
|
.loading,
|
||||||
|
.error,
|
||||||
|
.no-invoices,
|
||||||
|
.no-clients {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination (shared between facturas & clientes) */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info .current-page,
|
||||||
|
.pagination-info .total-pages {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination:hover:not(:disabled) {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination:disabled {
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pagination {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loader animation */
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-cell {
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-cell.w-sm { width: 60px; }
|
||||||
|
.skeleton-cell.w-md { width: 120px; }
|
||||||
|
.skeleton-cell.w-lg { width: 200px; }
|
||||||
|
.skeleton-cell.w-xl { width: 100%; }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .btn-empty-action {
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .btn-empty-action:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
/* ===================== CLIENTES PAGE STYLES ===================== */
|
||||||
|
.clientes-page {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clientes-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clientes-header h1 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clientes-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clientes-filters .search-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clients Table */
|
||||||
|
.clients-table-container {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table thead {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table tbody tr:hover {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table td {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-avatar-col {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-avatar-cell {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-name-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-company-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-code {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-email .email-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-phone {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-actions {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-actions .btn-action {
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table .client-actions-col {
|
||||||
|
width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table td.client-actions {
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table td.client-actions .btn-view {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge for clients */
|
||||||
|
.status-badge-client {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-client.status-active {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-client.status-inactive {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-client.status-unknown {
|
||||||
|
background: rgba(100, 116, 139, 0.1);
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client Modal */
|
||||||
|
.client-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-modal {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-modal:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-info-section,
|
||||||
|
.contacts-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-info-section h3,
|
||||||
|
.contacts-section h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-full-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-row .label {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-contacts {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.clientes-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table th,
|
||||||
|
.clients-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-email-col,
|
||||||
|
.client-email,
|
||||||
|
.client-type-col,
|
||||||
|
.client-type {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.clientes-filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clientes-filters .search-input {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table tbody tr.client-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-table td {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-avatar-cell {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-status,
|
||||||
|
.client-phone {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-type-col,
|
||||||
|
.client-type {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
/* ===================== CREATE INVOICE PAGE ===================== */
|
||||||
|
.create-invoice-page {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-header-compact {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-header-compact h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-form-compact {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid-compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-grid-compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-compact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-compact.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-compact label {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-compact input,
|
||||||
|
.form-field-compact select,
|
||||||
|
.form-field-compact textarea {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: #ffffff;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 38px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-compact input:focus,
|
||||||
|
.form-field-compact select:focus,
|
||||||
|
.form-field-compact textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date input wrapper */
|
||||||
|
.date-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input-wrapper input[type="date"] {
|
||||||
|
flex: 1;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input-wrapper input[type="date"]:focus {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-top-right-radius: 6px;
|
||||||
|
border-bottom-right-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker-btn:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lines Section */
|
||||||
|
.lines-section-compact {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines-header-compact {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines-header-compact h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-compact {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-compact:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines-table-compact {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column headers */
|
||||||
|
.lines-columns-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4fr 0.8fr 1.2fr 0.8fr 1.2fr 40px;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-row-compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4fr 0.8fr 1.2fr 0.8fr 1.2fr 40px;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-row-compact:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-label-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-desc-compact,
|
||||||
|
.line-qty-compact,
|
||||||
|
.line-price-compact,
|
||||||
|
.line-tax-compact {
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: white;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-desc-compact {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-desc-compact:focus,
|
||||||
|
.line-qty-compact:focus,
|
||||||
|
.line-price-compact:focus,
|
||||||
|
.line-tax-compact:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-total-compact {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
text-align: right;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
background: rgba(13, 148, 136, 0.04);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-compact {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-compact:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grand total */
|
||||||
|
.invoice-grand-total {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 300px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grand-total-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grand-total-row strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grand-total-final {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 2px solid var(--border-color);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grand-total-final strong {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form actions */
|
||||||
|
.form-actions-compact {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel-compact {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel-compact:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit-compact {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit-compact:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit-compact:disabled {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.create-invoice-page {
|
||||||
|
padding: 0.5rem;
|
||||||
|
height: calc(100vh - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid-compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-compact.full-width {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines-columns-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-row-compact {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-label-mobile {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-field-desc {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-field-actions {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-grand-total {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
/* ===================== DASHBOARD STYLES ===================== */
|
||||||
|
.dashboard {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Stats */
|
||||||
|
.dashboard-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-invoices {
|
||||||
|
background: rgba(13, 148, 136, 0.1);
|
||||||
|
color: #0d9488;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-paid {
|
||||||
|
background: rgba(5, 150, 105, 0.1);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-unpaid {
|
||||||
|
background: rgba(217, 119, 6, 0.1);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-clients {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Grid */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card h3 {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Actions */
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateX(4px);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn.secondary {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn.secondary:hover {
|
||||||
|
background: rgba(13, 148, 136, 0.18);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Info List */
|
||||||
|
.dashboard-info-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-info-row span:first-child {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-info-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat skeleton */
|
||||||
|
.stat-card .skeleton-value {
|
||||||
|
width: 50px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
/* ===================== FACTURAS PAGE STYLES ===================== */
|
||||||
|
.facturas-page {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facturas-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facturas-header h1 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-invoice {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-invoice:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facturas-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.filter-status {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-status {
|
||||||
|
min-width: 200px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invoices Table */
|
||||||
|
.invoices-table-container {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoices-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoices-table thead {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoices-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoices-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoices-table tbody tr:hover {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoices-table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoices-table td {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-number {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-status .status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-client {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-date,
|
||||||
|
.invoice-total,
|
||||||
|
.invoice-remain {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-total,
|
||||||
|
.invoice-remain {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.facturas-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.facturas-filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/* ===================== LOGIN STYLES ===================== */
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #1a2332 0%, #0d9488 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 2.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
animation: loginSlideUp 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loginSlideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo-icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
background: linear-gradient(135deg, #0d9488 0%, #1a2332 100%);
|
||||||
|
border-radius: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.25s, opacity 0.25s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button .spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--danger);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
@ -426,6 +426,23 @@
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-outline:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-outline:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Invoice Actions Buttons */
|
/* Invoice Actions Buttons */
|
||||||
.invoice-actions {
|
.invoice-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -589,18 +606,25 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-exit-icon {
|
.confirm-exit-icon {
|
||||||
width: 72px;
|
width: 80px;
|
||||||
height: 72px;
|
height: 80px;
|
||||||
margin: 0 auto 1.25rem;
|
margin: 0 auto 1.25rem;
|
||||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
background: linear-gradient(135deg, #fef9c3 0%, #fde68a 50%, #fcd34d 100%);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-shadow: 0 0 0 8px rgba(251, 191, 36, 0.12), 0 4px 12px rgba(245, 158, 11, 0.2);
|
||||||
|
animation: warningPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes warningPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0.12), 0 4px 12px rgba(245, 158, 11, 0.2); }
|
||||||
|
50% { box-shadow: 0 0 0 12px rgba(251, 191, 36, 0.08), 0 4px 16px rgba(245, 158, 11, 0.25); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-exit-icon svg {
|
.confirm-exit-icon svg {
|
||||||
color: #d97706;
|
filter: drop-shadow(0 2px 4px rgba(217, 119, 6, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-exit-title {
|
.confirm-exit-title {
|
||||||
|
|
@ -681,70 +705,104 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ===================== CONFIRM DIALOG ===================== */
|
||||||
Blocked Navigation Toast Styles
|
.confirm-dialog-overlay {
|
||||||
============================================ */
|
position: fixed;
|
||||||
.blocked-nav-toast {
|
top: 0;
|
||||||
position: fixed;
|
left: 0;
|
||||||
top: 20px;
|
right: 0;
|
||||||
left: 50%;
|
bottom: 0;
|
||||||
transform: translateX(-50%) translateY(-100px);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
z-index: 3000;
|
backdrop-filter: blur(4px);
|
||||||
opacity: 0;
|
display: flex;
|
||||||
transition: all 0.3s ease-out;
|
align-items: center;
|
||||||
pointer-events: none;
|
justify-content: center;
|
||||||
|
z-index: 10001;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocked-nav-toast.visible {
|
.confirm-dialog-overlay.visible {
|
||||||
transform: translateX(-50%) translateY(0);
|
opacity: 1;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocked-nav-toast.hiding {
|
.confirm-dialog-card {
|
||||||
transform: translateX(-50%) translateY(-20px);
|
background: var(--card-bg, #fff);
|
||||||
opacity: 0;
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: scale(0.9) translateY(10px);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocked-nav-toast-content {
|
.confirm-dialog-overlay.visible .confirm-dialog-card {
|
||||||
display: flex;
|
transform: scale(1) translateY(0);
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1.25rem;
|
|
||||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 10px 25px -5px rgba(251, 191, 36, 0.3),
|
|
||||||
0 4px 6px -2px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocked-nav-icon {
|
.confirm-dialog-icon {
|
||||||
color: #d97706;
|
width: 56px;
|
||||||
flex-shrink: 0;
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocked-nav-message {
|
.confirm-dialog-title {
|
||||||
font-size: 0.9375rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
color: #92400e;
|
color: var(--text-primary, #1a2332);
|
||||||
white-space: nowrap;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
.confirm-dialog-message {
|
||||||
.blocked-nav-toast {
|
font-size: 0.9rem;
|
||||||
left: 1rem;
|
color: var(--text-secondary, #64748b);
|
||||||
right: 1rem;
|
line-height: 1.5;
|
||||||
transform: translateX(0) translateY(-100px);
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocked-nav-toast.visible {
|
.confirm-dialog-actions {
|
||||||
transform: translateX(0) translateY(0);
|
display: flex;
|
||||||
}
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
.blocked-nav-toast.hiding {
|
}
|
||||||
transform: translateX(0) translateY(-20px);
|
|
||||||
}
|
.confirm-dialog-btn-cancel {
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
.blocked-nav-message {
|
background: transparent;
|
||||||
white-space: normal;
|
border: 1px solid var(--border-color, #e2e8f0);
|
||||||
}
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-btn-cancel:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--text-primary, #1a2332);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-btn-confirm {
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-btn-confirm:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-btn-confirm:active {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/* ===================== SESSION EXPIRED OVERLAY ===================== */
|
||||||
|
.session-expired-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-card {
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-overlay.visible .session-expired-card {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #e53e3e, #c53030);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1.25rem;
|
||||||
|
color: #fff;
|
||||||
|
animation: expiredPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes expiredPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 62, 62, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 12px rgba(229, 62, 62, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-icon.disconnected {
|
||||||
|
background: linear-gradient(135deg, #ed8936, #dd6b20);
|
||||||
|
animation: disconnectedPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes disconnectedPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(237, 137, 54, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 12px rgba(237, 137, 54, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a202c);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-message {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary, #718096);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: linear-gradient(135deg, #0d9488, #1a2332);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.25s;
|
||||||
|
box-shadow: 0 4px 15px rgba(13, 148, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(13, 148, 136, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-expired-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
/* ===================== SETTINGS PAGE ===================== */
|
||||||
|
.settings-page {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid layout */
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card base */
|
||||||
|
.settings-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
transition: box-shadow 0.25s, transform 0.25s;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card:hover {
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon circles */
|
||||||
|
.settings-card-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-icon svg {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-icon {
|
||||||
|
background: linear-gradient(135deg, #0d9488, #0f766e);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-icon {
|
||||||
|
background: linear-gradient(135deg, #48bb78, #38a169);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-icon {
|
||||||
|
background: linear-gradient(135deg, #9f7aea, #805ad5);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card title */
|
||||||
|
.settings-card h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info rows */
|
||||||
|
.settings-info {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-row strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-row span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: right;
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Token preview */
|
||||||
|
.settings-token {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
background: rgba(13, 148, 136, 0.08);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API url */
|
||||||
|
.settings-api-url {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API status */
|
||||||
|
.settings-api-status {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-ok {
|
||||||
|
color: #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-error {
|
||||||
|
color: #e53e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-settings {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-settings:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-settings {
|
||||||
|
background: #e53e3e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-settings:hover {
|
||||||
|
background: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-settings {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary);
|
||||||
|
border: 1.5px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-settings:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-row span {
|
||||||
|
text-align: left;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
/* ===================== SIDEBAR STYLES ===================== */
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 250px;
|
||||||
|
background-color: var(--sidebar-bg);
|
||||||
|
border-right: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width 0.3s ease, transform 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1.25rem 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 64px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-link:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-icon {
|
||||||
|
color: var(--sidebar-active);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle:hover {
|
||||||
|
background-color: var(--sidebar-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover {
|
||||||
|
background-color: var(--sidebar-hover);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link.active {
|
||||||
|
background-color: rgba(13, 148, 136, 0.15);
|
||||||
|
color: var(--sidebar-active);
|
||||||
|
border-left: 3px solid var(--sidebar-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed Sidebar State */
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-header h2,
|
||||||
|
.sidebar.collapsed .sidebar-text {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-logo-link {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-header {
|
||||||
|
padding: 1.25rem 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-icon {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Area */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #f0f4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content>div {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
/* ===================== TOAST NOTIFICATIONS ===================== */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 11000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 420px;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(120%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.removing {
|
||||||
|
transform: translateX(120%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.25s ease-in, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-success .toast-icon {
|
||||||
|
background: rgba(5, 150, 105, 0.12);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-error .toast-icon {
|
||||||
|
background: rgba(220, 38, 38, 0.12);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-warning .toast-icon {
|
||||||
|
background: rgba(217, 119, 6, 0.12);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-info .toast-icon {
|
||||||
|
background: rgba(13, 148, 136, 0.12);
|
||||||
|
color: #0d9488;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary, #1a2332);
|
||||||
|
margin: 0 0 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:hover {
|
||||||
|
color: var(--text-primary, #1a2332);
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-progress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
transition: width linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-success .toast-progress { background: #059669; }
|
||||||
|
.toast.toast-error .toast-progress { background: #dc2626; }
|
||||||
|
.toast.toast-warning .toast-progress { background: #d97706; }
|
||||||
|
.toast.toast-info .toast-progress { background: #0d9488; }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.toast-container {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Escapa caracteres HTML especiales para prevenir XSS
|
||||||
|
* cuando se inyecta texto en innerHTML / template literals.
|
||||||
|
* @param {*} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function escapeHtml(value) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue