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

View File

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

View File

@ -1,3 +1,5 @@
import { auth } from '../services/auth.js';
export function createSidebar(pages, currentPage) {
const sidebar = document.createElement('aside');
sidebar.className = 'sidebar';
@ -48,6 +50,34 @@ export function createSidebar(pages, currentPage) {
sidebar.appendChild(sidebarHeader);
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');
toggleBtn.addEventListener('click', () => {
if (window.matchMedia('(max-width: 768px)').matches) {

View File

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

View File

@ -10,16 +10,20 @@ export function renderFacturasPage() {
let currentPage = 1;
let totalPages = 1;
const pageSize = 20;
let allInvoices = []; // Almacenar todas las facturas
let filteredInvoices = []; // Facturas filtradas
let currentFilter = ''; // Filtro actual de estado
let searchTerm = ''; // Término de búsqueda actual
let allInvoices = [];
let filteredInvoices = [];
let currentFilter = '';
let searchTerm = '';
// Estado de ordenación
let sortKey = null;
let sortDir = 'asc';
container.innerHTML = /*html*/`
<div class="facturas-header">
<h1>Facturas</h1>
</div>
<div class="facturas-filters">
<input type="search" placeholder="Buscar facturas..." class="search-input" />
<select class="filter-status">
@ -35,12 +39,24 @@ export function renderFacturasPage() {
<table class="invoices-table">
<thead>
<tr>
<th class="invoice-number">Número</th>
<th class="invoice-status">Estado</th>
<th class="invoice-client">Cliente</th>
<th class="invoice-date">Fecha</th>
<th class="invoice-total">Total</th>
<th class="invoice-remain">Pendiente</th>
<th class="invoice-number sortable" data-sort="number">
Número <span class="sort-icon"></span>
</th>
<th class="invoice-status sortable" data-sort="status">
Estado <span class="sort-icon"></span>
</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>
</tr>
</thead>
@ -52,6 +68,26 @@ export function renderFacturasPage() {
</table>
</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">
<button class="btn-pagination btn-prev">← Anterior</button>
<div class="pagination-info">
@ -61,39 +97,82 @@ export function renderFacturasPage() {
</div>
`;
// Función para aplicar filtros combinados
function applyFilters() {
// Ya no filtra localmente, hace llamada a la API
loadAllInvoices();
const formatCurrency = (n) =>
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n || 0);
// ── 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) {
const invoicesList = container.querySelector('.invoices-list');
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 endIndex = Math.min(startIndex + pageSize, filteredInvoices.length);
const invoices = filteredInvoices.slice(startIndex, endIndex);
const invoices = sorted.slice(startIndex, startIndex + pageSize);
console.log(`Página ${currentPage}/${totalPages}, Facturas en esta página: ${invoices.length}, Total filtrado: ${filteredInvoices.length}`);
// Actualizar UI de paginación
updatePaginationUI();
updateSortIcons();
updateSummary(filteredInvoices);
// Limpiar lista
invoicesList.innerHTML = '';
// Si no hay facturas
if (invoices.length === 0) {
invoicesList.innerHTML = '<tr><td colspan="7" class="no-invoices">No hay facturas disponibles</td></tr>';
return;
}
// Renderizar cada factura
invoices.forEach(invoice => {
const invoiceItem = InvoiceItem(invoice, handleViewInvoice);
invoicesList.appendChild(invoiceItem);
@ -103,7 +182,6 @@ export function renderFacturasPage() {
// Manejar ver/editar factura
function handleViewInvoice(invoiceId) {
const modal = InvoiceModal(invoiceId, null, () => {
// Callback cuando se actualiza la factura
loadAllInvoices();
});
document.body.appendChild(modal);
@ -116,111 +194,89 @@ export function renderFacturasPage() {
try {
invoicesList.innerHTML = '<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>';
// Construir URL con parámetros
let url = '/api/Invoices?limit=1000';
if (currentFilter) {
url += `&status=${currentFilter}`;
}
if (searchTerm) {
url += `&search=${encodeURIComponent(searchTerm)}`;
}
if (currentFilter) url += `&status=${currentFilter}`;
if (searchTerm) url += `&search=${encodeURIComponent(searchTerm)}`;
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();
console.log(`Total de facturas cargadas: ${allInvoices.length}`);
// Renderizar la primera página
renderPage(1);
} catch (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>';
}
}
function applyFilters() {
loadAllInvoices();
}
// Función para actualizar la UI de paginación
function updatePaginationUI() {
const paginationDiv = container.querySelector('.pagination');
const paginationDiv = container.querySelector('.pagination');
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 btnNext = container.querySelector('.btn-next');
currentPageSpan.textContent = currentPage;
totalPagesSpan.textContent = totalPages;
totalPagesSpan.textContent = totalPages;
// Mostrar u ocultar la paginación según el número de páginas
if (totalPages <= 1) {
paginationDiv.style.display = 'none';
} else {
paginationDiv.style.display = 'flex';
}
// Deshabilitar botones según corresponda
paginationDiv.style.display = totalPages <= 1 ? 'none' : 'flex';
btnPrev.disabled = currentPage === 1;
btnNext.disabled = currentPage === totalPages;
}
// Event listeners
const newInvoiceBtn = container.querySelector('.btn-new-invoice');
newInvoiceBtn.addEventListener('click', () => {
// ── Sort click listeners ─────────────────────────────────────
container.querySelectorAll('th.sortable').forEach(th => {
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';
});
const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
const inputValue = e.target.value.trim();
// 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
}
searchTerm = /\d/.test(inputValue) ? inputValue : '';
applyFilters();
}, 500); // Espera 500ms después de que el usuario deja de escribir
}, 500);
});
const filterStatus = container.querySelector('.filter-status');
filterStatus.addEventListener('change', (e) => {
container.querySelector('.filter-status').addEventListener('change', (e) => {
currentFilter = e.target.value;
console.log(`Filtro aplicado: ${currentFilter || 'Todos'}`);
applyFilters();
});
// Event listeners de paginación
const btnPrev = container.querySelector('.btn-prev');
const btnNext = container.querySelector('.btn-next');
btnPrev.addEventListener('click', () => {
if (currentPage > 1) {
renderPage(currentPage - 1);
scrollToTop();
}
container.querySelector('.btn-prev').addEventListener('click', () => {
if (currentPage > 1) { renderPage(currentPage - 1); scrollToTop(); }
});
btnNext.addEventListener('click', () => {
if (currentPage < totalPages) {
renderPage(currentPage + 1);
scrollToTop();
}
container.querySelector('.btn-next').addEventListener('click', () => {
if (currentPage < totalPages) { renderPage(currentPage + 1); scrollToTop(); }
});
// Función para scroll al inicio
function scrollToTop() {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Cargar todas las facturas al montar el componente
loadAllInvoices();
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;
gap: var(--space-2);
align-items: center;
background: var(--gray-50);
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
@ -210,7 +210,7 @@
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 13px;
background: white;
background: var(--card-bg);
color: var(--text-primary);
font-family: inherit;
transition: border-color var(--transition-fast);
@ -337,3 +337,37 @@
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,
.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-client {

View File

@ -804,6 +804,103 @@
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) {
.blocked-nav-toast {
left: 1rem;

View File

@ -171,11 +171,97 @@
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 {
flex: 1;
overflow-y: auto;
background-color: var(--bg-page);
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 {

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