Enhance styles across various components

This commit is contained in:
JavMB 2026-05-14 19:02:04 +02:00
parent 9e7b4b3562
commit ee252a2648
23 changed files with 1048 additions and 409 deletions

View File

@ -1,14 +1,17 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/doli-favicon.svg" />
<link rel="apple-touch-icon" href="/doli-favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doli-front</title>
<meta name="theme-color" content="#2563eb" />
<meta name="description" content="Doli — Gestión de facturación" />
<title>Doli</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/styles/modal.css" />
</head>

12
public/doli-favicon.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<path d="M10 7h7.5a3.5 3.5 0 0 1 2.47 6L14.5 16.5a3.5 3.5 0 0 1-2.47 6H10V7z" fill="#fff" opacity=".92"/>
<path d="M10 17h5.3" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity=".6"/>
<path d="M10 21.5h5" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity=".6"/>
</svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@ -1,10 +1,6 @@
/**
* 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)
*/
import { icons } from '../services/icons.js';
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();
@ -15,23 +11,17 @@ export function showBlockedNavigationToast(message = 'Guarda los cambios antes d
toast.innerHTML = /*html*/`
<div class="blocked-nav-toast-content">
<svg class="blocked-nav-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<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>
${icons.info}
<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');
@ -42,4 +32,4 @@ export function showBlockedNavigationToast(message = 'Guarda los cambios antes d
}, duration);
return toast;
}
}

View File

@ -1,14 +1,14 @@
import { icons } from '../services/icons.js';
export function InvoiceItem(invoice, onView) {
const item = document.createElement('tr');
item.className = 'invoice-item';
// Formatear fecha
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('es-ES');
};
// Formatear moneda
const formatCurrency = (amount) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
@ -16,19 +16,17 @@ export function InvoiceItem(invoice, onView) {
}).format(amount);
};
// Obtener clase de estado
const getStatusClass = (status) => {
const statusMap = {
'draft': 'status-draft',
'validated': 'status-unpaid',
'paid': 'status-paid',
'unpaid': 'status-unpaid',
'canceled': 'status-canceled'
'draft': 'badge-draft',
'validated': 'badge-unpaid',
'paid': 'badge-paid',
'unpaid': 'badge-unpaid',
'canceled': 'badge-draft'
};
return statusMap[status] || 'status-default';
return statusMap[status] || 'badge-draft';
};
// Obtener texto de estado
const getStatusText = (status) => {
const statusTextMap = {
'draft': 'Borrador',
@ -40,35 +38,40 @@ export function InvoiceItem(invoice, onView) {
return statusTextMap[status] || status;
};
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
item.innerHTML = /*html*/`
<td class="invoice-number">
<button class="btn-invoice-num" title="Ver detalles">${invoice.number}</button>
<button class="btn-invoice-num" title="Ver detalles">${escapeHtml(invoice.number)}</button>
</td>
<td class="invoice-status">
<span class="status-badge ${getStatusClass(invoice.status)}">
${getStatusText(invoice.status)}
<span class="badge ${getStatusClass(invoice.status)}">
${escapeHtml(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="1.5" stroke-linecap="round" stroke-linejoin="round"><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'}
<span class="client-icon">${icons.user}</span>
${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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>
${icons.eye}
</button>
</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', () => onView?.(invoice.id));
return item;
}
}

View File

@ -1,3 +1,5 @@
import { doliLogo } from '../services/icons.js';
export function renderLogin(onLogin) {
const loginContainer = document.createElement('div');
loginContainer.className = 'login-container';
@ -6,14 +8,8 @@ export function renderLogin(onLogin) {
<div class="login-card">
<div class="login-brand">
<div class="login-logo-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
${doliLogo(44)}
</div>
<span class="login-brand-name">Doli</span>
</div>
<h2 class="login-title">Iniciar sesión</h2>
<p class="login-subtitle">Accede a tu cuenta para continuar</p>
@ -40,4 +36,4 @@ export function renderLogin(onLogin) {
});
return loginContainer;
}
}

View File

@ -1,4 +1,5 @@
import { auth } from '../services/auth.js';
import { doliLogo, icons } from '../services/icons.js';
export function createSidebar(pages, currentPage) {
const sidebar = document.createElement('aside');
@ -10,17 +11,12 @@ export function createSidebar(pages, currentPage) {
sidebarHeader.innerHTML = /*html*/`
<div class="sidebar-brand">
<div class="sidebar-logo-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
${doliLogo(26)}
</div>
<h2>Doli</h2>
</div>
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Toggle sidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Alternar barra lateral">
${icons.menu}
</button>
`;
@ -39,7 +35,7 @@ export function createSidebar(pages, currentPage) {
link.classList.add('active');
}
link.innerHTML = /*html*/`
<span class="sidebar-icon">${page.icon || '📄'}</span>
<span class="sidebar-icon">${page.icon}</span>
<span class="sidebar-text">${page.name}</span>
`;
listItem.appendChild(link);
@ -50,7 +46,6 @@ 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();
@ -63,11 +58,7 @@ export function createSidebar(pages, currentPage) {
<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>
${icons.logout}
</button>
`;
@ -88,4 +79,4 @@ export function createSidebar(pages, currentPage) {
});
return sidebar;
}
}

View File

@ -1,11 +1,11 @@
import { ClientItem } from '../components/ClientItem.js';
import { getClients } from '../services/clients.js';
import { icons } from '../services/icons.js';
export function renderClientesPage() {
const container = document.createElement('div');
container.className = 'clientes-page';
container.className = 'clientes-page page-enter';
// Estado de paginación
let currentPage = 1;
let totalPages = 1;
const pageSize = 20;
@ -19,7 +19,10 @@ export function renderClientesPage() {
</div>
<div class="clientes-filters">
<input type="search" placeholder="Buscar clientes..." class="search-input" />
<div class="search-wrapper">
<span class="search-icon">${icons.search}</span>
<input type="search" placeholder="Buscar clientes..." class="search-input search-input--with-icon" />
</div>
</div>
<div class="clients-table-container">
@ -36,9 +39,7 @@ export function renderClientesPage() {
</tr>
</thead>
<tbody class="clients-list">
<tr>
<td colspan="7" class="loading">Cargando clientes...</td>
</tr>
<tr><td colspan="7" class="loading">Cargando clientes...</td></tr>
</tbody>
</table>
</div>
@ -52,7 +53,23 @@ export function renderClientesPage() {
</div>
`;
// Función para aplicar filtros
function displayValue(value) {
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
return value;
}
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
function getClientStatusText(status) {
const statusMap = { '0': 'Inactivo', '1': 'Activo' };
return statusMap[status] || 'N/A';
}
function applyFilters() {
if (!searchTerm) {
filteredClients = [...allClients];
@ -74,40 +91,31 @@ export function renderClientesPage() {
renderPage(1);
}
// Función para renderizar la página actual
function renderPage(page = 1) {
const clientsList = container.querySelector('.clients-list');
currentPage = page;
totalPages = Math.ceil(filteredClients.length / pageSize);
// Calcular índices para la página
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, filteredClients.length);
const clients = filteredClients.slice(startIndex, endIndex);
console.log(`Página ${currentPage}/${totalPages}, Clientes en esta página: ${clients.length}, Total filtrado: ${filteredClients.length}`);
// Actualizar UI de paginación
updatePaginationUI();
// Limpiar lista
clientsList.innerHTML = '';
// 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">${icons.emptyClients}<h3>No hay clientes</h3><p>Los clientes aparecerán aquí una vez añadidos.</p></div></td></tr>`;
return;
}
// Renderizar cada cliente
clients.forEach(client => {
const clientItem = ClientItem(client, handleViewClient);
clientsList.appendChild(clientItem);
});
}
// Manejar ver cliente
function handleViewClient(clientId) {
const client = allClients.find(c => c.id === clientId);
if (client) {
@ -115,44 +123,22 @@ 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;
}
// Obtener texto de estado del cliente
function getClientStatusText(status) {
const statusMap = {
'0': 'Inactivo',
'1': 'Activo'
};
return statusMap[status] || 'N/A';
}
// Modal simple para ver detalles del cliente
function showClientModal(client) {
const modal = document.createElement('div');
modal.className = 'client-modal-overlay';
const iconMail = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`;
const iconPhone = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.38 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.82a16 16 0 0 0 6.27 6.27l.96-.96a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`;
const iconMobile = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`;
const contactsHtml = client.contacts && client.contacts.length > 0
? client.contacts.map(contact => `
<div class="contact-card">
<div class="contact-header">
<div class="contact-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><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>
<span class="contact-full-name">${displayValue(contact.firstname)} ${displayValue(contact.lastname)}</span>
<div class="contact-avatar">${icons.user}</div>
<span class="contact-full-name">${escapeHtml(displayValue(contact.firstname))} ${escapeHtml(displayValue(contact.lastname))}</span>
</div>
<div class="contact-details">
${contact.email ? `<div class="contact-row"><span class="contact-icon">${iconMail}</span><span>${displayValue(contact.email)}</span></div>` : ''}
${contact.phoneMobile ? `<div class="contact-row"><span class="contact-icon">${iconMobile}</span><span>${displayValue(contact.phoneMobile)}</span></div>` : ''}
${contact.phonePro ? `<div class="contact-row"><span class="contact-icon">${iconPhone}</span><span>${displayValue(contact.phonePro)}</span></div>` : ''}
${contact.phonePerso ? `<div class="contact-row"><span class="contact-icon">${iconPhone}</span><span>${displayValue(contact.phonePerso)}</span></div>` : ''}
${contact.email ? `<div class="contact-row"><span class="contact-icon">${icons.mail}</span><span>${escapeHtml(displayValue(contact.email))}</span></div>` : ''}
${contact.phoneMobile ? `<div class="contact-row"><span class="contact-icon">${icons.mobile}</span><span>${escapeHtml(displayValue(contact.phoneMobile))}</span></div>` : ''}
${contact.phonePro ? `<div class="contact-row"><span class="contact-icon">${icons.phone}</span><span>${escapeHtml(displayValue(contact.phonePro))}</span></div>` : ''}
${contact.phonePerso ? `<div class="contact-row"><span class="contact-icon">${icons.phone}</span><span>${escapeHtml(displayValue(contact.phonePerso))}</span></div>` : ''}
</div>
</div>
`).join('')
@ -161,8 +147,8 @@ export function renderClientesPage() {
modal.innerHTML = /*html*/`
<div class="client-modal">
<div class="client-modal-header">
<h2>${displayValue(client.name)}</h2>
<button class="btn-close-modal">✕</button>
<h2>${escapeHtml(displayValue(client.name))}</h2>
<button class="btn-close-modal">${icons.close}</button>
</div>
<div class="client-modal-body">
<div class="client-info-section">
@ -170,27 +156,27 @@ export function renderClientesPage() {
<div class="info-grid">
<div class="info-item">
<span class="info-label">ID</span>
<span class="info-value">${client.id}</span>
<span class="info-value">${escapeHtml(client.id)}</span>
</div>
<div class="info-item">
<span class="info-label">Código</span>
<span class="info-value">${displayValue(client.codeClient)}</span>
<span class="info-value">${escapeHtml(displayValue(client.codeClient))}</span>
</div>
<div class="info-item">
<span class="info-label">Tipo</span>
<span class="info-value">${displayValue(client.typentCode)}</span>
<span class="info-value">${escapeHtml(displayValue(client.typentCode))}</span>
</div>
<div class="info-item">
<span class="info-label">Estado</span>
<span class="info-value">${getClientStatusText(client.status)}</span>
<span class="info-value">${escapeHtml(getClientStatusText(client.status))}</span>
</div>
<div class="info-item">
<span class="info-label">Email</span>
<span class="info-value">${displayValue(client.email)}</span>
<span class="info-value">${escapeHtml(displayValue(client.email))}</span>
</div>
<div class="info-item">
<span class="info-label">Teléfono</span>
<span class="info-value">${displayValue(client.phone)}</span>
<span class="info-value">${escapeHtml(displayValue(client.phone))}</span>
</div>
</div>
</div>
@ -204,7 +190,6 @@ export function renderClientesPage() {
</div>
`;
// Cerrar modal
const closeBtn = modal.querySelector('.btn-close-modal');
closeBtn.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => {
@ -214,7 +199,6 @@ export function renderClientesPage() {
document.body.appendChild(modal);
}
// Función para cargar todos los clientes
async function loadAllClients() {
const clientsList = container.querySelector('.clients-list');
@ -223,22 +207,16 @@ export function renderClientesPage() {
const data = await getClients(1000, 1);
// Manejo de respuesta
allClients = Array.isArray(data) ? data : data.data || data.clients || [];
filteredClients = [...allClients];
console.log(`Total de clientes cargados: ${allClients.length}`);
// Renderizar la primera página
renderPage(1);
} catch (error) {
console.error('Error al cargar clientes:', error);
clientsList.innerHTML = '<tr><td colspan="7" class="error">Error al cargar los clientes. Por favor, intenta de nuevo.</td></tr>';
clientsList.innerHTML = `<tr><td colspan="7"><div class="empty-state">${icons.error}<h3>Error al cargar</h3><p>No se pudieron cargar los clientes. Inténtalo de nuevo.</p></div></td></tr>`;
}
}
// Función para actualizar la UI de paginación
function updatePaginationUI() {
const paginationDiv = container.querySelector('.pagination');
const currentPageSpan = container.querySelector('.current-page');
@ -249,53 +227,38 @@ export function renderClientesPage() {
currentPageSpan.textContent = currentPage;
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
btnPrev.disabled = currentPage === 1;
btnNext.disabled = currentPage === totalPages;
}
// Event listeners
const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
clearTimeout(searchInput.debounceTimer);
searchInput.debounceTimer = setTimeout(() => {
clearTimeout(searchInput._debounceTimer);
searchInput._debounceTimer = setTimeout(() => {
searchTerm = e.target.value.trim();
applyFilters();
}, 300);
});
// 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 todos los clientes al montar el componente
loadAllClients();
return container;
}
}

View File

@ -1,10 +1,12 @@
import { FormChangeTracker } from '../components/ConfirmExitModal.js';
import { navigationGuards } from '../router.js';
import { apiGet, apiPost } from '../services/apiClient.js';
import { icons } from '../services/icons.js';
import { showToast } from '../services/toast.js';
export function renderCreateInvoicePage() {
const container = document.createElement('div');
container.className = 'create-invoice-page';
container.className = 'create-invoice-page page-enter';
// Tracker de cambios
const changeTracker = new FormChangeTracker();
@ -206,13 +208,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;
}
@ -235,19 +237,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.', '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.', '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 de factura.', 'warning');
return;
}
@ -281,7 +283,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;
}
@ -298,12 +300,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,6 +1,7 @@
import { auth } from '../services/auth.js';
import { InvoiceModal } from '../components/InvoiceModal.js';
import { apiGet } from '../services/apiClient.js';
import { icons } from '../services/icons.js';
import {
Chart,
BarController,
@ -13,6 +14,13 @@ import {
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend);
function getGreeting() {
const hour = new Date().getHours();
if (hour < 12) return 'Buenos días';
if (hour < 20) return 'Buenas tardes';
return 'Buenas noches';
}
async function fetchAllInvoices() {
const data = await apiGet('/api/Invoices?limit=500&page=1');
return Array.isArray(data) ? data : data.data || data.invoices || [];
@ -75,6 +83,13 @@ function statusDotClass(status) {
return map[status] || 'status-draft';
}
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
function openInvoiceModal(invoiceId) {
const modal = InvoiceModal(invoiceId, null, () => { });
document.body.appendChild(modal);
@ -91,24 +106,20 @@ export function renderDashboard() {
container.innerHTML = /*html*/`
<div class="db-header">
<div>
<h1 class="db-greeting">Buenos dias, ${userName}</h1>
<p class="db-subtitle">Resumen de facturacion · ${year}</p>
<h1 class="db-greeting">${getGreeting()}, ${escapeHtml(userName)}</h1>
<p class="db-subtitle">Resumen de facturación · ${year}</p>
</div>
<button id="logout-button" class="logout-button">Cerrar sesion</button>
<button id="logout-button" class="logout-button">
${icons.logout}
<span>Cerrar sesión</span>
</button>
</div>
<div class="db-kpi-row">
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Facturas emitidas</span>
<div class="db-kpi-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<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>
<div class="db-kpi-icon db-kpi-icon--blue">${icons.fileText}</div>
</div>
<div class="db-kpi-value" id="kpi-total"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-total-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
@ -117,12 +128,7 @@ export function renderDashboard() {
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Total facturado</span>
<div class="db-kpi-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
</div>
<div class="db-kpi-icon db-kpi-icon--green">${icons.dollarSign}</div>
</div>
<div class="db-kpi-value" id="kpi-billed"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-billed-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
@ -131,12 +137,7 @@ export function renderDashboard() {
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Tasa de cobro</span>
<div class="db-kpi-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" 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>
</div>
<div class="db-kpi-icon db-kpi-icon--emerald">${icons.checkCircle}</div>
</div>
<div class="db-kpi-value" id="kpi-rate"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-rate-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
@ -145,12 +146,7 @@ export function renderDashboard() {
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Pendiente de cobro</span>
<div class="db-kpi-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="db-kpi-icon db-kpi-icon--amber">${icons.clock}</div>
</div>
<div class="db-kpi-value" id="kpi-pending"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-pending-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
@ -160,7 +156,7 @@ export function renderDashboard() {
<div class="db-mid-row">
<div class="db-chart-card">
<div class="db-card-header">
<span class="db-card-title">Facturacion trimestral</span>
<span class="db-card-title">Facturación trimestral</span>
<span class="db-card-year">${year}</span>
</div>
<div class="db-chart-body">
@ -186,14 +182,14 @@ export function renderDashboard() {
<div class="db-table-card">
<div class="db-card-header">
<span class="db-card-title">Ultimas facturas</span>
<span class="db-card-title">Últimas facturas</span>
<a href="#invoices" class="db-see-all">Ver todas</a>
</div>
<div class="db-table-wrap">
<table class="db-monitoring-table">
<thead>
<tr>
<th>Numero</th>
<th>Número</th>
<th>Cliente</th>
<th>Estado</th>
<th>Fecha</th>
@ -229,7 +225,6 @@ export function renderDashboard() {
const { total, totalBilled, paid, unpaid, draft, paidRate, pendingAmount } = calcKPIs(invoices);
// KPIs
container.querySelector('#kpi-total').textContent = total.toLocaleString('es-ES');
container.querySelector('#kpi-total-sub').innerHTML =
`<span class="db-sub-green">${paid} pagadas</span>&nbsp;&middot;&nbsp;` +
@ -248,10 +243,13 @@ export function renderDashboard() {
? `<span class="db-sub-red">${unpaid} factura${unpaid !== 1 ? 's' : ''} sin cobrar</span>`
: '<span class="db-sub-green">Sin importes pendientes</span>';
// Chart.js — quarterly bar chart
const canvas = container.querySelector('#db-quarterly-chart');
const q = calcQuarterly(invoices);
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const chartGridColor = isDark ? 'rgba(148, 163, 184, 0.08)' : '#f3f4f6';
const chartTickColor = isDark ? '#94a3b8' : '#9ca3af';
new Chart(canvas, {
type: 'bar',
data: {
@ -261,7 +259,7 @@ export function renderDashboard() {
data: q,
backgroundColor: 'rgba(37, 99, 235, 0.85)',
hoverBackgroundColor: 'rgba(29, 78, 216, 1)',
borderRadius: 4,
borderRadius: 6,
borderSkipped: false,
barPercentage: 0.5,
}]
@ -273,9 +271,9 @@ export function renderDashboard() {
legend: { display: false },
tooltip: {
callbacks: { label: ctx => ` ${formatCurrency(ctx.parsed.y)}` },
backgroundColor: '#111827',
backgroundColor: isDark ? '#1e293b' : '#111827',
titleColor: '#f9fafb',
bodyColor: '#d1d5db',
bodyColor: isDark ? '#cbd5e1' : '#d1d5db',
padding: 10,
cornerRadius: 6,
displayColors: false,
@ -286,52 +284,48 @@ export function renderDashboard() {
grid: { display: false },
border: { display: false },
ticks: {
color: '#6b7280',
color: chartTickColor,
font: { size: 12, family: 'Inter, system-ui, sans-serif' }
}
},
y: {
grid: { color: '#f3f4f6' },
grid: { color: chartGridColor },
border: { display: false },
ticks: {
color: '#9ca3af',
color: chartTickColor,
font: { size: 11, family: 'Inter, system-ui, sans-serif' },
callback: v => v >= 1000 ? `${(v / 1000).toFixed(0)}k €` : `${v} €`,
maxTicksLimit: 5,
}
}
},
animation: { duration: 500, easing: 'easeOutQuart' }
animation: { duration: 600, easing: 'easeOutQuart' }
}
});
// Recent activity list
const recent = [...invoices]
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 8);
const recentList = container.querySelector('#db-recent-list');
if (recent.length === 0) {
recentList.innerHTML = '<p class="db-no-data">No hay facturas recientes.</p>';
recentList.innerHTML = `<div class="db-no-data">${icons.emptyInboxes || ''}No hay facturas recientes.</div>`;
} else {
recentList.innerHTML = recent.map(inv => `
<div class="db-recent-item" data-id="${inv.id}" role="button" tabindex="0">
<div class="db-recent-dot ${statusDotClass(inv.status)}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
${icons.fileText}
</div>
<div class="db-recent-info">
<span class="db-recent-num">${inv.number || `#${inv.id}`}</span>
<span class="db-recent-client">${inv.clientName || '—'}</span>
<span class="db-recent-num">${escapeHtml(inv.number || `#${inv.id}`)}</span>
<span class="db-recent-client">${escapeHtml(inv.clientName || '—')}</span>
</div>
<div class="db-recent-right">
<span class="db-recent-amount">${formatCurrency(inv.total)}</span>
<span class="db-recent-date">${formatDate(inv.date)}</span>
</div>
<div class="db-recent-arrow">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
${icons.chevron}
</div>
</div>
`).join('');
@ -343,7 +337,6 @@ export function renderDashboard() {
});
}
// Monitoring table – last 15
const tableRows = [...invoices]
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 15);
@ -354,8 +347,8 @@ export function renderDashboard() {
} else {
tbody.innerHTML = tableRows.map(inv => `
<tr class="db-table-row" data-id="${inv.id}" role="button" tabindex="0">
<td class="db-cell-num">${inv.number || `#${inv.id}`}</td>
<td class="db-cell-client">${inv.clientName || '—'}</td>
<td class="db-cell-num">${escapeHtml(inv.number || `#${inv.id}`)}</td>
<td class="db-cell-client">${escapeHtml(inv.clientName || '—')}</td>
<td>${getStatusBadge(inv.status)}</td>
<td>${formatDate(inv.date)}</td>
<td>${formatDate(inv.expireDate)}</td>
@ -373,4 +366,4 @@ export function renderDashboard() {
})();
return container;
}
}

View File

@ -1,12 +1,12 @@
import { InvoiceItem } from '../components/InvoiceItem.js';
import { InvoiceModal } from '../components/InvoiceModal.js';
import { apiGet } from '../services/apiClient.js';
import { icons } from '../services/icons.js';
export function renderFacturasPage() {
const container = document.createElement('div');
container.className = 'facturas-page';
container.className = 'facturas-page page-enter';
// Estado de paginación
let currentPage = 1;
let totalPages = 1;
const pageSize = 20;
@ -14,8 +14,6 @@ export function renderFacturasPage() {
let filteredInvoices = [];
let currentFilter = '';
let searchTerm = '';
// Estado de ordenación
let sortKey = null;
let sortDir = 'asc';
@ -25,14 +23,17 @@ export function renderFacturasPage() {
</div>
<div class="facturas-filters">
<input type="search" placeholder="Buscar facturas..." class="search-input" />
<div class="search-wrapper">
<span class="search-icon">${icons.search}</span>
<input type="search" placeholder="Buscar facturas..." class="search-input search-input--with-icon" />
</div>
<select class="filter-status">
<option value="">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="unpaid">Pte. Pago</option>
<option value="paid">Pagada</option>
</select>
<button class="btn-new-invoice">+ Nueva Factura</button>
<button class="btn-new-invoice">${icons.plus} <span>Nueva Factura</span></button>
</div>
<div class="invoices-table-container">
@ -61,9 +62,7 @@ export function renderFacturasPage() {
</tr>
</thead>
<tbody class="invoices-list">
<tr>
<td colspan="7" class="loading">Cargando facturas...</td>
</tr>
<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>
</tbody>
</table>
</div>
@ -100,7 +99,6 @@ export function renderFacturasPage() {
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) => {
@ -135,13 +133,12 @@ export function renderFacturasPage() {
});
}
// ── 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 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;
@ -151,16 +148,15 @@ export function renderFacturasPage() {
summaryEl.style.display = 'flex';
}
// ── Render current page ──────────────────────────────────────
function renderPage(page = 1) {
const invoicesList = container.querySelector('.invoices-list');
currentPage = page;
const sorted = sortInvoices(filteredInvoices);
totalPages = Math.ceil(sorted.length / pageSize);
totalPages = Math.ceil(sorted.length / pageSize);
const startIndex = (page - 1) * pageSize;
const invoices = sorted.slice(startIndex, startIndex + pageSize);
const invoices = sorted.slice(startIndex, startIndex + pageSize);
updatePaginationUI();
updateSortIcons();
@ -169,7 +165,7 @@ export function renderFacturasPage() {
invoicesList.innerHTML = '';
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">${icons.emptyInvoices}<h3>No hay facturas</h3><p>Crea tu primera factura para comenzar.</p></div></td></tr>`;
return;
}
@ -179,7 +175,6 @@ export function renderFacturasPage() {
});
}
// Manejar ver/editar factura
function handleViewInvoice(invoiceId) {
const modal = InvoiceModal(invoiceId, null, () => {
loadAllInvoices();
@ -187,7 +182,6 @@ export function renderFacturasPage() {
document.body.appendChild(modal);
}
// Función para cargar todas las facturas
async function loadAllInvoices() {
const invoicesList = container.querySelector('.invoices-list');
@ -206,7 +200,7 @@ export function renderFacturasPage() {
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>';
invoicesList.innerHTML = `<tr><td colspan="7"><div class="empty-state">${icons.error}<h3>Error al cargar</h3><p>No se pudieron cargar las facturas. Inténtalo de nuevo.</p></div></td></tr>`;
}
}
@ -214,23 +208,21 @@ export function renderFacturasPage() {
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 btnPrev = container.querySelector('.btn-prev');
const btnNext = container.querySelector('.btn-next');
currentPageSpan.textContent = currentPage;
totalPagesSpan.textContent = totalPages;
totalPagesSpan.textContent = totalPages;
paginationDiv.style.display = totalPages <= 1 ? 'none' : 'flex';
btnPrev.disabled = currentPage === 1;
btnNext.disabled = currentPage === totalPages;
}
// ── Sort click listeners ─────────────────────────────────────
container.querySelectorAll('th.sortable').forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
@ -245,7 +237,6 @@ export function renderFacturasPage() {
});
});
// ── Other event listeners ────────────────────────────────────
container.querySelector('.btn-new-invoice').addEventListener('click', () => {
window.location.hash = '#create-invoice';
});
@ -253,8 +244,8 @@ export function renderFacturasPage() {
const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
const inputValue = e.target.value.trim();
clearTimeout(searchInput.debounceTimer);
searchInput.debounceTimer = setTimeout(() => {
clearTimeout(searchInput._debounceTimer);
searchInput._debounceTimer = setTimeout(() => {
searchTerm = /\d/.test(inputValue) ? inputValue : '';
applyFilters();
}, 500);
@ -280,4 +271,4 @@ export function renderFacturasPage() {
loadAllInvoices();
return container;
}
}

View File

@ -1,4 +1,5 @@
import { isDarkTheme, toggleTheme } from '../services/theme.js';
import { icons } from '../services/icons.js';
function decodeTokenPayload(token) {
try {
@ -50,10 +51,12 @@ export function renderSettingsPage() {
const container = document.createElement('div');
container.className = 'settings-page';
const dark = isDarkTheme();
container.innerHTML = `
<div class="settings-header">
<h1>Configuracion</h1>
<p>Preferencias visuales y estado de sesion.</p>
<h1>Configuración</h1>
<p>Preferencias visuales y estado de sesión.</p>
</div>
<div class="settings-grid">
@ -64,19 +67,33 @@ export function renderSettingsPage() {
<p class="settings-label">Modo oscuro</p>
<p class="settings-help">Cambia entre tema claro y oscuro.</p>
</div>
<button type="button" class="settings-theme-toggle" id="settings-theme-toggle"></button>
<button type="button" class="settings-theme-toggle ${dark ? 'settings-theme-toggle--active' : ''}" id="settings-theme-toggle" aria-pressed="${dark ? 'true' : 'false'}">
<span class="toggle-track">
<span class="toggle-icons">
<span class="toggle-icon-sun">${icons.sun}</span>
<span class="toggle-icon-moon">${icons.moon}</span>
</span>
<span class="toggle-thumb"></span>
</span>
</button>
</div>
</section>
<section class="settings-card">
<h2>Sesion</h2>
<section class="settings-card settings-card--session">
<h2>Sesión</h2>
<div class="settings-session-list">
<div class="settings-session-item">
<span class="settings-label">Caducidad del token</span>
<div class="settings-session-item-label">
<span class="settings-session-icon">${icons.info}</span>
<span>Caducidad del token</span>
</div>
<strong id="token-expire-at">-</strong>
</div>
<div class="settings-session-item">
<span class="settings-label">Tiempo restante</span>
<div class="settings-session-item-label">
<span class="settings-session-icon">${icons.clock}</span>
<span>Tiempo restante</span>
</div>
<strong id="token-remaining">-</strong>
</div>
</div>
@ -89,9 +106,9 @@ export function renderSettingsPage() {
const remainingEl = container.querySelector('#token-remaining');
const renderThemeButton = () => {
const dark = isDarkTheme();
themeBtn.textContent = dark ? 'Activado' : 'Desactivado';
themeBtn.setAttribute('aria-pressed', dark ? 'true' : 'false');
const isDark = isDarkTheme();
themeBtn.classList.toggle('settings-theme-toggle--active', isDark);
themeBtn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
};
const renderTokenInfo = () => {
@ -118,4 +135,4 @@ export function renderSettingsPage() {
container.cleanup = () => clearInterval(intervalId);
return container;
}
}

View File

@ -3,13 +3,13 @@ import { renderFacturasPage } from './Facturas.js';
import { renderCreateInvoicePage } from './CreateInvoicePage.js';
import { renderClientesPage } from './ClientesPage.js';
import { renderSettingsPage } from './SettingsPage.js';
import { icons } from '../services/icons.js';
// Registry of all pages available in the application
export const pagesRegistry = [
{
route: 'dashboard',
name: 'Dashboard',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
icon: icons.dashboard,
voicePatterns: ['dashboard', 'inicio', 'ir al inicio', 'ir al dashboard', 'resumen', 'home', 'panel', 'principal'],
requiresAuth: true,
showInSidebar: true,
@ -18,7 +18,7 @@ export const pagesRegistry = [
{
route: 'invoices',
name: 'Facturas',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/><polyline points="10 9 9 9 8 9"/></svg>',
icon: icons.invoices,
voicePatterns: ['facturas', 'ver facturas', 'ir a facturas', 'lista facturas', 'mis facturas', 'listado facturas'],
requiresAuth: true,
showInSidebar: true,
@ -27,16 +27,16 @@ export const pagesRegistry = [
{
route: 'create-invoice',
name: 'Nueva Factura',
icon: '+',
icon: icons.plus,
voicePatterns: ['nueva factura', 'crear factura', 'factura nueva', 'añadir factura'],
requiresAuth: true,
showInSidebar: false, // No mostrar en sidebar
showInSidebar: false,
render: renderCreateInvoicePage
},
{
route: 'clients',
name: 'Clientes',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
icon: icons.clients,
voicePatterns: ['clientes', 'ver clientes', 'ir a clientes', 'lista clientes', 'mis clientes'],
requiresAuth: true,
showInSidebar: true,
@ -45,25 +45,24 @@ export const pagesRegistry = [
{
route: 'settings',
name: 'Configuración',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>', voicePatterns: ['configuración', 'configuracion', 'ajustes', 'settings', 'preferencias'], requiresAuth: true,
icon: icons.settings,
voicePatterns: ['configuración', 'configuracion', 'ajustes', 'settings', 'preferencias'],
requiresAuth: true,
showInSidebar: true,
render: renderSettingsPage
}
];
// Get pages that the user can access
export function getAvailablePages(isAuthenticated) {
if (!isAuthenticated) {
return [];
}
// Filter pages based on authentication requirement and sidebar visibility
return pagesRegistry.filter(page => {
if (page.requiresAuth && !isAuthenticated) return false;
return page.showInSidebar;
});
}
// Get page by route
export function getPageByRoute(route) {
return pagesRegistry.find(page => page.route === route);
}
}

View File

@ -118,8 +118,9 @@ export function initRouter() {
return;
}
// Render page content using registry
const pageContent = page.render();
// Render page content using registry (supports async render)
const maybePromise = page.render();
const pageContent = maybePromise instanceof Promise ? await maybePromise : maybePromise;
// Mobile sidebar: backdrop + topbar
const sidebarBackdrop = document.createElement('div');

99
src/services/icons.js Normal file
View File

@ -0,0 +1,99 @@
export const doliLogo = (size = 28) => `
<svg width="${size}" height="${size}" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="doli-logo-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#doli-logo-grad)"/>
<path d="M10 7h7.5a3.5 3.5 0 0 1 2.47 6L14.5 16.5a3.5 3.5 0 0 1-2.47 6H10V7z" fill="#fff" opacity=".92"/>
<path d="M10 17h5.3" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity=".55"/>
<path d="M10 21.5h5" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity=".55"/>
</svg>`;
export const doliLogoFull = (height = 24) => `
<svg width="${height * 2.2}" height="${height}" viewBox="0 0 88 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="doli-full-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#doli-full-grad)"/>
<path d="M10 7h7.5a3.5 3.5 0 0 1 2.47 6L14.5 16.5a3.5 3.5 0 0 1-2.47 6H10V7z" fill="#fff" opacity=".92"/>
<path d="M10 17h5.3" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity=".55"/>
<path d="M10 21.5h5" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity=".55"/>
<text x="42" y="23" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="16" fill="var(--text-primary, #0f172a)">Doli</text>
</svg>`;
export const icons = {
dashboard: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="4" rx="1.5"/><rect x="14" y="11" width="7" height="10" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/></svg>`,
invoices: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/><path d="M9 13h6"/><path d="M9 17h4"/></svg>`,
clients: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M21 21v-2a4 4 0 0 0-3-3.87"/></svg>`,
settings: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
logout: `<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>`,
menu: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`,
chevron: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`,
search: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
plus: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
close: `<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>`,
dollarSign: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>`,
checkCircle: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" 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>`,
clock: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
fileText: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><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"/><path d="M9 13h6"/><path d="M9 17h4"/></svg>`,
mail: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
phone: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.38 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.82a16 16 0 0 0 6.27 6.27l.96-.96a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`,
mobile: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`,
user: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><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>`,
calendar: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
eye: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>`,
lock: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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"/></svg>`,
sort: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 4 8 16"/><path d="M4 12l8 8 8-8"/></svg>`,
emptyInvoices: `<svg width="64" height="64" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="16" y="4" width="48" height="72" rx="6" fill="var(--blue-50, #eff6ff)" stroke="var(--blue-500, #3b82f6)" stroke-width="2"/>
<line x1="26" y1="22" x2="54" y2="22" stroke="var(--blue-200, #bfdbfe)" stroke-width="2.5" stroke-linecap="round"/>
<line x1="26" y1="32" x2="48" y2="32" stroke="var(--blue-200, #bfdbfe)" stroke-width="2.5" stroke-linecap="round"/>
<line x1="26" y1="42" x2="44" y2="42" stroke="var(--blue-200, #bfdbfe)" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="58" cy="60" r="14" fill="var(--card-bg, #fff)" stroke="var(--blue-500, #3b82f6)" stroke-width="2"/>
<path d="M54 60h8" stroke="var(--blue-500, #3b82f6)" stroke-width="2.5" stroke-linecap="round"/>
<path d="M58 56v8" stroke="var(--blue-500, #3b82f6)" stroke-width="2.5" stroke-linecap="round"/>
</svg>`,
emptyClients: `<svg width="64" height="64" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="24" r="12" fill="var(--blue-50, #eff6ff)" stroke="var(--blue-500, #3b82f6)" stroke-width="2"/>
<path d="M12 68v-4a20 20 0 0 1 40 0v4" stroke="var(--blue-500, #3b82f6)" stroke-width="2" fill="var(--blue-50, #eff6ff)"/>
<circle cx="56" cy="28" r="10" fill="var(--card-bg, #fff)" stroke="var(--blue-200, #bfdbfe)" stroke-width="2" stroke-dasharray="4 3"/>
<path d="M44 68v-2a14 14 0 0 1 8-12.5" stroke="var(--blue-200, #bfdbfe)" stroke-width="2" fill="none" stroke-dasharray="4 3"/>
</svg>`,
error: `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--danger, #ef4444)" stroke-width="1.5" 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>`,
sun: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
moon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`,
info: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" 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>`
};

View File

@ -41,19 +41,23 @@
--red-600: #dc2626;
--red-700: #b91c1c;
/* Semantic tokens */
--primary: var(--blue-600);
--primary-hover: var(--blue-700);
--primary-light: rgba(37, 99, 235, 0.06);
--primary-subtle: rgba(37, 99, 235, 0.08);
--success: var(--green-600);
--success-hover: var(--green-700);
--warning: var(--amber-600);
--warning-hover: #a16207;
--danger: var(--red-500);
--danger-hover: var(--red-600);
--card-bg: #ffffff;
--border-color: var(--gray-200);
--border-subtle: var(--gray-100);
--text-primary: var(--gray-900);
--text-secondary: var(--gray-500);
--text-tertiary: var(--gray-400);
--bg-page: var(--gray-50);
--bg-subtle: var(--gray-100);
@ -92,6 +96,19 @@
-moz-osx-font-smoothing: grayscale;
}
/* ── Large / Presentation Displays ── */
@media (min-width: 1600px) {
:root {
font-size: 15px;
}
}
@media (min-width: 2200px) {
:root {
font-size: 16px;
}
}
:root[data-theme='dark'] {
--gray-50: #0f172a;
--gray-100: #111827;
@ -108,8 +125,10 @@
--border-color: #1f2a3d;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--bg-page: #070d18;
--bg-subtle: #0f172a;
--border-subtle: #1f2a3d;
color-scheme: dark;
}

View File

@ -255,3 +255,96 @@ button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
transition: background 0.15s ease;
}
.empty-state .btn-empty-action:hover { background: var(--primary-hover); }
/* ── Page entrance animation ── */
.page-enter {
animation: pageEnter 0.3s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes pageEnter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Stagger animation for KPI cards ── */
.db-kpi-card { animation: fadeInUp 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; }
.db-kpi-card:nth-child(1) { animation-delay: 0ms; }
.db-kpi-card:nth-child(2) { animation-delay: 80ms; }
.db-kpi-card:nth-child(3) { animation-delay: 160ms; }
.db-kpi-card:nth-child(4) { animation-delay: 240ms; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Hover lift for cards ── */
.db-kpi-card,
.settings-card,
.db-chart-card,
.db-recent-card,
.db-table-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.db-kpi-card:hover,
.db-chart-card:hover,
.db-recent-card:hover,
.db-table-card:hover {
transform: translateY(-1px);
}
/* ── Status badge enhanced ── */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.6;
}
.badge::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
}
.badge-draft {
background: var(--gray-100);
color: var(--gray-600);
}
.badge-draft::before { background: var(--gray-400); }
.badge-unpaid {
background: var(--amber-50);
color: var(--amber-600);
}
.badge-unpaid::before { background: var(--amber-600); }
.badge-paid {
background: var(--green-50);
color: var(--green-600);
}
.badge-paid::before { background: var(--green-600); }
:root[data-theme='dark'] .badge-draft {
background: rgba(148, 163, 184, 0.14);
color: #cbd5e1;
}
:root[data-theme='dark'] .badge-draft::before { background: #64748b; }
:root[data-theme='dark'] .badge-unpaid {
background: rgba(245, 158, 11, 0.15);
color: #fcd34d;
}
:root[data-theme='dark'] .badge-unpaid::before { background: #fcd34d; }
:root[data-theme='dark'] .badge-paid {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
}
:root[data-theme='dark'] .badge-paid::before { background: #86efac; }

View File

@ -8,6 +8,15 @@
margin: 0 auto;
}
@media (min-width: 1600px) {
.clientes-page { max-width: 1600px; }
.clientes-header h1 { font-size: 1.5rem; }
}
@media (min-width: 2200px) {
.clientes-page { max-width: 2000px; }
}
.clientes-header {
margin-bottom: var(--space-5);
}
@ -29,6 +38,11 @@
max-width: 400px;
}
.clientes-filters .search-wrapper {
flex: 1;
max-width: 400px;
}
.client-avatar-col {
width: 48px;
}

View File

@ -9,6 +9,20 @@
min-height: 100vh;
}
@media (min-width: 1600px) {
.create-invoice-page { max-width: 1100px; }
.invoice-header-compact h1 { font-size: 1.5rem; }
.form-field-compact input,
.form-field-compact select,
.form-field-compact textarea { height: 42px; font-size: 15px; }
.btn-submit-compact,
.btn-cancel-compact { height: 44px; font-size: 15px; }
.line-desc-compact,
.line-qty-compact,
.line-price-compact,
.line-tax-compact { height: 38px; font-size: 14px; }
}
.invoice-header-compact {
display: flex;
justify-content: space-between;

View File

@ -55,8 +55,8 @@
}
.db-kpi-icon {
width: 28px;
height: 28px;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
background: var(--gray-100);
display: flex;
@ -66,6 +66,26 @@
flex-shrink: 0;
}
.db-kpi-icon--blue {
background: rgba(37, 99, 235, 0.1);
color: var(--blue-600);
}
.db-kpi-icon--green {
background: rgba(22, 163, 74, 0.1);
color: var(--green-600);
}
.db-kpi-icon--emerald {
background: rgba(5, 150, 105, 0.1);
color: #059669;
}
.db-kpi-icon--amber {
background: rgba(217, 119, 6, 0.1);
color: var(--amber-600);
}
.db-kpi-label {
font-size: 11px;
font-weight: 500;
@ -413,12 +433,69 @@
.db-greeting { font-size: 1.1rem; }
}
/* ── Large / Presentation Displays ── */
@media (min-width: 1600px) {
.db-kpi-row {
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
}
.db-kpi-value { font-size: 2rem; }
.db-kpi-label { font-size: 12px; }
.db-greeting { font-size: 1.75rem; }
.db-subtitle { font-size: 15px; }
.db-card-title { font-size: 15px; }
.db-chart-body { height: 340px; }
.db-recent-list { max-height: 400px; }
.dashboard-subtitle { font-size: 15px; }
}
@media (min-width: 2200px) {
.db-kpi-value { font-size: 2.5rem; }
.db-greeting { font-size: 2rem; }
.db-kpi-card { padding: var(--space-6); }
.db-card-header { padding: var(--space-5) var(--space-6); }
.db-chart-body { height: 420px; }
}
.dashboard-subtitle {
color: var(--text-secondary);
font-size: 13px;
margin: var(--space-1) 0 0 0;
}
/* Logout button with icon */
.logout-button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background-color: transparent;
color: var(--text-secondary);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: inherit;
transition: color var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
white-space: nowrap;
}
.logout-button:hover {
color: var(--danger);
border-color: var(--danger);
background-color: var(--red-50);
}
.logout-button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.logout-button svg {
flex-shrink: 0;
}
/* Dark mode UX tune */
:root[data-theme='dark'] .db-kpi-card,
:root[data-theme='dark'] .db-chart-card,
@ -458,3 +535,33 @@
background: rgba(148, 163, 184, 0.18);
color: #cbd5e1;
}
:root[data-theme='dark'] .db-kpi-icon--blue {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
}
:root[data-theme='dark'] .db-kpi-icon--green {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
:root[data-theme='dark'] .db-kpi-icon--emerald {
background: rgba(16, 185, 129, 0.15);
color: #6ee7b7;
}
:root[data-theme='dark'] .db-kpi-icon--amber {
background: rgba(245, 158, 11, 0.15);
color: #fcd34d;
}
:root[data-theme='dark'] .logout-button {
background-color: transparent;
border-color: rgba(148, 163, 184, 0.2);
}
:root[data-theme='dark'] .logout-button:hover {
background-color: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}

View File

@ -12,7 +12,7 @@
.facturas-header h1 { font-size: 1.25rem; font-weight: 600; }
.btn-new-invoice {
background-color: var(--primary);
background: linear-gradient(135deg, var(--blue-600), var(--blue-700));
color: white;
padding: var(--space-2) var(--space-4);
border: none;
@ -20,11 +20,25 @@
cursor: pointer;
font-weight: 500;
font-size: 14px;
transition: background-color var(--transition-fast);
font-family: inherit;
transition: all 0.2s ease;
height: 36px;
display: inline-flex;
align-items: center;
gap: var(--space-1);
letter-spacing: 0.01em;
}
.btn-new-invoice:hover { background-color: var(--primary-hover); }
.btn-new-invoice:hover {
background: linear-gradient(135deg, var(--blue-700), #1e3a8a);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
.btn-new-invoice:active {
transform: translateY(0);
box-shadow: none;
}
.facturas-filters {
display: flex;
@ -56,6 +70,30 @@
.search-input { flex: 1; max-width: 320px; }
.filter-status { min-width: 180px; cursor: pointer; }
.search-wrapper {
position: relative;
display: flex;
align-items: center;
flex: 1;
max-width: 340px;
}
.search-icon {
position: absolute;
left: 10px;
color: var(--text-tertiary, var(--gray-400));
display: flex;
align-items: center;
pointer-events: none;
}
.search-input--with-icon {
padding-left: 34px !important;
max-width: 100%;
flex: none;
width: 100%;
}
.invoices-table-container,
.clients-table-container {
background: var(--card-bg);
@ -403,3 +441,17 @@
display: none;
}
}
@media (min-width: 1600px) {
.facturas-page { max-width: 1600px; }
.invoices-table th,
.invoices-table td { font-size: 14px; padding: var(--space-3) var(--space-5); }
.facturas-header h1 { font-size: 1.5rem; }
.filter-status { min-width: 220px; }
}
@media (min-width: 2200px) {
.facturas-page { max-width: 2000px; }
.invoices-table th,
.invoices-table td { padding: var(--space-4) var(--space-6); }
}

View File

@ -1,221 +1,355 @@
/* ============================================
Login + Session Notices
============================================ */
Login + Session Notices
============================================ */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
width: 100%;
background: linear-gradient(145deg, var(--bg-page) 0%, var(--blue-50) 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
width: 100%;
background: linear-gradient(135deg, var(--bg-page) 0%, var(--blue-50) 50%, var(--bg-page) 100%);
position: relative;
overflow: hidden;
}
.login-container::before {
content: '';
position: absolute;
width: 500px;
height: 500px;
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.08) 0%, transparent 70%);
top: -150px;
right: -100px;
pointer-events: none;
animation: loginPulse 8s ease-in-out infinite;
}
.login-container::after {
content: '';
position: absolute;
width: 400px;
height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.05) 0%, transparent 70%);
bottom: -120px;
left: -80px;
pointer-events: none;
animation: loginPulse 10s ease-in-out infinite reverse;
}
@keyframes loginPulse {
0%, 100% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.1); opacity: 1; }
}
.login-card {
background: var(--card-bg);
padding: var(--space-8);
border-radius: 12px;
box-shadow: var(--shadow-md);
width: 100%;
max-width: 390px;
border: 1px solid var(--border-color);
background: var(--card-bg);
padding: var(--space-8);
border-radius: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 10px 30px -5px rgba(0, 0, 0, 0.08);
width: 100%;
max-width: 400px;
border: 1px solid var(--border-color);
position: relative;
z-index: 1;
animation: loginCardIn 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes loginCardIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.login-brand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-bottom: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-5);
}
.login-logo-icon {
width: 40px;
height: 40px;
background: var(--primary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
animation: logoFloat 3s ease-in-out infinite;
filter: drop-shadow(0 4px 12px rgba(37, 99, 235, 0.25));
}
@keyframes logoFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
.login-brand-name {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.login-title {
color: var(--text-primary);
margin: 0 0 var(--space-1) 0;
font-size: 1.125rem;
text-align: center;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-1) 0;
font-size: 1.25rem;
text-align: center;
font-weight: 600;
}
.login-subtitle {
color: var(--text-secondary);
font-size: 13px;
text-align: center;
margin: 0 0 var(--space-5) 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
margin: 0 0 var(--space-5) 0;
}
.form-group {
margin-bottom: var(--space-4);
margin-bottom: var(--space-4);
}
.form-group label {
display: block;
color: var(--text-secondary);
margin-bottom: var(--space-1);
font-weight: 500;
font-size: 13px;
display: block;
color: var(--text-secondary);
margin-bottom: var(--space-1);
font-weight: 500;
font-size: 13px;
}
.form-group input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--card-bg);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
box-sizing: border-box;
transition: border-color var(--transition-fast);
height: 36px;
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--card-bg);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
height: 40px;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.form-group input:focus:not(:focus-visible) {
box-shadow: none;
}
.login-button {
width: 100%;
padding: var(--space-2) var(--space-4);
background-color: var(--primary);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition-fast);
height: 36px;
margin-top: var(--space-2);
width: 100%;
padding: var(--space-3) var(--space-4);
background: linear-gradient(135deg, var(--blue-600), var(--blue-700));
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
height: 42px;
margin-top: var(--space-2);
letter-spacing: 0.01em;
position: relative;
overflow: hidden;
}
.login-button::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.1), transparent);
opacity: 0;
transition: opacity 0.2s ease;
}
.login-button:hover {
background-color: var(--primary-hover);
border-color: transparent;
background: linear-gradient(135deg, var(--blue-700), #1e3a8a);
border-color: transparent;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
.login-button:hover::after {
opacity: 1;
}
.login-button:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.2);
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.login-error {
color: var(--danger);
text-align: center;
margin-top: var(--space-3);
font-size: 13px;
color: var(--danger);
text-align: center;
margin-top: var(--space-3);
font-size: 13px;
min-height: 20px;
}
.session-warning-overlay,
.connection-lost-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.session-warning-overlay {
background: rgba(17, 24, 39, 0.45);
z-index: 3000;
background: rgba(17, 24, 39, 0.45);
z-index: 3000;
backdrop-filter: blur(4px);
}
.connection-lost-overlay {
background: rgba(17, 24, 39, 0.55);
z-index: 4000;
background: rgba(17, 24, 39, 0.55);
z-index: 4000;
backdrop-filter: blur(4px);
}
.session-warning-modal,
.connection-lost-modal {
width: 100%;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: var(--space-6);
width: 100%;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: var(--space-6);
}
.session-warning-modal { max-width: 430px; }
.connection-lost-modal { max-width: 460px; }
.session-warning-modal h3 {
margin: 0 0 var(--space-3) 0;
font-size: 1.15rem;
margin: 0 0 var(--space-3) 0;
font-size: 1.15rem;
}
.connection-lost-modal h3 {
margin: 0 0 var(--space-2) 0;
color: var(--danger);
margin: 0 0 var(--space-2) 0;
color: var(--danger);
}
.session-warning-modal p,
.connection-lost-modal p {
margin: 0;
color: var(--text-secondary);
margin: 0;
color: var(--text-secondary);
}
.session-warning-actions {
margin-top: var(--space-5);
display: flex;
justify-content: flex-end;
gap: var(--space-2);
margin-top: var(--space-5);
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.session-warning-logout {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.session-warning-logout:hover {
background: var(--gray-100);
background: var(--gray-100);
}
.session-warning-continue {
background: var(--primary);
background: var(--primary);
}
.connection-lost-sub {
margin-top: var(--space-3) !important;
font-size: 0.92rem;
margin-top: var(--space-3) !important;
font-size: 0.92rem;
}
.connection-lost-login-btn {
margin-top: var(--space-5);
width: 100%;
margin-top: var(--space-5);
width: 100%;
}
@media (max-width: 640px) {
.login-card {
margin: var(--space-4);
padding: var(--space-6);
}
.login-card {
margin: var(--space-4);
padding: var(--space-6);
}
.session-warning-actions {
flex-direction: column;
}
.session-warning-actions {
flex-direction: column;
}
.session-warning-actions button {
width: 100%;
}
.session-warning-actions button {
width: 100%;
}
}
@media (min-width: 1600px) {
.login-card {
max-width: 440px;
padding: var(--space-10);
border-radius: 20px;
}
.login-title {
font-size: 1.4rem;
}
.login-subtitle {
font-size: 15px;
}
.login-logo-icon {
width: 64px;
height: 64px;
}
.form-group label {
font-size: 14px;
}
.form-group input {
height: 46px;
font-size: 15px;
padding: var(--space-3) var(--space-4);
}
.login-button {
height: 48px;
font-size: 16px;
}
}
:root[data-theme='dark'] .login-container {
background: linear-gradient(145deg, var(--bg-page) 0%, rgba(30, 58, 110, 0.2) 100%);
background: linear-gradient(135deg, var(--bg-page) 0%, rgba(30, 58, 110, 0.15) 50%, var(--bg-page) 100%);
}
:root[data-theme='dark'] .login-container::before {
background: radial-gradient(circle, rgba(59, 130, 246, 0.06) 0%, transparent 70%);
}
:root[data-theme='dark'] .login-container::after {
background: radial-gradient(circle, rgba(59, 130, 246, 0.04) 0%, transparent 70%);
}
:root[data-theme='dark'] .login-card {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 10px 30px -5px rgba(0, 0, 0, 0.2);
border-color: var(--border-color);
}
:root[data-theme='dark'] .login-logo-icon {
filter: drop-shadow(0 4px 12px rgba(37, 99, 235, 0.2));
}

View File

@ -8,18 +8,29 @@
padding: var(--space-6);
}
@media (min-width: 1600px) {
.settings-page { max-width: 780px; }
.settings-card { padding: var(--space-6); }
.settings-header h1 { font-size: 1.5rem; }
}
.settings-header {
margin-bottom: var(--space-6);
}
.settings-header h1 {
margin: 0 0 var(--space-1) 0;
}
.settings-header p {
margin-top: var(--space-2);
margin: 0;
color: var(--text-secondary);
font-size: 13px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
display: flex;
flex-direction: column;
gap: var(--space-4);
}
@ -32,8 +43,9 @@
}
.settings-card h2 {
margin-bottom: var(--space-4);
margin: 0 0 var(--space-4) 0;
font-size: 1.05rem;
font-weight: 600;
}
.settings-row {
@ -56,14 +68,90 @@
color: var(--text-secondary);
}
/* ── Toggle Switch ── */
.settings-theme-toggle {
min-width: 116px;
position: relative;
background: none;
border: none;
padding: 0;
cursor: pointer;
outline: none;
}
.settings-theme-toggle:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 4px;
border-radius: 4px;
}
.toggle-track {
display: flex;
align-items: center;
width: 56px;
height: 30px;
border-radius: 15px;
background: var(--gray-300);
position: relative;
transition: background-color 0.25s ease;
overflow: hidden;
}
.settings-theme-toggle--active .toggle-track {
background: var(--blue-600);
}
.toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 2;
}
.settings-theme-toggle--active .toggle-thumb {
transform: translateX(26px);
}
.toggle-icons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 7px;
position: relative;
z-index: 1;
pointer-events: none;
}
.toggle-icon-sun,
.toggle-icon-moon {
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
transition: opacity 0.2s ease;
}
.toggle-icon-sun { opacity: 1; }
.toggle-icon-moon { opacity: 0.6; }
.settings-theme-toggle--active .toggle-icon-sun { opacity: 0.6; }
.settings-theme-toggle--active .toggle-icon-moon { opacity: 1; }
/* ── Session Section ── */
.settings-card--session {
background: var(--card-bg);
}
.settings-session-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
gap: var(--space-3);
}
.settings-session-item {
@ -71,7 +159,7 @@
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding-bottom: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px dashed var(--border-color);
}
@ -80,6 +168,47 @@
padding-bottom: 0;
}
.settings-token-expired {
color: var(--danger);
.settings-session-item-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.9rem;
color: var(--text-primary);
font-weight: 500;
}
.settings-session-icon {
display: flex;
align-items: center;
color: var(--text-tertiary, var(--text-secondary));
}
.settings-token-expired {
color: var(--danger) !important;
font-weight: 600;
}
/* ── Dark mode ── */
:root[data-theme='dark'] .toggle-track {
background: var(--gray-700);
}
:root[data-theme='dark'] .settings-theme-toggle--active .toggle-track {
background: var(--blue-600);
}
:root[data-theme='dark'] .toggle-thumb {
background: #e2e8f0;
}
@media (max-width: 580px) {
.settings-page {
padding: var(--space-4);
}
.settings-session-item {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
}

View File

@ -38,15 +38,13 @@
}
.sidebar-logo-icon {
width: 26px;
height: 26px;
background: var(--primary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(37, 99, 235, 0.2));
}
.sidebar-header h2 {
@ -270,6 +268,25 @@
padding: var(--space-6);
}
@media (min-width: 1600px) {
.sidebar { width: 250px; }
.main-content > div {
max-width: 1440px;
padding: var(--space-8);
}
}
@media (min-width: 2200px) {
.sidebar { width: 280px; }
.sidebar-link { font-size: 15px; padding: var(--space-3) var(--space-4); }
.sidebar-avatar { width: 32px; height: 32px; font-size: 13px; }
.sidebar-username { font-size: 13px; }
.main-content > div {
max-width: 1800px;
padding: var(--space-10);
}
}
/* ── Mobile topbar (hidden on desktop) ── */
.mobile-topbar {
display: none;