mejorar la interfaz

This commit is contained in:
Алекс 2026-02-06 23:00:04 +01:00
parent c24564dc35
commit 9c75ff154e
38 changed files with 3355 additions and 2071 deletions

View File

@ -3,9 +3,9 @@
<head>
<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" />
<title>doli-front</title>
<title>Doli App</title>
<link rel="stylesheet" href="/src/styles/modal.css" />
</head>

14
public/doli.svg Normal file
View File

@ -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

View File

@ -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;
}

View File

@ -1,7 +1,9 @@
import { escapeHtml } from '../utils/escapeHtml.js';
// Helper para mostrar N/A si el valor es nulo/vacío/inválido
function displayValue(value) {
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
return value;
return escapeHtml(value);
}
export function ClientItem(client, onView) {
@ -50,7 +52,7 @@ export function ClientItem(client, onView) {
item.innerHTML = /*html*/`
<td class="client-avatar-cell">
<div class="client-avatar" style="background-color: ${getAvatarColor()}">
${getInitials()}
${escapeHtml(getInitials())}
</div>
</td>
<td class="client-name">
@ -68,7 +70,7 @@ export function ClientItem(client, onView) {
</td>
<td class="client-phone">${displayValue(client.phone)}</td>
<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">
<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"/>

View File

@ -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();
});
}

View File

@ -25,10 +25,10 @@ export function ConfirmExitModal(options = {}) {
modal.innerHTML = /*html*/`
<div class="confirm-exit-modal">
<div class="confirm-exit-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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 width="52" height="52" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L1 21h22L12 2z" fill="#f59e0b" stroke="#d97706" stroke-width="1" stroke-linejoin="round"/>
<rect x="11" y="9" width="2" height="5" rx="1" fill="white"/>
<circle cx="12" cy="16.5" r="1.2" fill="white"/>
</svg>
</div>
<h3 class="confirm-exit-title">${title}</h3>

View File

@ -1,3 +1,5 @@
import { escapeHtml } from '../utils/escapeHtml.js';
export function InvoiceItem(invoice, onView) {
const item = document.createElement('tr');
item.className = 'invoice-item';
@ -41,21 +43,21 @@ export function InvoiceItem(invoice, onView) {
};
item.innerHTML = /*html*/`
<td class="invoice-number">${invoice.number}</td>
<td class="invoice-number">${escapeHtml(invoice.number)}</td>
<td class="invoice-status">
<span class="status-badge ${getStatusClass(invoice.status)}">
<span class="status-badge ${getStatusClass(invoice.status)}" role="status">
${getStatusText(invoice.status)}
</span>
</td>
<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>
${invoice.clientName || 'Sin nombre'}
${escapeHtml(invoice.clientName || 'Sin nombre')}
</td>
<td class="invoice-date">${formatDate(invoice.date)}</td>
<td class="invoice-total">${formatCurrency(invoice.total)}</td>
<td class="invoice-remain">${formatCurrency(invoice.remainToPay)}</td>
<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>
</button>
</td>

View File

@ -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 { showToast } from './Toast.js';
import { showConfirmDialog } from './ConfirmDialog.js';
import { escapeHtml } from '../utils/escapeHtml.js';
export function InvoiceModal(invoiceId, onClose, onUpdate) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-label', 'Detalles de factura');
let invoice = null;
let payments = [];
@ -55,7 +61,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
modalContent.innerHTML = `
<div class="modal-header">
<h2>Cargando...</h2>
<button class="btn-close">×</button>
<button class="btn-close" aria-label="Cerrar">×</button>
</div>
<div class="modal-body">
<div class="loading">Cargando detalles de la factura...</div>
@ -68,7 +74,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
modalContent.innerHTML = `
<div class="modal-header">
<h2>Error</h2>
<button class="btn-close">×</button>
<button class="btn-close" aria-label="Cerrar">×</button>
</div>
<div class="modal-body">
<div class="error">No se pudo cargar la factura</div>
@ -83,12 +89,12 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
modalContent.innerHTML = `
<div class="modal-header">
<div class="modal-title">
<h2>${invoice.number || 'Nueva Factura'}</h2>
<h2>${escapeHtml(invoice.number || 'Nueva Factura')}</h2>
<span class="status-badge status-${invoice.status}">
${getStatusText(invoice.status)}
</span>
</div>
<button class="btn-close">×</button>
<button class="btn-close" aria-label="Cerrar">×</button>
</div>
<div class="modal-body">
@ -103,7 +109,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<input
type="text"
name="number"
value="${invoice.number || ''}"
value="${escapeHtml(invoice.number || '')}"
disabled
/>
</div>
@ -126,6 +132,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
type="date"
name="expireDate"
value="${formatDateForInput(invoice.expireDate)}"
min="${formatDateForInput(invoice.date)}"
${!canEdit ? 'disabled' : ''}
/>
</div>
@ -144,14 +151,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<div class="form-row">
<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>
<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 class="form-row">
<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>
<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>
@ -210,7 +217,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
${invoice.lines && invoice.lines.length > 0
? invoice.lines.map(line => `
<tr data-line-id="${line.id}">
<td>${line.description || ''}</td>
<td>${escapeHtml(line.description || '')}</td>
<td>${line.quantity || 0}</td>
<td>${formatCurrency(line.unitPrice)}</td>
<td>${line.taxRate || 0}%</td>
@ -296,7 +303,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
</div>
<div class="form-group">
<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 class="form-row">
@ -320,9 +327,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<div class="modal-footer">
<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-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 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>' : ''}
</div>
</div>
@ -386,6 +394,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
const payBtn = modal.querySelector('#btn-pay');
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
modal.addEventListener('click', (e) => {
if (e.target === modal) {
@ -442,6 +458,15 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
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
Object.keys(data).forEach(key => {
if (data[key] === undefined || data[key] === '') {
@ -460,12 +485,12 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
savedSuccessfully = true;
changeTracker.markAsSaved();
alert('Factura actualizada correctamente');
showToast('Factura actualizada correctamente', 'success');
if (onUpdate) onUpdate();
handleClose();
} catch (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 {
const saveBtn = modal.querySelector('.btn-save');
if (saveBtn) {
@ -477,9 +502,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
// Manejar validación
const handleValidate = async () => {
if (!confirm('¿Estás seguro de que quieres validar esta factura? No podrás editarla completamente después.')) {
return;
}
const confirmed = await showConfirmDialog({
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 {
const validateBtn = modal.querySelector('.btn-validate');
@ -492,7 +522,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
savedSuccessfully = true;
changeTracker.markAsSaved();
alert('Factura validada correctamente');
showToast('Factura validada correctamente', 'success');
if (onUpdate) onUpdate();
// Recargar la factura para mostrar el nuevo estado
@ -502,7 +532,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
savedSuccessfully = false;
} catch (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');
if (validateBtn) {
@ -537,22 +567,22 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
const taxRate = parseFloat(modal.querySelector('#line-tax').value);
if (!description) {
alert('La descripción es obligatoria');
showToast('La descripción es obligatoria', 'warning');
return;
}
if (!quantity || quantity <= 0) {
alert('La cantidad debe ser mayor que 0');
showToast('La cantidad debe ser mayor que 0', 'warning');
return;
}
if (unitPrice < 0) {
alert('El precio no puede ser negativo');
showToast('El precio no puede ser negativo', 'warning');
return;
}
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;
}
@ -568,7 +598,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
taxRate
});
alert('Línea añadida correctamente');
showToast('Línea añadida correctamente', 'success');
toggleAddLineForm();
// Recargar y recapturar estado inicial
@ -576,7 +606,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
changeTracker.markAsSaved();
} catch (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 {
const saveBtn = modal.querySelector('.btn-save-line');
if (saveBtn) {
@ -588,10 +618,22 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
// Eliminar línea de factura
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;
}
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 {
// Deshabilitar el botón mientras se elimina
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
@ -607,7 +649,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
changeTracker.markAsSaved();
} catch (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}"]`);
if (btn) {
@ -632,7 +674,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
let amount = null;
if (!isNaN(rawAmount) && rawAmount > 0) {
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;
}
amount = rawAmount;
@ -640,7 +682,13 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
const paymentDate = dateInput.value;
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;
}
@ -664,14 +712,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
changeTracker.markAsSaved();
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();
await loadInvoice();
savedSuccessfully = false;
} catch (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 {
const payBtn = modal.querySelector('#btn-pay');
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
const loadInvoice = async () => {
try {

View File

@ -4,27 +4,70 @@ export function renderLogin(onLogin) {
loginContainer.innerHTML = `
<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">
<div class="form-group">
<label for="identifier">Usuario o email</label>
<input type="text" id="identifier" name="identifier" required autocomplete="username">
<label for="identifier">
<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 class="form-group">
<label for="password">Contraseña</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<label for="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>
<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>
<p id="login-error" class="login-error"></p>
</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();
// 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 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;

View File

@ -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);
});
}

View File

@ -6,7 +6,10 @@ export function createSidebar(pages, currentPage) {
const sidebarHeader = document.createElement('div');
sidebarHeader.className = 'sidebar-header';
sidebarHeader.innerHTML = /*html*/`
<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">
<span class="toggle-icon">☰</span>
</button>
@ -14,6 +17,7 @@ export function createSidebar(pages, currentPage) {
const nav = document.createElement('nav');
nav.className = 'sidebar-nav';
nav.setAttribute('aria-label', 'Navegación principal');
const navList = document.createElement('ul');
navList.className = 'sidebar-menu';
@ -25,6 +29,7 @@ export function createSidebar(pages, currentPage) {
link.className = 'sidebar-link';
if (page.route === currentPage) {
link.classList.add('active');
link.setAttribute('aria-current', 'page');
}
link.innerHTML = /*html*/`
<span class="sidebar-icon">${page.icon || '📄'}</span>
@ -42,6 +47,8 @@ export function createSidebar(pages, currentPage) {
const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle');
toggleBtn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
const isCollapsed = sidebar.classList.contains('collapsed');
toggleBtn.setAttribute('aria-expanded', !isCollapsed);
});
return sidebar;

108
src/components/Toast.js Normal file
View File

@ -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;
}

View File

@ -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)
}

View File

@ -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'
initRouter()

View File

@ -1,5 +1,6 @@
import { ClientItem } from '../components/ClientItem.js';
import { getClients } from '../services/clients.js';
import { escapeHtml } from '../utils/escapeHtml.js';
export function renderClientesPage() {
const container = document.createElement('div');
@ -19,7 +20,7 @@ export function renderClientesPage() {
</div>
<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 class="clients-table-container">
@ -96,7 +97,15 @@ export function renderClientesPage() {
// Si no hay clientes
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;
}
@ -118,7 +127,7 @@ export function renderClientesPage() {
// Función helper para mostrar N/A si el valor es nulo/vacío/inválido
function displayValue(value) {
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
return value;
return escapeHtml(value);
}
// Obtener texto de estado del cliente
@ -155,7 +164,7 @@ export function renderClientesPage() {
<div class="client-modal">
<div class="client-modal-header">
<h2>${displayValue(client.name)}</h2>
<button class="btn-close-modal">✕</button>
<button class="btn-close-modal" aria-label="Cerrar">✕</button>
</div>
<div class="client-modal-body">
<div class="client-info-section">
@ -204,6 +213,20 @@ export function renderClientesPage() {
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);
}
@ -212,11 +235,19 @@ export function renderClientesPage() {
const clientsList = container.querySelector('.clients-list');
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);
// Manejo de respuesta
allClients = Array.isArray(data) ? data : data.data || data.clients || [];
filteredClients = [...allClients];

View File

@ -1,5 +1,7 @@
import { FormChangeTracker } from '../components/ConfirmExitModal.js';
import { navigationGuards } from '../router.js';
import { authFetch } from '../services/auth.js';
import { showToast } from '../components/Toast.js';
export function renderCreateInvoicePage() {
const container = document.createElement('div');
@ -102,7 +104,7 @@ export function renderCreateInvoicePage() {
async function loadClients() {
try {
const token = localStorage.getItem('token');
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Clients?limit=1000`, {
const response = await authFetch(`${import.meta.env.VITE_API_BASE_URL}/api/Clients?limit=1000`, {
headers: { 'Authorization': `Bearer ${token}` }
});
@ -134,6 +136,19 @@ export function renderCreateInvoicePage() {
expireDate.setDate(expireDate.getDate() + 30);
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
container.querySelectorAll('.date-picker-btn').forEach(btn => {
btn.addEventListener('click', () => {
@ -225,6 +240,11 @@ export function renderCreateInvoicePage() {
taxInput.addEventListener('input', updateTotal);
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();
updateGrandTotal();
});
@ -284,13 +304,13 @@ export function renderCreateInvoicePage() {
const clientIdValue = parseInt(container.querySelector('#clientId').value);
if (!clientIdValue || isNaN(clientIdValue) || clientIdValue < 1) {
alert('Selecciona un cliente');
showToast('Selecciona un cliente', 'warning');
return;
}
const lines = container.querySelectorAll('.line-row-compact');
if (lines.length === 0) {
alert('Añade al menos una línea');
showToast('Añade al menos una línea', 'warning');
return;
}
@ -313,19 +333,19 @@ export function renderCreateInvoicePage() {
}
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;
}
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;
}
const dateObj = new Date(dateValue);
const expireDateObj = new Date(expireDateValue);
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;
}
@ -359,7 +379,7 @@ export function renderCreateInvoicePage() {
});
if (hasEmptyLine) {
alert('Completa todos los campos de las líneas');
showToast('Completa todos los campos de las líneas', 'warning');
return;
}
@ -369,7 +389,7 @@ export function renderCreateInvoicePage() {
submitBtn.textContent = 'Guardando...';
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',
headers: {
'Authorization': `Bearer ${token}`,
@ -391,12 +411,12 @@ export function renderCreateInvoicePage() {
navigationGuards.unregister();
window.removeEventListener('beforeunload', handleBeforeUnload);
alert(`Factura creada (ID: ${invoiceId})`);
showToast(`Factura creada (ID: ${invoiceId})`, 'success');
window.location.hash = '#invoices';
} catch (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');
if (submitBtn) {

View File

@ -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() {
const user = auth.getUser();
@ -7,18 +8,132 @@ export function renderDashboard() {
container.innerHTML = /*html*/`
<div class="dashboard-header">
<h1>Bienvenido, ${user?.email || 'Usuario'}</h1>
<button id="logout-button" class="logout-button">Cerrar Sesión</button>
<div>
<h1>Bienvenido, ${escapeHtml(user?.identifier || user?.email || 'Usuario')}</h1>
<p class="dashboard-subtitle">Resumen de tu actividad en Dolibarr</p>
</div>
</div>
<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 class="dashboard-content">
<p>Dashboard protegido</p>
</div>
`;
container.querySelector('#logout-button').addEventListener('click', () => {
auth.logout();
window.location.hash = '#login';
});
// Cargar estadísticas
loadDashboardStats(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);
}
}

View File

@ -1,5 +1,6 @@
import { InvoiceItem } from '../components/InvoiceItem.js';
import { InvoiceModal } from '../components/InvoiceModal.js';
import { authFetch } from '../services/auth.js';
export function renderFacturasPage() {
const container = document.createElement('div');
@ -20,8 +21,8 @@ export function renderFacturasPage() {
</div>
<div class="facturas-filters">
<input type="search" placeholder="Buscar facturas..." class="search-input" />
<select class="filter-status">
<input type="search" placeholder="Buscar por número o cliente..." class="search-input" aria-label="Buscar facturas" />
<select class="filter-status" aria-label="Filtrar por estado">
<option value="">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="paid">Pagada</option>
@ -89,7 +90,16 @@ export function renderFacturasPage() {
// Si no hay facturas
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;
}
@ -114,11 +124,19 @@ export function renderFacturasPage() {
const invoicesList = container.querySelector('.invoices-list');
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');
// Construir URL con parámetros
let url = `${import.meta.env.VITE_API_BASE_URL}/api/Invoices?limit=1000`;
if (currentFilter) {
@ -129,7 +147,7 @@ export function renderFacturasPage() {
url += `&search=${encodeURIComponent(searchTerm)}`;
}
const response = await fetch(url, {
const response = await authFetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
@ -145,7 +163,18 @@ export function renderFacturasPage() {
// Manejo de respuesta - obtener todas las facturas
allInvoices = Array.isArray(data) ? data : data.data || data.invoices || [];
// 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}`);
@ -193,13 +222,7 @@ export function renderFacturasPage() {
// Debounce para no hacer muchas llamadas
clearTimeout(searchInput.debounceTimer);
searchInput.debounceTimer = setTimeout(() => {
// Solo aplicar búsqueda si contiene al menos un número
const hasNumber = /\d/.test(inputValue);
if (hasNumber) {
searchTerm = inputValue;
} else {
searchTerm = ''; // Si es muy corto, mostrar todas
}
applyFilters();
}, 500); // Espera 500ms después de que el usuario deja de escribir
});

View File

@ -1,11 +1,13 @@
import { auth } from '../services/auth.js';
import { renderLogin } from '../components/Login.js';
import { showSessionExpiredOverlay } from '../components/SessionExpiredOverlay.js';
export function renderLoginPage(onLoginSuccess) {
const container = document.createElement('div');
container.className = 'login-page';
const loginComponent = renderLogin(async (email, password) => {
try {
if (await auth.login(email, password)) {
if (onLoginSuccess) {
onLoginSuccess();
@ -16,6 +18,14 @@ export function renderLoginPage(onLoginSuccess) {
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.';
}
}
});
container.appendChild(loginComponent);

View File

@ -2,6 +2,9 @@ import { renderDashboard } from './DashboardPage.js';
import { renderFacturasPage } from './Facturas.js';
import { renderCreateInvoicePage } from './CreateInvoicePage.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
export const pagesRegistry = [
@ -45,7 +48,127 @@ export const pagesRegistry = [
showInSidebar: true,
render: () => {
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;
}
}

View File

@ -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;
}

View File

@ -34,8 +34,26 @@ export function initRouter() {
const app = document.querySelector('#app');
let isNavigating = false;
let pendingHash = null;
let tokenValidated = 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 route = hash.substring(1);

View File

@ -1,9 +1,51 @@
import { showSessionExpiredOverlay } from '../components/SessionExpiredOverlay.js';
export class Auth {
constructor() {
this.currentUser = null;
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) {
if (!identifier || !password) return false;
@ -43,6 +85,10 @@ export class Auth {
return true;
} catch (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;
}
}
@ -78,3 +124,30 @@ export class 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;
}

View File

@ -1,3 +1,5 @@
import { authFetch } from './auth.js';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
function getAuthHeaders() {
@ -9,7 +11,7 @@ function getAuthHeaders() {
}
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',
headers: getAuthHeaders()
});
@ -20,16 +22,3 @@ export async function getClients(limit = 50, page = 1) {
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();
}

View File

@ -1,3 +1,5 @@
import { authFetch } from './auth.js';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
function getAuthHeaders() {
@ -9,7 +11,7 @@ function getAuthHeaders() {
}
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',
headers: getAuthHeaders()
});
@ -22,7 +24,7 @@ export async function getInvoiceById(id) {
}
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',
headers: getAuthHeaders(),
body: JSON.stringify(data)
@ -37,7 +39,7 @@ export async function updateInvoice(id, data) {
}
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',
headers: getAuthHeaders()
});
@ -51,7 +53,7 @@ export async function validateInvoice(id) {
}
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',
headers: getAuthHeaders(),
body: JSON.stringify({ status })
@ -66,7 +68,7 @@ export async function updateInvoiceStatus(id, status) {
}
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',
headers: getAuthHeaders(),
body: JSON.stringify(lineData)
@ -81,7 +83,7 @@ export async function addInvoiceLine(id, lineData) {
}
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',
headers: getAuthHeaders()
});
@ -95,7 +97,7 @@ export async function deleteInvoiceLine(invoiceId, lineId) {
}
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',
headers: getAuthHeaders(),
body: JSON.stringify(paymentData)
@ -110,7 +112,7 @@ export async function addPayment(invoiceId, paymentData) {
}
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',
headers: getAuthHeaders()
});
@ -127,7 +129,7 @@ export async function deleteInvoice(id) {
// Nota: No hay endpoint DELETE en el OpenAPI spec,
// pero podríamos usar el endpoint de cambio de estado a "canceled"
// o implementarlo si está disponible en el backend
const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, {
const response = await authFetch(`${API_BASE_URL}/api/Invoices/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});

File diff suppressed because it is too large Load Diff

347
src/styles/base.css Normal file
View File

@ -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);
}

400
src/styles/clientes.css Normal file
View File

@ -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;
}
}

View File

@ -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%;
}
}

192
src/styles/dashboard.css Normal file
View File

@ -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;
}

163
src/styles/facturas.css Normal file
View File

@ -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%;
}
}

101
src/styles/login.css Normal file
View File

@ -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;
}

View File

@ -426,6 +426,23 @@
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 {
display: flex;
@ -589,18 +606,25 @@
}
.confirm-exit-icon {
width: 72px;
height: 72px;
width: 80px;
height: 80px;
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%;
display: flex;
align-items: 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 {
color: #d97706;
filter: drop-shadow(0 2px 4px rgba(217, 119, 6, 0.3));
}
.confirm-exit-title {
@ -681,70 +705,104 @@
}
}
/* ============================================
Blocked Navigation Toast Styles
============================================ */
.blocked-nav-toast {
/* ===================== CONFIRM DIALOG ===================== */
.confirm-dialog-overlay {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-100px);
z-index: 3000;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
opacity: 0;
transition: all 0.3s ease-out;
pointer-events: none;
transition: opacity 0.25s ease;
}
.blocked-nav-toast.visible {
transform: translateX(-50%) translateY(0);
.confirm-dialog-overlay.visible {
opacity: 1;
}
.blocked-nav-toast.hiding {
transform: translateX(-50%) translateY(-20px);
opacity: 0;
.confirm-dialog-card {
background: var(--card-bg, #fff);
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 {
transform: scale(1) translateY(0);
}
.confirm-dialog-icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
}
.confirm-dialog-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary, #1a2332);
margin: 0 0 0.5rem;
}
.confirm-dialog-message {
font-size: 0.9rem;
color: var(--text-secondary, #64748b);
line-height: 1.5;
margin: 0 0 1.5rem;
}
.confirm-dialog-actions {
display: flex;
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);
justify-content: center;
}
.blocked-nav-icon {
color: #d97706;
flex-shrink: 0;
}
.blocked-nav-message {
font-size: 0.9375rem;
.confirm-dialog-btn-cancel {
padding: 0.6rem 1.25rem;
background: transparent;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
color: var(--text-secondary, #64748b);
font-weight: 500;
color: #92400e;
white-space: nowrap;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
@media (max-width: 480px) {
.blocked-nav-toast {
left: 1rem;
right: 1rem;
transform: translateX(0) translateY(-100px);
.confirm-dialog-btn-cancel:hover {
background: #f8fafc;
color: var(--text-primary, #1a2332);
}
.blocked-nav-toast.visible {
transform: translateX(0) translateY(0);
.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;
}
.blocked-nav-toast.hiding {
transform: translateX(0) translateY(-20px);
.confirm-dialog-btn-confirm:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.blocked-nav-message {
white-space: normal;
}
.confirm-dialog-btn-confirm:active {
transform: translateY(0);
}

103
src/styles/overlays.css Normal file
View File

@ -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);
}

205
src/styles/settings.css Normal file
View File

@ -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%;
}
}

194
src/styles/sidebar.css Normal file
View File

@ -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;
}
}

138
src/styles/toast.css Normal file
View File

@ -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%;
}
}

15
src/utils/escapeHtml.js Normal file
View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}