Mejora simple de UI/UX

This commit is contained in:
Алекс 2026-05-09 00:59:54 +02:00
parent b67d3a8454
commit 605e73e304
11 changed files with 719 additions and 118 deletions

View File

@ -41,7 +41,9 @@ export function InvoiceItem(invoice, onView) {
}; };
item.innerHTML = /*html*/` item.innerHTML = /*html*/`
<td class="invoice-number">${invoice.number}</td> <td class="invoice-number">
<button class="btn-invoice-num" title="Ver detalles">${invoice.number}</button>
</td>
<td class="invoice-status"> <td class="invoice-status">
<span class="status-badge ${getStatusClass(invoice.status)}"> <span class="status-badge ${getStatusClass(invoice.status)}">
${getStatusText(invoice.status)} ${getStatusText(invoice.status)}
@ -61,13 +63,12 @@ export function InvoiceItem(invoice, onView) {
</td> </td>
`; `;
// Invoice number — also opens modal
item.querySelector('.btn-invoice-num').addEventListener('click', () => onView?.(invoice.id));
// Event listener para el botón de ver // Event listener para el botón de ver
const viewBtn = item.querySelector('.btn-view'); const viewBtn = item.querySelector('.btn-view');
viewBtn.addEventListener('click', () => { viewBtn.addEventListener('click', () => onView?.(invoice.id));
if (onView) {
onView(invoice.id);
}
});
return item; return item;
} }

View File

@ -1,5 +1,6 @@
import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments, downloadInvoicePdf } from '../services/invoices.js'; import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments, downloadInvoicePdf } from '../services/invoices.js';
import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js'; import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js';
import { toast } from '../services/toast.js';
export function InvoiceModal(invoiceId, onClose, onUpdate) { export function InvoiceModal(invoiceId, onClose, onUpdate) {
const modal = document.createElement('div'); const modal = document.createElement('div');
@ -475,12 +476,12 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
savedSuccessfully = true; savedSuccessfully = true;
changeTracker.markAsSaved(); changeTracker.markAsSaved();
alert('Factura actualizada correctamente'); toast.success('Factura actualizada correctamente');
if (onUpdate) onUpdate(); if (onUpdate) onUpdate();
handleClose(); handleClose();
} catch (error) { } catch (error) {
console.error('Error al guardar:', error); console.error('Error al guardar:', error);
alert('Error al guardar la factura: ' + error.message); toast.error('Error al guardar la factura: ' + error.message);
} finally { } finally {
const saveBtn = modal.querySelector('.btn-save'); const saveBtn = modal.querySelector('.btn-save');
if (saveBtn) { if (saveBtn) {
@ -492,9 +493,13 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
// Manejar validación // Manejar validación
const handleValidate = async () => { const handleValidate = async () => {
if (!confirm('¿Estás seguro de que quieres validar esta factura? No podrás editarla completamente después.')) { const ok = await showConfirmExitModal({
return; title: 'Validar factura',
} message: '¿Seguro que quieres validar esta factura? No podrás editarla completamente después.',
confirmText: 'Validar',
cancelText: 'Cancelar'
});
if (!ok) return;
try { try {
const validateBtn = modal.querySelector('.btn-validate'); const validateBtn = modal.querySelector('.btn-validate');
@ -507,7 +512,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
savedSuccessfully = true; savedSuccessfully = true;
changeTracker.markAsSaved(); changeTracker.markAsSaved();
alert('Factura validada correctamente'); toast.success('Factura validada correctamente');
if (onUpdate) onUpdate(); if (onUpdate) onUpdate();
// Recargar la factura para mostrar el nuevo estado // Recargar la factura para mostrar el nuevo estado
@ -517,7 +522,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
savedSuccessfully = false; savedSuccessfully = false;
} catch (error) { } catch (error) {
console.error('Error al validar:', error); console.error('Error al validar:', error);
alert('Error al validar la factura: ' + error.message); toast.error('Error al validar la factura: ' + error.message);
const validateBtn = modal.querySelector('.btn-validate'); const validateBtn = modal.querySelector('.btn-validate');
if (validateBtn) { if (validateBtn) {
@ -530,7 +535,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
// Manejar descarga del PDF // Manejar descarga del PDF
const handleDownloadPdf = async () => { const handleDownloadPdf = async () => {
if (!invoice?.number) { if (!invoice?.number) {
alert('No se puede descargar la factura porque no tiene número.'); toast.warning('No se puede descargar la factura porque no tiene número.');
return; return;
} }
@ -554,7 +559,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
} catch (error) { } catch (error) {
console.error('Error al descargar factura:', error); console.error('Error al descargar factura:', error);
alert('Error al descargar la factura: ' + error.message); toast.error('Error al descargar la factura: ' + error.message);
} finally { } finally {
if (downloadBtn) { if (downloadBtn) {
downloadBtn.disabled = false; downloadBtn.disabled = false;
@ -566,7 +571,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
// Manejar vista previa del PDF // Manejar vista previa del PDF
const handlePreviewPdf = async () => { const handlePreviewPdf = async () => {
if (!invoice?.number) { if (!invoice?.number) {
alert('No se puede previsualizar la factura porque no tiene número.'); toast.warning('No se puede previsualizar la factura porque no tiene número.');
return; return;
} }
@ -575,7 +580,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
const previewWindow = window.open('', '_blank'); const previewWindow = window.open('', '_blank');
if (!previewWindow) { if (!previewWindow) {
alert('El navegador bloqueó la ventana emergente. Permite popups para ver la vista previa.'); toast.warning('El navegador bloqueó la ventana emergente. Permite popups para ver la vista previa.');
return; return;
} }
@ -600,7 +605,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
} catch (error) { } catch (error) {
console.error('Error al previsualizar factura:', error); console.error('Error al previsualizar factura:', error);
previewWindow.close(); previewWindow.close();
alert('Error al abrir la vista previa: ' + error.message); toast.error('Error al abrir la vista previa: ' + error.message);
} finally { } finally {
if (previewBtn) { if (previewBtn) {
previewBtn.disabled = false; previewBtn.disabled = false;
@ -634,22 +639,22 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
const taxRate = parseFloat(modal.querySelector('#line-tax').value); const taxRate = parseFloat(modal.querySelector('#line-tax').value);
if (!description) { if (!description) {
alert('La descripción es obligatoria'); toast.warning('La descripción es obligatoria');
return; return;
} }
if (!quantity || quantity <= 0) { if (!quantity || quantity <= 0) {
alert('La cantidad debe ser mayor que 0'); toast.warning('La cantidad debe ser mayor que 0');
return; return;
} }
if (unitPrice < 0) { if (unitPrice < 0) {
alert('El precio no puede ser negativo'); toast.warning('El precio no puede ser negativo');
return; return;
} }
if (taxRate < 0 || taxRate > 100) { if (taxRate < 0 || taxRate > 100) {
alert('El IVA debe estar entre 0 y 100'); toast.warning('El IVA debe estar entre 0 y 100');
return; return;
} }
@ -665,7 +670,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
taxRate taxRate
}); });
alert('Línea añadida correctamente'); toast.success('Línea añadida correctamente');
toggleAddLineForm(); toggleAddLineForm();
// Recargar y recapturar estado inicial // Recargar y recapturar estado inicial
@ -673,7 +678,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
changeTracker.markAsSaved(); changeTracker.markAsSaved();
} catch (error) { } catch (error) {
console.error('Error al añadir línea:', error); console.error('Error al añadir línea:', error);
alert('Error al añadir línea: ' + error.message); toast.error('Error al añadir línea: ' + error.message);
} finally { } finally {
const saveBtn = modal.querySelector('.btn-save-line'); const saveBtn = modal.querySelector('.btn-save-line');
if (saveBtn) { if (saveBtn) {
@ -685,9 +690,13 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
// Eliminar línea de factura // Eliminar línea de factura
const handleDeleteLine = async (lineId) => { const handleDeleteLine = async (lineId) => {
if (!confirm('¿Estás seguro de que quieres eliminar esta línea?')) { const ok = await showConfirmExitModal({
return; title: 'Eliminar línea',
} message: '¿Seguro que quieres eliminar esta línea de la factura?',
confirmText: 'Eliminar',
cancelText: 'Cancelar'
});
if (!ok) return;
try { try {
// Deshabilitar el botón mientras se elimina // Deshabilitar el botón mientras se elimina
@ -704,7 +713,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
changeTracker.markAsSaved(); changeTracker.markAsSaved();
} catch (error) { } catch (error) {
console.error('Error al eliminar línea:', error); console.error('Error al eliminar línea:', error);
alert('Error al eliminar línea: ' + error.message); toast.error('Error al eliminar línea: ' + error.message);
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`); const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
if (btn) { if (btn) {
@ -729,7 +738,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
let amount = null; let amount = null;
if (!isNaN(rawAmount) && rawAmount > 0) { if (!isNaN(rawAmount) && rawAmount > 0) {
if (rawAmount > remainToPay) { if (rawAmount > remainToPay) {
alert(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`); toast.warning(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`);
return; return;
} }
amount = rawAmount; amount = rawAmount;
@ -737,7 +746,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
const paymentDate = dateInput.value; const paymentDate = dateInput.value;
if (!paymentDate) { if (!paymentDate) {
alert('La fecha de pago es obligatoria'); toast.warning('La fecha de pago es obligatoria');
return; return;
} }
@ -761,14 +770,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
changeTracker.markAsSaved(); changeTracker.markAsSaved();
const displayAmount = amount ? `${amount.toFixed(2)} €` : `${remainToPay.toFixed(2)} € (total)`; const displayAmount = amount ? `${amount.toFixed(2)} €` : `${remainToPay.toFixed(2)} € (total)`;
alert(`Pago de ${displayAmount} registrado correctamente`); toast.success(`Pago de ${displayAmount} registrado correctamente`);
if (onUpdate) onUpdate(); if (onUpdate) onUpdate();
await loadInvoice(); await loadInvoice();
savedSuccessfully = false; savedSuccessfully = false;
} catch (error) { } catch (error) {
console.error('Error al registrar pago:', error); console.error('Error al registrar pago:', error);
alert('Error al registrar el pago: ' + error.message); toast.error('Error al registrar el pago: ' + error.message);
} finally { } finally {
const payBtn = modal.querySelector('#btn-pay'); const payBtn = modal.querySelector('#btn-pay');
if (payBtn) { if (payBtn) {
@ -806,6 +815,17 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
</div> </div>
`; `;
// Escape key closes modal
const handleEscapeKey = (e) => {
if (e.key !== 'Escape') return;
if (!modal.isConnected) {
document.removeEventListener('keydown', handleEscapeKey);
return;
}
handleClose();
};
document.addEventListener('keydown', handleEscapeKey);
// Cargar factura // Cargar factura
loadInvoice(); loadInvoice();

View File

@ -1,3 +1,5 @@
import { auth } from '../services/auth.js';
export function createSidebar(pages, currentPage) { export function createSidebar(pages, currentPage) {
const sidebar = document.createElement('aside'); const sidebar = document.createElement('aside');
sidebar.className = 'sidebar'; sidebar.className = 'sidebar';
@ -48,6 +50,34 @@ export function createSidebar(pages, currentPage) {
sidebar.appendChild(sidebarHeader); sidebar.appendChild(sidebarHeader);
sidebar.appendChild(nav); sidebar.appendChild(nav);
// User footer
const user = auth.getUser();
const identifier = user?.identifier || user?.name || user?.login || '?';
const initials = identifier.slice(0, 2).toUpperCase();
const sidebarFooter = document.createElement('div');
sidebarFooter.className = 'sidebar-footer';
sidebarFooter.innerHTML = /*html*/`
<div class="sidebar-user">
<div class="sidebar-avatar">${initials}</div>
<span class="sidebar-username">${identifier}</span>
</div>
<button class="sidebar-logout-btn" title="Cerrar sesión">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<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>
</button>
`;
sidebarFooter.querySelector('.sidebar-logout-btn').addEventListener('click', () => {
auth.logout();
window.location.hash = '#login';
});
sidebar.appendChild(sidebarFooter);
const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle'); const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle');
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener('click', () => {
if (window.matchMedia('(max-width: 768px)').matches) { if (window.matchMedia('(max-width: 768px)').matches) {

View File

@ -7,6 +7,7 @@ import './styles/clients.css'
import './styles/create-invoice.css' import './styles/create-invoice.css'
import './styles/settings.css' import './styles/settings.css'
import './styles/voice-assistant.css' import './styles/voice-assistant.css'
import './styles/toast.css'
import { initRouter } from './router.js' import { initRouter } from './router.js'
import { initSessionManager } from './services/session.js' import { initSessionManager } from './services/session.js'
import { initTheme } from './services/theme.js' import { initTheme } from './services/theme.js'

View File

@ -10,16 +10,20 @@ export function renderFacturasPage() {
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
const pageSize = 20; const pageSize = 20;
let allInvoices = []; // Almacenar todas las facturas let allInvoices = [];
let filteredInvoices = []; // Facturas filtradas let filteredInvoices = [];
let currentFilter = ''; // Filtro actual de estado let currentFilter = '';
let searchTerm = ''; // Término de búsqueda actual let searchTerm = '';
// Estado de ordenación
let sortKey = null;
let sortDir = 'asc';
container.innerHTML = /*html*/` container.innerHTML = /*html*/`
<div class="facturas-header"> <div class="facturas-header">
<h1>Facturas</h1> <h1>Facturas</h1>
</div> </div>
<div class="facturas-filters"> <div class="facturas-filters">
<input type="search" placeholder="Buscar facturas..." class="search-input" /> <input type="search" placeholder="Buscar facturas..." class="search-input" />
<select class="filter-status"> <select class="filter-status">
@ -35,12 +39,24 @@ export function renderFacturasPage() {
<table class="invoices-table"> <table class="invoices-table">
<thead> <thead>
<tr> <tr>
<th class="invoice-number">Número</th> <th class="invoice-number sortable" data-sort="number">
<th class="invoice-status">Estado</th> Número <span class="sort-icon"></span>
<th class="invoice-client">Cliente</th> </th>
<th class="invoice-date">Fecha</th> <th class="invoice-status sortable" data-sort="status">
<th class="invoice-total">Total</th> Estado <span class="sort-icon"></span>
<th class="invoice-remain">Pendiente</th> </th>
<th class="invoice-client sortable" data-sort="clientName">
Cliente <span class="sort-icon"></span>
</th>
<th class="invoice-date sortable" data-sort="date">
Fecha <span class="sort-icon"></span>
</th>
<th class="invoice-total sortable" data-sort="total">
Total <span class="sort-icon"></span>
</th>
<th class="invoice-remain sortable" data-sort="remainToPay">
Pendiente <span class="sort-icon"></span>
</th>
<th class="invoice-actions">Acciones</th> <th class="invoice-actions">Acciones</th>
</tr> </tr>
</thead> </thead>
@ -52,6 +68,26 @@ export function renderFacturasPage() {
</table> </table>
</div> </div>
<div class="invoices-summary" style="display:none">
<div class="summary-item">
<span class="summary-label">Facturas</span>
<span class="summary-value" data-summary="count">—</span>
</div>
<div class="summary-divider"></div>
<div class="summary-item">
<span class="summary-label">Total facturado</span>
<span class="summary-value" data-summary="total">—</span>
</div>
<div class="summary-item summary-item--paid">
<span class="summary-label">Pagado</span>
<span class="summary-value" data-summary="paid">—</span>
</div>
<div class="summary-item summary-item--pending">
<span class="summary-label">Pendiente</span>
<span class="summary-value" data-summary="pending">—</span>
</div>
</div>
<div class="pagination"> <div class="pagination">
<button class="btn-pagination btn-prev">← Anterior</button> <button class="btn-pagination btn-prev">← Anterior</button>
<div class="pagination-info"> <div class="pagination-info">
@ -61,39 +97,82 @@ export function renderFacturasPage() {
</div> </div>
`; `;
// Función para aplicar filtros combinados const formatCurrency = (n) =>
function applyFilters() { new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n || 0);
// Ya no filtra localmente, hace llamada a la API
loadAllInvoices(); // ── Sort helpers ────────────────────────────────────────────
function sortInvoices(invoices) {
if (!sortKey) return invoices;
return [...invoices].sort((a, b) => {
let va = a[sortKey], vb = b[sortKey];
if (sortKey === 'total' || sortKey === 'remainToPay') {
va = parseFloat(va) || 0;
vb = parseFloat(vb) || 0;
} else if (sortKey === 'date') {
va = new Date(va).getTime() || 0;
vb = new Date(vb).getTime() || 0;
} else {
va = String(va ?? '').toLowerCase();
vb = String(vb ?? '').toLowerCase();
}
if (va < vb) return sortDir === 'asc' ? -1 : 1;
if (va > vb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
} }
// Función para renderizar la página actual function updateSortIcons() {
container.querySelectorAll('th.sortable').forEach(th => {
const key = th.dataset.sort;
const icon = th.querySelector('.sort-icon');
if (key === sortKey) {
icon.textContent = sortDir === 'asc' ? ' ↑' : ' ↓';
th.classList.add('sort-active');
} else {
icon.textContent = '';
th.classList.remove('sort-active');
}
});
}
// ── Summary ─────────────────────────────────────────────────
function updateSummary(invoices) {
const summaryEl = container.querySelector('.invoices-summary');
if (!invoices.length) { summaryEl.style.display = 'none'; return; }
const total = invoices.reduce((s, i) => s + (parseFloat(i.total) || 0), 0);
const pending = invoices.reduce((s, i) => s + (parseFloat(i.remainToPay) || 0), 0);
const paid = total - pending;
container.querySelector('[data-summary="count"]').textContent = invoices.length;
container.querySelector('[data-summary="total"]').textContent = formatCurrency(total);
container.querySelector('[data-summary="paid"]').textContent = formatCurrency(paid);
container.querySelector('[data-summary="pending"]').textContent = formatCurrency(pending);
summaryEl.style.display = 'flex';
}
// ── Render current page ──────────────────────────────────────
function renderPage(page = 1) { function renderPage(page = 1) {
const invoicesList = container.querySelector('.invoices-list'); const invoicesList = container.querySelector('.invoices-list');
currentPage = page; currentPage = page;
totalPages = Math.ceil(filteredInvoices.length / pageSize); const sorted = sortInvoices(filteredInvoices);
totalPages = Math.ceil(sorted.length / pageSize);
// Calcular índices para la página
const startIndex = (page - 1) * pageSize; const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, filteredInvoices.length); const invoices = sorted.slice(startIndex, startIndex + pageSize);
const invoices = filteredInvoices.slice(startIndex, endIndex);
console.log(`Página ${currentPage}/${totalPages}, Facturas en esta página: ${invoices.length}, Total filtrado: ${filteredInvoices.length}`);
// Actualizar UI de paginación
updatePaginationUI(); updatePaginationUI();
updateSortIcons();
updateSummary(filteredInvoices);
// Limpiar lista
invoicesList.innerHTML = ''; invoicesList.innerHTML = '';
// Si no hay facturas
if (invoices.length === 0) { if (invoices.length === 0) {
invoicesList.innerHTML = '<tr><td colspan="7" class="no-invoices">No hay facturas disponibles</td></tr>'; invoicesList.innerHTML = '<tr><td colspan="7" class="no-invoices">No hay facturas disponibles</td></tr>';
return; return;
} }
// Renderizar cada factura
invoices.forEach(invoice => { invoices.forEach(invoice => {
const invoiceItem = InvoiceItem(invoice, handleViewInvoice); const invoiceItem = InvoiceItem(invoice, handleViewInvoice);
invoicesList.appendChild(invoiceItem); invoicesList.appendChild(invoiceItem);
@ -103,7 +182,6 @@ export function renderFacturasPage() {
// Manejar ver/editar factura // Manejar ver/editar factura
function handleViewInvoice(invoiceId) { function handleViewInvoice(invoiceId) {
const modal = InvoiceModal(invoiceId, null, () => { const modal = InvoiceModal(invoiceId, null, () => {
// Callback cuando se actualiza la factura
loadAllInvoices(); loadAllInvoices();
}); });
document.body.appendChild(modal); document.body.appendChild(modal);
@ -116,111 +194,89 @@ export function renderFacturasPage() {
try { try {
invoicesList.innerHTML = '<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>'; invoicesList.innerHTML = '<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>';
// Construir URL con parámetros
let url = '/api/Invoices?limit=1000'; let url = '/api/Invoices?limit=1000';
if (currentFilter) url += `&status=${currentFilter}`;
if (currentFilter) { if (searchTerm) url += `&search=${encodeURIComponent(searchTerm)}`;
url += `&status=${currentFilter}`;
}
if (searchTerm) {
url += `&search=${encodeURIComponent(searchTerm)}`;
}
const data = await apiGet(url); const data = await apiGet(url);
// Manejo de respuesta - obtener todas las facturas allInvoices = Array.isArray(data) ? data : data.data || data.invoices || [];
allInvoices = Array.isArray(data) ? data : data.data || data.invoices || [];
filteredInvoices = [...allInvoices].reverse(); filteredInvoices = [...allInvoices].reverse();
console.log(`Total de facturas cargadas: ${allInvoices.length}`);
// Renderizar la primera página
renderPage(1); renderPage(1);
} catch (error) { } catch (error) {
console.error('Error al cargar facturas:', error); console.error('Error al cargar facturas:', error);
invoicesList.innerHTML = '<tr><td colspan="7" class="error">Error al cargar las facturas. Por favor, intenta de nuevo.</td></tr>'; invoicesList.innerHTML = '<tr><td colspan="7" class="error">Error al cargar las facturas. Por favor, intenta de nuevo.</td></tr>';
} }
} }
function applyFilters() {
loadAllInvoices();
}
// Función para actualizar la UI de paginación // Función para actualizar la UI de paginación
function updatePaginationUI() { function updatePaginationUI() {
const paginationDiv = container.querySelector('.pagination'); const paginationDiv = container.querySelector('.pagination');
const currentPageSpan = container.querySelector('.current-page'); const currentPageSpan = container.querySelector('.current-page');
const totalPagesSpan = container.querySelector('.total-pages'); const totalPagesSpan = container.querySelector('.total-pages');
const btnPrev = container.querySelector('.btn-prev'); const btnPrev = container.querySelector('.btn-prev');
const btnNext = container.querySelector('.btn-next'); const btnNext = container.querySelector('.btn-next');
currentPageSpan.textContent = currentPage; currentPageSpan.textContent = currentPage;
totalPagesSpan.textContent = totalPages; totalPagesSpan.textContent = totalPages;
// Mostrar u ocultar la paginación según el número de páginas paginationDiv.style.display = totalPages <= 1 ? 'none' : 'flex';
if (totalPages <= 1) {
paginationDiv.style.display = 'none';
} else {
paginationDiv.style.display = 'flex';
}
// Deshabilitar botones según corresponda
btnPrev.disabled = currentPage === 1; btnPrev.disabled = currentPage === 1;
btnNext.disabled = currentPage === totalPages; btnNext.disabled = currentPage === totalPages;
} }
// Event listeners // ── Sort click listeners ─────────────────────────────────────
const newInvoiceBtn = container.querySelector('.btn-new-invoice'); container.querySelectorAll('th.sortable').forEach(th => {
newInvoiceBtn.addEventListener('click', () => { th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDir = 'asc';
}
renderPage(1);
});
});
// ── Other event listeners ────────────────────────────────────
container.querySelector('.btn-new-invoice').addEventListener('click', () => {
window.location.hash = '#create-invoice'; window.location.hash = '#create-invoice';
}); });
const searchInput = container.querySelector('.search-input'); const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => { searchInput.addEventListener('input', (e) => {
const inputValue = e.target.value.trim(); const inputValue = e.target.value.trim();
// Debounce para no hacer muchas llamadas
clearTimeout(searchInput.debounceTimer); clearTimeout(searchInput.debounceTimer);
searchInput.debounceTimer = setTimeout(() => { searchInput.debounceTimer = setTimeout(() => {
// Solo aplicar búsqueda si contiene al menos un número searchTerm = /\d/.test(inputValue) ? inputValue : '';
const hasNumber = /\d/.test(inputValue);
if (hasNumber) {
searchTerm = inputValue;
} else {
searchTerm = ''; // Si es muy corto, mostrar todas
}
applyFilters(); applyFilters();
}, 500); // Espera 500ms después de que el usuario deja de escribir }, 500);
}); });
const filterStatus = container.querySelector('.filter-status'); container.querySelector('.filter-status').addEventListener('change', (e) => {
filterStatus.addEventListener('change', (e) => {
currentFilter = e.target.value; currentFilter = e.target.value;
console.log(`Filtro aplicado: ${currentFilter || 'Todos'}`);
applyFilters(); applyFilters();
}); });
// Event listeners de paginación container.querySelector('.btn-prev').addEventListener('click', () => {
const btnPrev = container.querySelector('.btn-prev'); if (currentPage > 1) { renderPage(currentPage - 1); scrollToTop(); }
const btnNext = container.querySelector('.btn-next');
btnPrev.addEventListener('click', () => {
if (currentPage > 1) {
renderPage(currentPage - 1);
scrollToTop();
}
}); });
btnNext.addEventListener('click', () => { container.querySelector('.btn-next').addEventListener('click', () => {
if (currentPage < totalPages) { if (currentPage < totalPages) { renderPage(currentPage + 1); scrollToTop(); }
renderPage(currentPage + 1);
scrollToTop();
}
}); });
// Función para scroll al inicio
function scrollToTop() { function scrollToTop() {
container.scrollIntoView({ behavior: 'smooth', block: 'start' }); container.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
// Cargar todas las facturas al montar el componente
loadAllInvoices(); loadAllInvoices();
return container; return container;

54
src/services/toast.js Normal file
View File

@ -0,0 +1,54 @@
let container = null;
function getContainer() {
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
return container;
}
const ICONS = {
success: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
error: `<svg width="16" height="16" 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: `<svg width="16" height="16" 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: `<svg width="16" height="16" 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>`,
};
export function showToast(message, type = 'info', duration = 3500) {
const c = getContainer();
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.innerHTML = `
<span class="toast-icon">${ICONS[type] ?? ICONS.info}</span>
<span class="toast-message">${message}</span>
<button class="toast-close" aria-label="Cerrar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
`;
c.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('toast--visible'));
const remove = () => {
if (!toast.isConnected) return;
toast.classList.remove('toast--visible');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
};
const timer = setTimeout(remove, duration);
toast.querySelector('.toast-close').addEventListener('click', () => {
clearTimeout(timer);
remove();
});
return remove;
}
export const toast = {
success: (msg, d) => showToast(msg, 'success', d),
error: (msg, d) => showToast(msg, 'error', d),
warning: (msg, d) => showToast(msg, 'warning', d),
info: (msg, d) => showToast(msg, 'info', d),
};

View File

@ -196,7 +196,7 @@
grid-template-columns: 4fr 0.8fr 1.2fr 0.8fr 1.2fr auto; grid-template-columns: 4fr 0.8fr 1.2fr 0.8fr 1.2fr auto;
gap: var(--space-2); gap: var(--space-2);
align-items: center; align-items: center;
background: var(--gray-50); background: var(--bg-subtle);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
@ -210,7 +210,7 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 13px; font-size: 13px;
background: white; background: var(--card-bg);
color: var(--text-primary); color: var(--text-primary);
font-family: inherit; font-family: inherit;
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
@ -337,3 +337,37 @@
padding: var(--space-3); padding: var(--space-3);
} }
} }
:root[data-theme='dark'] .invoice-form-compact {
background: #081226;
border-color: #1e2f4d;
}
:root[data-theme='dark'] .line-row-compact {
background: rgba(15, 23, 42, 0.6);
border-color: #1e2f4d;
}
:root[data-theme='dark'] .line-desc-compact,
:root[data-theme='dark'] .line-qty-compact,
:root[data-theme='dark'] .line-price-compact,
:root[data-theme='dark'] .line-tax-compact {
background: #0b1220;
border-color: #1e2f4d;
color: var(--text-primary);
}
:root[data-theme='dark'] .btn-add-compact {
background: #0b1220;
border-color: #1e2f4d;
color: var(--text-primary);
}
:root[data-theme='dark'] .btn-add-compact:hover {
border-color: #2d4a7a;
}
:root[data-theme='dark'] .btn-submit-compact:disabled {
background: #1e2f4d;
color: var(--text-secondary);
}

View File

@ -103,6 +103,101 @@
.invoices-table td, .invoices-table td,
.clients-table td { padding: var(--space-3) var(--space-4); font-size: 14px; } .clients-table td { padding: var(--space-3) var(--space-4); font-size: 14px; }
/* Sortable column headers */
.invoices-table th.sortable {
user-select: none;
white-space: nowrap;
}
.invoices-table th.sortable:hover {
color: var(--text-primary);
background-color: var(--gray-100);
}
:root[data-theme='dark'] .invoices-table th.sortable:hover {
background-color: rgba(30, 58, 110, 0.3);
}
.invoices-table th.sort-active {
color: var(--primary);
}
.sort-icon {
font-size: 11px;
opacity: 0.7;
}
/* Totals summary bar */
.invoices-summary {
display: flex;
align-items: center;
gap: var(--space-5);
padding: var(--space-3) var(--space-4);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
margin-top: var(--space-3);
box-shadow: var(--shadow-xs);
flex-wrap: wrap;
}
.summary-divider {
width: 1px;
height: 24px;
background: var(--border-color);
flex-shrink: 0;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.summary-label {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summary-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.summary-item--paid .summary-value { color: var(--success); }
.summary-item--pending .summary-value { color: var(--primary); }
:root[data-theme='dark'] .invoices-summary {
background: #081226;
border-color: #1e2f4d;
}
/* Clickable invoice number */
.btn-invoice-num {
background: none;
border: none;
cursor: pointer;
font-weight: 600;
font-size: 13px;
color: var(--primary);
padding: 0;
font-family: inherit;
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: transparent;
transition: text-decoration-color var(--transition-fast), color var(--transition-fast);
}
.btn-invoice-num:hover {
text-decoration-color: var(--primary);
color: var(--primary-hover);
}
.invoice-number { font-weight: 500; color: var(--text-primary); } .invoice-number { font-weight: 500; color: var(--text-primary); }
.invoice-client { .invoice-client {

View File

@ -804,6 +804,103 @@
white-space: nowrap; white-space: nowrap;
} }
/* ============================================
Dark Mode Overrides
============================================ */
:root[data-theme='dark'] .modal-content {
background: #081226;
}
:root[data-theme='dark'] .modal-header,
:root[data-theme='dark'] .modal-footer,
:root[data-theme='dark'] .total-row.pending,
:root[data-theme='dark'] .payments-history-section,
:root[data-theme='dark'] .payment-section {
border-color: #1e2f4d;
}
:root[data-theme='dark'] .form-group input,
:root[data-theme='dark'] .form-group textarea,
:root[data-theme='dark'] .form-group select {
background: #0b1220;
border-color: #1e2f4d;
color: var(--text-primary);
}
:root[data-theme='dark'] .form-group input:disabled,
:root[data-theme='dark'] .form-group textarea:disabled,
:root[data-theme='dark'] .form-group input[readonly] {
background: rgba(15, 23, 42, 0.8);
color: var(--text-secondary);
}
:root[data-theme='dark'] .add-line-form {
background: rgba(15, 23, 42, 0.6);
border-color: #1e2f4d;
}
:root[data-theme='dark'] .lines-table thead {
background: rgba(15, 23, 42, 0.6);
}
:root[data-theme='dark'] .lines-table th,
:root[data-theme='dark'] .lines-table td {
border-color: #1e2f4d;
}
:root[data-theme='dark'] .lines-table tbody tr:hover {
background: rgba(30, 58, 110, 0.2);
}
:root[data-theme='dark'] .invoice-totals {
background: rgba(15, 23, 42, 0.6);
}
:root[data-theme='dark'] .payments-history-table {
border-color: #1e2f4d;
}
:root[data-theme='dark'] .payments-history-table thead th {
background: rgba(15, 23, 42, 0.6);
border-color: #1e2f4d;
}
:root[data-theme='dark'] .payments-history-table td,
:root[data-theme='dark'] .payments-history-table th {
border-color: #1e2f4d;
}
:root[data-theme='dark'] .payments-history-table tbody tr:hover {
background: rgba(30, 58, 110, 0.2);
}
:root[data-theme='dark'] .payment-form input {
background: #0b1220;
border-color: #1e2f4d;
}
:root[data-theme='dark'] .payment-type-pill {
background: rgba(30, 58, 110, 0.4);
}
:root[data-theme='dark'] .btn-delete:hover {
background-color: rgba(220, 38, 38, 0.15);
}
:root[data-theme='dark'] .btn-cancel:hover {
background-color: rgba(30, 58, 110, 0.2);
}
:root[data-theme='dark'] .confirm-exit-modal {
background: #081226;
border: 1px solid #1e2f4d;
}
:root[data-theme='dark'] .confirm-exit-icon {
background: rgba(217, 119, 6, 0.15);
}
@media (max-width: 480px) { @media (max-width: 480px) {
.blocked-nav-toast { .blocked-nav-toast {
left: 1rem; left: 1rem;

View File

@ -171,11 +171,97 @@
padding: var(--space-2); padding: var(--space-2);
} }
/* ── Sidebar footer (user + logout) ── */
.sidebar-footer {
padding: var(--space-3) var(--space-3);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.sidebar-user {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.sidebar-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary);
color: white;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
letter-spacing: 0;
}
.sidebar-username {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity var(--transition-fast), width var(--transition-fast);
}
.sidebar-logout-btn {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: var(--space-1);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
transition: color var(--transition-fast), background-color var(--transition-fast);
}
.sidebar-logout-btn:hover {
color: var(--danger);
background: var(--red-50);
border-color: transparent;
}
.sidebar.collapsed .sidebar-username {
opacity: 0;
width: 0;
overflow: hidden;
}
.sidebar.collapsed .sidebar-logout-btn {
display: none;
}
.sidebar.collapsed .sidebar-footer {
justify-content: center;
padding: var(--space-3) var(--space-2);
}
.main-content { .main-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
background-color: var(--bg-page); background-color: var(--bg-page);
min-width: 0; min-width: 0;
animation: pageIn 0.18s ease;
}
@keyframes pageIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
} }
.main-content > div { .main-content > div {

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

@ -0,0 +1,127 @@
/* ============================================
Toast Notifications
============================================ */
.toast-container {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column-reverse;
gap: 8px;
pointer-events: none;
width: max-content;
max-width: min(420px, calc(100vw - 32px));
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
pointer-events: auto;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06);
border: 1px solid transparent;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.22s ease, transform 0.22s ease;
line-height: 1.4;
cursor: default;
}
.toast--visible {
opacity: 1;
transform: translateY(0);
}
.toast--success {
background: #f0fdf4;
border-color: #bbf7d0;
color: #15803d;
}
.toast--error {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.toast--warning {
background: #fffbeb;
border-color: #fde68a;
color: #d97706;
}
.toast--info {
background: #eff6ff;
border-color: #bfdbfe;
color: #2563eb;
}
.toast-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.toast-message {
flex: 1;
min-width: 0;
}
.toast-close {
background: none;
border: none;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
color: inherit;
opacity: 0.55;
border-radius: 4px;
flex-shrink: 0;
transition: opacity 0.15s, background-color 0.15s;
}
.toast-close:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.07);
}
/* Dark mode */
:root[data-theme='dark'] .toast--success {
background: rgba(22, 163, 74, 0.15);
border-color: rgba(22, 163, 74, 0.3);
color: #86efac;
}
:root[data-theme='dark'] .toast--error {
background: rgba(220, 38, 38, 0.15);
border-color: rgba(220, 38, 38, 0.3);
color: #fca5a5;
}
:root[data-theme='dark'] .toast--warning {
background: rgba(217, 119, 6, 0.15);
border-color: rgba(217, 119, 6, 0.3);
color: #fcd34d;
}
:root[data-theme='dark'] .toast--info {
background: rgba(37, 99, 235, 0.15);
border-color: rgba(37, 99, 235, 0.3);
color: #93c5fd;
}
@media (max-width: 480px) {
.toast-container {
bottom: 16px;
width: calc(100vw - 32px);
max-width: none;
}
}