feat: módulo banco, acciones masivas facturas, resumen trimestral IVA, colores importes y mejoras UI

This commit is contained in:
Алекс 2026-05-15 21:50:10 +02:00
parent 696e97080d
commit f9ad12197a
21 changed files with 2545 additions and 151 deletions

View File

@ -18,22 +18,24 @@ export function InvoiceItem(invoice, onView) {
const getStatusClass = (status) => {
const statusMap = {
'draft': 'badge-draft',
'validated': 'badge-unpaid',
'paid': 'badge-paid',
'unpaid': 'badge-unpaid',
'canceled': 'badge-draft'
'draft': 'status-draft',
'validated': 'status-validated',
'paid': 'status-paid',
'unpaid': 'status-unpaid',
'canceled': 'status-canceled',
'pending': 'status-pending',
};
return statusMap[status] || 'badge-draft';
return statusMap[status] || 'status-draft';
};
const getStatusText = (status) => {
const statusTextMap = {
'draft': 'Borrador',
'draft': 'Borrador',
'validated': 'Pte. Pago',
'paid': 'Pagada',
'unpaid': 'Pte. Pago',
'canceled': 'Cancelada'
'paid': 'Pagada',
'unpaid': 'Pte. Pago',
'canceled': 'Cancelada',
'pending': 'Pendiente',
};
return statusTextMap[status] || status;
};
@ -46,11 +48,14 @@ export function InvoiceItem(invoice, onView) {
}
item.innerHTML = /*html*/`
<td class="invoice-select-col">
<input type="checkbox" class="invoice-check" value="${invoice.id}" aria-label="Seleccionar factura ${escapeHtml(invoice.number)}" />
</td>
<td class="invoice-number">
<button class="btn-invoice-num" title="Ver detalles">${escapeHtml(invoice.number)}</button>
</td>
<td class="invoice-status">
<span class="badge ${getStatusClass(invoice.status)}">
<span class="status-badge ${getStatusClass(invoice.status)}">
${escapeHtml(getStatusText(invoice.status))}
</span>
</td>
@ -59,8 +64,8 @@ export function InvoiceItem(invoice, onView) {
${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-total amount-positive">${formatCurrency(invoice.total)}</td>
<td class="invoice-remain ${parseFloat(invoice.remainToPay) > 0.009 ? 'amount-pending' : 'amount-paid'}">${formatCurrency(invoice.remainToPay)}</td>
<td class="invoice-actions">
<button class="btn-action btn-view" data-invoice-id="${invoice.id}" title="Ver/Editar detalles">
${icons.eye}

View File

@ -1,6 +1,8 @@
import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments, downloadInvoicePdf } from '../services/invoices.js';
import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, updateInvoiceLine, addPayment, getPayments, downloadInvoicePdf } from '../services/invoices.js';
import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js';
import { toast } from '../services/toast.js';
import { getPaymentTypes } from '../services/setup.js';
import { apiGet } from '../services/apiClient.js';
export function InvoiceModal(invoiceId, onClose, onUpdate) {
const modal = document.createElement('div');
@ -8,6 +10,8 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
let invoice = null;
let payments = [];
let paymentTypes = [];
let bankAccounts = [];
let isLoading = true;
// Tracker de cambios
@ -220,8 +224,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<td>${formatCurrency(line.total)}</td>
${isDraft ? `
<td class="line-actions">
<button type="button" class="btn-edit-line" data-line-id="${line.id}" title="Editar línea">
<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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button type="button" class="btn-delete-line" data-line-id="${line.id}" title="Eliminar línea">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
@ -290,8 +297,8 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<div class="form-row">
<div class="form-group">
<label>Cantidad a pagar (€)</label>
<input type="number" id="payment-amount"
min="0" max="${invoice.remainToPay}" step="0.01"
<input type="number" id="payment-amount"
min="0" max="${invoice.remainToPay}" step="0.01"
placeholder="Dejar vacío o 0 = pago total (${invoice.remainToPay?.toFixed(2)} €)" />
<small class="payment-hint">Máximo: ${formatCurrency(invoice.remainToPay)}. Vacío o 0 = pago completo.</small>
</div>
@ -300,6 +307,24 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<input type="date" id="payment-date" value="${new Date().toISOString().split('T')[0]}" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Método de pago</label>
<select id="payment-method">
${paymentTypes.length
? paymentTypes.map(t => `<option value="${t.id}">${t.label || t.code}</option>`).join('')
: '<option value="4">Tarjeta bancaria</option>'}
</select>
</div>
<div class="form-group">
<label>Cuenta bancaria</label>
<select id="payment-account">
${bankAccounts.length
? bankAccounts.map(a => `<option value="${a.id}">${a.label}${a.iban ? ' · ' + a.iban.slice(-4) : ''}</option>`).join('')
: '<option value="1">Cuenta principal</option>'}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Nº Referencia (opcional)</label>
@ -384,13 +409,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
if (form) form.style.display = 'none';
});
// Botones editar línea
modal.querySelectorAll('.btn-edit-line').forEach(btn => {
btn.addEventListener('click', () => handleEditLine(btn.dataset.lineId));
});
// Botones eliminar línea
const deleteLineBtns = modal.querySelectorAll('.btn-delete-line');
deleteLineBtns.forEach(btn => {
btn.addEventListener('click', () => {
const lineId = parseInt(btn.dataset.lineId);
handleDeleteLine(lineId);
});
modal.querySelectorAll('.btn-delete-line').forEach(btn => {
btn.addEventListener('click', () => handleDeleteLine(parseInt(btn.dataset.lineId)));
});
// Botón registrar pago
@ -688,6 +714,56 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
}
};
// Editar línea de factura inline
const handleEditLine = (lineId) => {
const line = invoice.lines?.find(l => String(l.id) === String(lineId));
if (!line) return;
const row = modal.querySelector(`tr[data-line-id="${lineId}"]`);
if (!row) return;
row.innerHTML = `
<td><input class="line-edit-input line-edit-desc" type="text" value="${(line.description || '').replace(/"/g, '&quot;')}" /></td>
<td><input class="line-edit-input line-edit-qty" type="number" value="${line.quantity ?? 1}" min="0.01" step="0.01" style="width:70px"/></td>
<td><input class="line-edit-input line-edit-price" type="number" value="${line.unitPrice ?? 0}" min="0" step="0.01" style="width:90px"/></td>
<td><input class="line-edit-input line-edit-tax" type="number" value="${line.taxRate ?? 21}" min="0" max="100" step="0.01" style="width:60px"/></td>
<td>—</td>
<td class="line-actions">
<button type="button" class="btn-save-line-edit btn-small btn-success" title="Guardar">✓</button>
<button type="button" class="btn-cancel-line-edit btn-small" title="Cancelar">✕</button>
</td>
`;
row.querySelector('.btn-save-line-edit').addEventListener('click', () => saveLineEdit(lineId, row));
row.querySelector('.btn-cancel-line-edit').addEventListener('click', () => loadInvoice());
row.querySelector('.line-edit-desc').focus();
};
const saveLineEdit = async (lineId, row) => {
const description = row.querySelector('.line-edit-desc').value.trim();
const quantity = parseFloat(row.querySelector('.line-edit-qty').value);
const unitPrice = parseFloat(row.querySelector('.line-edit-price').value);
const taxRate = parseFloat(row.querySelector('.line-edit-tax').value);
if (!description) { toast.warning('La descripción es obligatoria'); return; }
if (!quantity || quantity <= 0) { toast.warning('La cantidad debe ser mayor que 0'); return; }
const saveBtn = row.querySelector('.btn-save-line-edit');
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
await updateInvoiceLine(invoiceId, lineId, { description, quantity, unitPrice, taxRate });
toast.success('Línea actualizada');
if (onUpdate) onUpdate();
await loadInvoice();
changeTracker.markAsSaved();
} catch (error) {
toast.error('Error al actualizar línea: ' + error.message);
saveBtn.disabled = false;
saveBtn.textContent = '✓';
}
};
// Eliminar línea de factura
const handleDeleteLine = async (lineId) => {
const ok = await showConfirmExitModal({
@ -751,6 +827,8 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
}
const paymentRef = refInput?.value?.trim() || undefined;
const paymentModeId = parseInt(modal.querySelector('#payment-method')?.value) || 4;
const accountId = parseInt(modal.querySelector('#payment-account')?.value) || 1;
try {
const payBtn = modal.querySelector('#btn-pay');
@ -760,9 +838,9 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
await addPayment(invoiceId, {
amount: amount,
paymentDate: paymentDate,
paymentModeId: 4,
paymentModeId,
closePaidInvoices: "yes",
accountId: 1,
accountId,
numPayment: paymentRef
});
@ -787,18 +865,24 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
}
};
// Cargar factura
// Cargar factura + datos auxiliares en paralelo
const loadInvoice = async () => {
try {
isLoading = true;
renderContent();
invoice = await getInvoiceById(invoiceId);
try {
payments = await getPayments(invoiceId);
} catch (e) {
payments = [];
}
const [invoiceData, paymentsData, typesData, accountsData] = await Promise.allSettled([
getInvoiceById(invoiceId),
getPayments(invoiceId),
getPaymentTypes(),
apiGet('/api/Bank/accounts'),
]);
invoice = invoiceData.status === 'fulfilled' ? invoiceData.value : null;
payments = paymentsData.status === 'fulfilled' ? paymentsData.value : [];
paymentTypes = typesData.status === 'fulfilled' ? typesData.value : [];
bankAccounts = accountsData.status === 'fulfilled' ? accountsData.value : [];
isLoading = false;
renderContent();
} catch (error) {

View File

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

629
src/pages/BancoPage.js Normal file
View File

@ -0,0 +1,629 @@
import { apiGet, apiPost, apiPut } from '../services/apiClient.js';
import { getCountries } from '../services/setup.js';
import { showToast } from '../services/toast.js';
const API_ACCOUNTS = '/api/Bank/accounts';
const API_MOVEMENTS = '/api/Bank/movements';
const DOLIBARR_LABELS = {
'(InitialBankBalance)': 'Saldo inicial',
'(banktransfert)': 'Transferencia interna',
'(payment)': 'Pago',
'(withdraw)': 'Retirada',
};
function cleanLabel(raw) {
if (!raw) return 'Movimiento';
return DOLIBARR_LABELS[raw] ?? raw;
}
export function renderBancoPage() {
const container = document.createElement('div');
container.className = 'banco-page';
const fmt = (n) => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n ?? 0);
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('es-ES') : '—';
container.innerHTML = /*html*/`
<div class="banco-header">
<h1>Banco</h1>
<p class="banco-subtitle">Gestión de cuentas bancarias y movimientos</p>
</div>
<div class="banco-summary-grid">
<div class="banco-stat-card banco-stat-card--balance">
<div class="banco-stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
</div>
<div class="banco-stat-body">
<span class="banco-stat-label">Saldo total</span>
<span class="banco-stat-value" id="stat-balance">—</span>
</div>
</div>
<div class="banco-stat-card banco-stat-card--income">
<div class="banco-stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/>
<polyline points="17 6 23 6 23 12"/>
</svg>
</div>
<div class="banco-stat-body">
<span class="banco-stat-label">Ingresos</span>
<span class="banco-stat-value" id="stat-income">—</span>
</div>
</div>
<div class="banco-stat-card banco-stat-card--expense">
<div class="banco-stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/>
<polyline points="17 18 23 18 23 12"/>
</svg>
</div>
<div class="banco-stat-body">
<span class="banco-stat-label">Gastos</span>
<span class="banco-stat-value" id="stat-expense">—</span>
</div>
</div>
<div class="banco-stat-card banco-stat-card--accounts">
<div class="banco-stat-icon">
<svg width="20" height="20" 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>
</div>
<div class="banco-stat-body">
<span class="banco-stat-label">Cuentas</span>
<span class="banco-stat-value" id="stat-accounts">—</span>
</div>
</div>
</div>
<div class="banco-content-grid">
<div class="banco-card banco-card--accounts">
<div class="banco-card-header">
<h2>Cuentas bancarias</h2>
<button class="btn-banco-new" id="btn-new-account">+ Nueva cuenta</button>
</div>
<div class="banco-accounts-list" id="banco-accounts">
<div class="banco-loading">
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
</div>
</div>
</div>
<div class="banco-card banco-card--transactions">
<div class="banco-card-header">
<h2 id="tx-title">Últimos movimientos</h2>
<button class="btn-banco-back" id="btn-all-movements" style="display:none">
← Todos
</button>
</div>
<div class="banco-transactions-list" id="banco-transactions">
<div class="banco-loading">
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
</div>
</div>
</div>
</div>
`;
let allMovements = [];
let selectedAccountId = null;
// ── Render helpers ────────────────────────────────────────────────
function renderAccounts(list) {
const el = container.querySelector('#banco-accounts');
if (!list || list.length === 0) {
el.innerHTML = `<div class="banco-empty">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" style="color:var(--gray-300)">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
<p>No hay cuentas bancarias registradas</p>
</div>`;
return;
}
const totalBalance = list.reduce((s, a) => s + (parseFloat(a.balance) || 0), 0);
container.querySelector('#stat-balance').textContent = fmt(totalBalance);
container.querySelector('#stat-accounts').textContent = list.length;
el.innerHTML = '';
list.forEach(acc => {
const item = document.createElement('div');
item.className = 'banco-account-item';
item.dataset.id = acc.id;
item.innerHTML = `
<div class="banco-account-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
</div>
<div class="banco-account-info">
<span class="banco-account-name">${acc.label || acc.ref || 'Cuenta sin nombre'}</span>
<span class="banco-account-sub">${acc.iban || acc.accountNumber || acc.ref || '—'}</span>
</div>
<div class="banco-account-right">
<span class="banco-account-balance ${parseFloat(acc.balance) < 0 ? 'banco-account-balance--negative' : ''}">${fmt(acc.balance)}</span>
<button class="btn-account-edit" title="Editar cuenta" aria-label="Editar ${acc.label}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
</svg>
</button>
<svg class="banco-account-arrow" width="14" height="14" 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>
</div>
`;
item.querySelector('.btn-account-edit').addEventListener('click', (e) => {
e.stopPropagation();
showEditAccountModal(acc);
});
item.addEventListener('click', () => selectAccount(acc));
el.appendChild(item);
});
}
function renderTransactions(list, subtitle = null) {
const el = container.querySelector('#banco-transactions');
const title = container.querySelector('#tx-title');
const back = container.querySelector('#btn-all-movements');
title.textContent = subtitle ? `Movimientos · ${subtitle}` : 'Últimos movimientos';
back.style.display = subtitle ? 'inline-flex' : 'none';
if (!list || list.length === 0) {
el.innerHTML = `<div class="banco-empty"><p>No hay movimientos${subtitle ? ' para esta cuenta' : ' recientes'}</p></div>`;
return;
}
let income = 0, expense = 0;
el.innerHTML = list.map(tx => {
const amount = parseFloat(tx.amount) || 0;
if (amount >= 0) income += amount; else expense += Math.abs(amount);
return `
<div class="banco-tx-item">
<div class="banco-tx-indicator ${amount >= 0 ? 'banco-tx-indicator--credit' : 'banco-tx-indicator--debit'}"></div>
<div class="banco-tx-info">
<span class="banco-tx-label">${cleanLabel(tx.label)}</span>
<span class="banco-tx-date">${fmtDate(tx.dateValue || tx.date)}</span>
</div>
<span class="banco-tx-amount ${amount >= 0 ? 'banco-tx-amount--credit' : 'banco-tx-amount--debit'}">
${amount >= 0 ? '+' : ''}${fmt(amount)}
</span>
</div>
`;
}).join('');
if (!subtitle) {
container.querySelector('#stat-income').textContent = fmt(income);
container.querySelector('#stat-expense').textContent = fmt(expense);
}
}
function renderUnavailable(section) {
const el = container.querySelector(section);
el.innerHTML = `<div class="banco-empty banco-empty--soon">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" style="color:var(--gray-300)">
<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>
<p>Módulo bancario no disponible aún</p>
<span class="banco-soon-tag">Próximamente</span>
</div>`;
}
// ── Account selection ─────────────────────────────────────────────
async function selectAccount(acc) {
selectedAccountId = acc.id;
// Highlight selected
container.querySelectorAll('.banco-account-item').forEach(el => {
el.classList.toggle('banco-account-item--active', String(el.dataset.id) === String(acc.id));
});
const el = container.querySelector('#banco-transactions');
el.innerHTML = `<div class="banco-loading"><div class="banco-skeleton"></div><div class="banco-skeleton"></div><div class="banco-skeleton"></div></div>`;
container.querySelector('#tx-title').textContent = `Movimientos · ${acc.label || acc.ref}`;
container.querySelector('#btn-all-movements').style.display = 'inline-flex';
try {
const raw = await apiGet(`${API_ACCOUNTS}/${acc.id}/lines`);
const lines = Array.isArray(raw) ? raw : raw?.data ?? [];
renderTransactions(lines, acc.label || acc.ref);
} catch {
el.innerHTML = `<div class="banco-empty"><p>No se pudieron cargar los movimientos</p></div>`;
}
}
function showAllMovements() {
selectedAccountId = null;
container.querySelectorAll('.banco-account-item').forEach(el => el.classList.remove('banco-account-item--active'));
renderTransactions(allMovements);
}
container.querySelector('#btn-all-movements').addEventListener('click', showAllMovements);
// ── Load all data ─────────────────────────────────────────────────
async function loadData() {
try {
const raw = await apiGet(API_ACCOUNTS);
const accounts = Array.isArray(raw) ? raw : raw?.data ?? [];
renderAccounts(accounts);
} catch {
renderUnavailable('#banco-accounts');
container.querySelector('#stat-balance').textContent = '—';
container.querySelector('#stat-accounts').textContent = '—';
}
try {
const raw = await apiGet(`${API_MOVEMENTS}?limit=50`);
allMovements = Array.isArray(raw) ? raw : raw?.data ?? [];
renderTransactions(allMovements);
} catch {
renderUnavailable('#banco-transactions');
container.querySelector('#stat-income').textContent = '—';
container.querySelector('#stat-expense').textContent = '—';
}
}
// ── Edit account modal ────────────────────────────────────────────
function showEditAccountModal(acc) {
const overlay = document.createElement('div');
overlay.className = 'banco-modal-overlay';
overlay.innerHTML = /*html*/`
<div class="banco-modal">
<div class="banco-modal-header">
<div class="banco-modal-title-group">
<div class="banco-modal-icon banco-modal-icon--edit">
<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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</div>
<div>
<h2>Editar cuenta bancaria</h2>
<p class="banco-modal-subtitle">${acc.label || acc.ref || 'Cuenta sin nombre'}</p>
</div>
</div>
<button class="banco-modal-close" type="button" aria-label="Cerrar">✕</button>
</div>
<form class="banco-modal-form" id="edit-account-form" novalidate>
<div class="banco-form-section">
<span class="banco-form-section-label">Identificación</span>
<div class="banco-form-grid">
<div class="banco-form-field banco-form-field--full">
<label class="banco-form-label" for="edit-label">Nombre de la cuenta</label>
<input type="text" class="banco-form-input" id="edit-label" value="${acc.label || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-ref">Código / Ref</label>
<input type="text" class="banco-form-input" id="edit-ref" value="${acc.ref || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-type">Tipo de cuenta</label>
<select class="banco-form-input" id="edit-type">
<option value="1">Cuenta corriente</option>
<option value="0">Cuenta de ahorro</option>
<option value="2">Caja</option>
</select>
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-currency">Divisa</label>
<input type="text" class="banco-form-input" id="edit-currency" value="${acc.currencyCode || 'EUR'}" maxlength="3" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-country">País</label>
<select class="banco-form-input" id="edit-country">
<option value="">Cargando...</option>
</select>
</div>
</div>
</div>
<div class="banco-form-section">
<span class="banco-form-section-label">Datos bancarios <span class="banco-form-section-optional">(opcional)</span></span>
<div class="banco-form-grid">
<div class="banco-form-field banco-form-field--full">
<label class="banco-form-label" for="edit-bank">Entidad bancaria</label>
<input type="text" class="banco-form-input" id="edit-bank" value="${acc.bank || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-number">Número de cuenta</label>
<input type="text" class="banco-form-input" id="edit-number" value="${acc.accountNumber || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-iban">IBAN</label>
<input type="text" class="banco-form-input" id="edit-iban" value="${acc.iban || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-bic">BIC / SWIFT</label>
<input type="text" class="banco-form-input" id="edit-bic" value="${acc.bic || ''}" autocomplete="off" />
</div>
</div>
</div>
<div class="banco-modal-footer">
<button type="button" class="btn-banco-cancel" id="cancel-edit">Cancelar</button>
<button type="submit" class="btn-banco-submit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Guardar cambios
</button>
</div>
</form>
</div>
`;
document.body.appendChild(overlay);
// Set type select value
const typeSelect = overlay.querySelector('#edit-type');
if (acc.type != null) typeSelect.value = String(acc.type);
// Load countries and pre-select current
(async () => {
const sel = overlay.querySelector('#edit-country');
try {
const countries = await getCountries();
const priority = ['ES','FR','DE','PT','GB','US'];
const first = countries.filter(c => priority.includes(c.code));
const rest = countries.filter(c => !priority.includes(c.code));
sel.innerHTML = '<option value="">Sin especificar</option>';
first.forEach(c => { const o = document.createElement('option'); o.value = c.id; o.textContent = c.label; sel.appendChild(o); });
if (rest.length) {
const sep = document.createElement('option'); sep.disabled = true; sep.textContent = '──────────'; sel.appendChild(sep);
rest.forEach(c => { const o = document.createElement('option'); o.value = c.id; o.textContent = c.label; sel.appendChild(o); });
}
if (acc.countryId) sel.value = String(acc.countryId);
} catch {
sel.innerHTML = `
<option value="">Sin especificar</option>
<option value="4">España</option>
<option value="1">Francia</option>
<option value="5">Alemania</option>
<option value="25">Portugal</option>
<option value="7">Reino Unido</option>
<option value="11">Estados Unidos</option>`;
if (acc.countryId) sel.value = String(acc.countryId);
}
})();
const close = () => { if (overlay.isConnected) overlay.remove(); };
overlay.querySelector('.banco-modal-close').addEventListener('click', close);
overlay.querySelector('#cancel-edit').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
overlay.querySelector('#edit-account-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {};
const label = overlay.querySelector('#edit-label').value.trim();
const ref = overlay.querySelector('#edit-ref').value.trim();
const currency = overlay.querySelector('#edit-currency').value.trim();
const bank = overlay.querySelector('#edit-bank').value.trim();
const number = overlay.querySelector('#edit-number').value.trim();
const iban = overlay.querySelector('#edit-iban').value.trim();
const bic = overlay.querySelector('#edit-bic').value.trim();
const countryId = parseInt(overlay.querySelector('#edit-country').value) || null;
const type = parseInt(overlay.querySelector('#edit-type').value);
if (label) payload.label = label;
if (ref) payload.ref = ref;
if (currency) payload.currencyCode = currency;
if (bank) payload.bank = bank;
if (number) payload.accountNumber = number;
if (iban) payload.iban = iban;
if (bic) payload.bic = bic;
if (countryId) payload.countryId = countryId;
payload.type = type;
if (Object.keys(payload).length === 1 && payload.type === acc.type) {
close(); return;
}
const submitBtn = overlay.querySelector('.btn-banco-submit');
submitBtn.disabled = true;
submitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin 0.8s linear infinite"><circle cx="12" cy="12" r="10" stroke-opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10"/></svg> Guardando...';
try {
await apiPut(`/api/Bank/accounts/${acc.id}`, payload);
showToast('Cuenta actualizada', 'success');
close();
loadData();
} catch (err) {
showToast(err.message || 'Error al actualizar la cuenta', 'error');
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Guardar cambios';
}
});
}
// ── Create account modal ──────────────────────────────────────────
function showCreateAccountModal() {
const overlay = document.createElement('div');
overlay.className = 'banco-modal-overlay';
overlay.innerHTML = /*html*/`
<div class="banco-modal">
<div class="banco-modal-header">
<div class="banco-modal-title-group">
<div class="banco-modal-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
</div>
<div>
<h2>Nueva cuenta bancaria</h2>
<p class="banco-modal-subtitle">Los campos marcados con * son obligatorios</p>
</div>
</div>
<button class="banco-modal-close" type="button" aria-label="Cerrar">✕</button>
</div>
<form class="banco-modal-form" id="create-account-form" novalidate>
<div class="banco-form-section">
<span class="banco-form-section-label">Identificación</span>
<div class="banco-form-grid">
<div class="banco-form-field banco-form-field--full">
<label class="banco-form-label" for="acc-label">Nombre de la cuenta <span class="required">*</span></label>
<input type="text" class="banco-form-input" id="acc-label" placeholder="Ej: Cuenta corriente principal" autocomplete="off" required />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-ref">Código / Ref <span class="required">*</span></label>
<input type="text" class="banco-form-input" id="acc-ref" placeholder="Ej: CAJA-01" autocomplete="off" required />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-type">Tipo de cuenta <span class="required">*</span></label>
<select class="banco-form-input" id="acc-type">
<option value="1">Cuenta corriente</option>
<option value="0">Cuenta de ahorro</option>
<option value="2">Caja</option>
</select>
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-currency">Divisa <span class="required">*</span></label>
<input type="text" class="banco-form-input" id="acc-currency" value="EUR" maxlength="3" autocomplete="off" required />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-country">País <span class="required">*</span></label>
<select class="banco-form-input" id="acc-country">
<option value="">Cargando países...</option>
</select>
</div>
</div>
</div>
<div class="banco-form-section">
<span class="banco-form-section-label">Datos bancarios <span class="banco-form-section-optional">(opcional)</span></span>
<div class="banco-form-grid">
<div class="banco-form-field banco-form-field--full">
<label class="banco-form-label" for="acc-bank">Entidad bancaria</label>
<input type="text" class="banco-form-input" id="acc-bank" placeholder="Nombre del banco" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-number">Número de cuenta</label>
<input type="text" class="banco-form-input" id="acc-number" placeholder="ES12 3456 7890..." autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-iban">IBAN</label>
<input type="text" class="banco-form-input" id="acc-iban" placeholder="ES00 0000 0000..." autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-bic">BIC / SWIFT</label>
<input type="text" class="banco-form-input" id="acc-bic" placeholder="CAIXESBBXXX" autocomplete="off" />
</div>
</div>
</div>
<div class="banco-modal-footer">
<button type="button" class="btn-banco-cancel" id="cancel-account">Cancelar</button>
<button type="submit" class="btn-banco-submit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Crear cuenta
</button>
</div>
</form>
</div>
`;
document.body.appendChild(overlay);
// Focus first input
setTimeout(() => overlay.querySelector('#acc-label')?.focus(), 50);
// Load countries
(async () => {
const sel = overlay.querySelector('#acc-country');
try {
const countries = await getCountries();
const priority = ['ES','FR','DE','PT','GB','US'];
const first = countries.filter(c => priority.includes(c.code));
const rest = countries.filter(c => !priority.includes(c.code));
sel.innerHTML = '<option value="">Seleccionar país...</option>';
first.forEach(c => {
const o = document.createElement('option');
o.value = c.id; o.textContent = c.label;
if (c.code === 'ES') o.selected = true;
sel.appendChild(o);
});
if (rest.length) {
const sep = document.createElement('option'); sep.disabled = true; sep.textContent = '──────────';
sel.appendChild(sep);
rest.forEach(c => { const o = document.createElement('option'); o.value = c.id; o.textContent = c.label; sel.appendChild(o); });
}
} catch {
sel.innerHTML = `
<option value="">Seleccionar país...</option>
<option value="4" selected>España</option>
<option value="1">Francia</option>
<option value="5">Alemania</option>
<option value="25">Portugal</option>
<option value="7">Reino Unido</option>
<option value="11">Estados Unidos</option>`;
}
})();
const close = () => { if (overlay.isConnected) overlay.remove(); };
overlay.querySelector('.banco-modal-close').addEventListener('click', close);
overlay.querySelector('#cancel-account').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
overlay.querySelector('#create-account-form').addEventListener('submit', async (e) => {
e.preventDefault();
const label = overlay.querySelector('#acc-label').value.trim();
const ref = overlay.querySelector('#acc-ref').value.trim();
const countryId = parseInt(overlay.querySelector('#acc-country').value);
if (!label) { showToast('El nombre es obligatorio', 'warning'); overlay.querySelector('#acc-label').focus(); return; }
if (!ref) { showToast('El código/ref es obligatorio', 'warning'); overlay.querySelector('#acc-ref').focus(); return; }
if (!countryId) { showToast('Selecciona un país', 'warning'); overlay.querySelector('#acc-country').focus(); return; }
const submitBtn = overlay.querySelector('.btn-banco-submit');
submitBtn.disabled = true;
submitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin 0.8s linear infinite"><circle cx="12" cy="12" r="10" stroke-opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10"/></svg> Guardando...';
try {
await apiPost('/api/Bank/accounts', {
label,
ref,
type: parseInt(overlay.querySelector('#acc-type').value),
currencyCode: overlay.querySelector('#acc-currency').value.trim() || 'EUR',
countryId,
bank: overlay.querySelector('#acc-bank').value.trim() || null,
accountNumber: overlay.querySelector('#acc-number').value.trim() || null,
iban: overlay.querySelector('#acc-iban').value.trim() || null,
bic: overlay.querySelector('#acc-bic').value.trim() || null,
});
showToast('Cuenta bancaria creada', 'success');
close();
loadData();
} catch (err) {
showToast(err.message || 'Error al crear la cuenta', 'error');
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Crear cuenta';
}
});
}
container.querySelector('#btn-new-account').addEventListener('click', showCreateAccountModal);
loadData();
return container;
}

View File

@ -1,6 +1,7 @@
import { ClientItem } from '../components/ClientItem.js';
import { getClients } from '../services/clients.js';
import { createClient, getClients } from '../services/clients.js';
import { icons } from '../services/icons.js';
import { toast } from '../services/toast.js';
export function renderClientesPage() {
const container = document.createElement('div');
@ -17,12 +18,15 @@ export function renderClientesPage() {
<div class="clientes-header">
<h1>Clientes</h1>
</div>
<div class="clientes-filters">
<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>
<button class="btn-new-client">
${icons.plus} Nuevo cliente
</button>
</div>
<div class="clients-table-container">
@ -199,6 +203,159 @@ export function renderClientesPage() {
document.body.appendChild(modal);
}
function showCreateClientModal() {
const overlay = document.createElement('div');
overlay.className = 'client-modal-overlay';
overlay.innerHTML = /*html*/`
<div class="client-modal create-client-modal">
<div class="client-modal-header">
<h2>Nuevo cliente</h2>
<button class="btn-close-modal" type="button">${icons.close}</button>
</div>
<form class="create-client-form" novalidate>
<div class="client-modal-body">
<div class="create-client-section">
<h3>Información básica</h3>
<div class="create-client-grid">
<div class="form-field form-field--full">
<label class="form-label" for="cc-name">Nombre <span class="form-required">*</span></label>
<input id="cc-name" name="name" type="text" class="form-input" placeholder="Nombre o razón social" required maxlength="200" />
<span class="form-error" id="cc-name-error"></span>
</div>
<div class="form-field">
<label class="form-label" for="cc-email">Email</label>
<input id="cc-email" name="email" type="email" class="form-input" placeholder="empresa@ejemplo.com" maxlength="200" />
</div>
<div class="form-field">
<label class="form-label" for="cc-phone">Teléfono</label>
<input id="cc-phone" name="phone" type="tel" class="form-input" placeholder="+34 600 000 000" maxlength="20" />
</div>
<div class="form-field">
<label class="form-label" for="cc-vat">NIF / CIF / VAT</label>
<input id="cc-vat" name="vatNumber" type="text" class="form-input" placeholder="B12345678" maxlength="50" />
</div>
<div class="form-field">
<label class="form-label" for="cc-country">País (código)</label>
<input id="cc-country" name="countryCode" type="text" class="form-input" placeholder="ES" maxlength="50" />
</div>
</div>
</div>
<div class="create-client-section">
<h3>Dirección</h3>
<div class="create-client-grid">
<div class="form-field form-field--full">
<label class="form-label" for="cc-address">Dirección</label>
<input id="cc-address" name="address" type="text" class="form-input" placeholder="Calle, número, piso..." maxlength="50" />
</div>
<div class="form-field">
<label class="form-label" for="cc-zip">Código postal</label>
<input id="cc-zip" name="zip" type="text" class="form-input" placeholder="46001" maxlength="50" />
</div>
<div class="form-field">
<label class="form-label" for="cc-town">Ciudad</label>
<input id="cc-town" name="town" type="text" class="form-input" placeholder="Valencia" maxlength="50" />
</div>
</div>
</div>
<div class="create-client-section">
<h3>Notas</h3>
<div class="create-client-grid">
<div class="form-field form-field--full">
<label class="form-label" for="cc-note-pub">Nota pública</label>
<textarea id="cc-note-pub" name="notePublic" class="form-input form-textarea" placeholder="Visible para el cliente..." maxlength="500" rows="2"></textarea>
</div>
<div class="form-field form-field--full">
<label class="form-label" for="cc-note-priv">Nota privada</label>
<textarea id="cc-note-priv" name="notePrivate" class="form-input form-textarea" placeholder="Solo visible internamente..." maxlength="500" rows="2"></textarea>
</div>
</div>
</div>
</div>
<div class="create-client-footer">
<button type="button" class="btn-cancel-create">Cancelar</button>
<button type="submit" class="btn-submit-create">
<span class="btn-submit-text">Crear cliente</span>
<span class="btn-submit-spinner" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="animation:spin .7s linear infinite;display:block"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
</span>
</button>
</div>
</form>
</div>
`;
const form = overlay.querySelector('.create-client-form');
const nameInput = overlay.querySelector('#cc-name');
const nameError = overlay.querySelector('#cc-name-error');
const submitBtn = overlay.querySelector('.btn-submit-create');
const submitText = overlay.querySelector('.btn-submit-text');
const submitSpinner = overlay.querySelector('.btn-submit-spinner');
const close = () => overlay.remove();
overlay.querySelector('.btn-close-modal').addEventListener('click', close);
overlay.querySelector('.btn-cancel-create').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const handleEscape = (e) => {
if (e.key !== 'Escape') return;
if (!overlay.isConnected) { document.removeEventListener('keydown', handleEscape); return; }
close();
};
document.addEventListener('keydown', handleEscape);
form.addEventListener('submit', async (e) => {
e.preventDefault();
nameError.textContent = '';
const name = nameInput.value.trim();
if (!name) {
nameError.textContent = 'El nombre es obligatorio.';
nameInput.focus();
return;
}
const getData = (n) => {
const v = form.elements[n]?.value?.trim();
return v || undefined;
};
const payload = {
Name: name,
Email: getData('email'),
Phone: getData('phone'),
VatNumber: getData('vatNumber'),
CountryCode: getData('countryCode'),
Address: getData('address'),
Zip: getData('zip'),
Town: getData('town'),
NotePublic: getData('notePublic'),
NotePrivate: getData('notePrivate'),
};
submitBtn.disabled = true;
submitText.hidden = true;
submitSpinner.hidden = false;
try {
await createClient(payload);
toast.success(`Cliente "${name}" creado correctamente.`);
close();
await loadAllClients();
} catch (err) {
toast.error(err.message || 'Error al crear el cliente.');
submitBtn.disabled = false;
submitText.hidden = false;
submitSpinner.hidden = true;
}
});
document.body.appendChild(overlay);
setTimeout(() => nameInput.focus(), 50);
}
async function loadAllClients() {
const clientsList = container.querySelector('.clients-list');
@ -237,6 +394,8 @@ export function renderClientesPage() {
btnNext.disabled = currentPage === totalPages;
}
container.querySelector('.btn-new-client').addEventListener('click', showCreateClientModal);
const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
clearTimeout(searchInput._debounceTimer);

View File

@ -3,6 +3,7 @@ import { navigationGuards } from '../router.js';
import { apiGet, apiPost } from '../services/apiClient.js';
import { icons } from '../services/icons.js';
import { showToast } from '../services/toast.js';
import { getPaymentTerms } from '../services/setup.js';
export function renderCreateInvoicePage() {
const container = document.createElement('div');
@ -46,6 +47,13 @@ export function renderCreateInvoicePage() {
</button>
</div>
</div>
<div class="form-field-compact full-width">
<label>Condiciones de pago</label>
<select id="paymentTerms" name="paymentTerms">
<option value="">Sin condiciones específicas</option>
</select>
</div>
</div>
<div class="notes-grid-compact">
@ -101,11 +109,44 @@ export function renderCreateInvoicePage() {
const today = new Date().toISOString().split('T')[0];
container.querySelector('#date').value = today;
const expireDate = new Date();
expireDate.setDate(expireDate.getDate() + 30);
container.querySelector('#expireDate').value = expireDate.toISOString().split('T')[0];
// Condiciones de pago — calcula vencimiento automáticamente al seleccionar
async function loadPaymentTerms() {
try {
const terms = await getPaymentTerms();
const select = container.querySelector('#paymentTerms');
terms.forEach(t => {
if (!t.label && !t.code) return;
const opt = document.createElement('option');
opt.value = t.nbjour ?? '';
opt.textContent = t.label || t.code;
select.appendChild(opt);
});
} catch { /* opcional — fallo silencioso */ }
}
loadPaymentTerms();
container.querySelector('#paymentTerms').addEventListener('change', (e) => {
const days = parseInt(e.target.value);
if (!days || days <= 0) return;
const base = new Date(container.querySelector('#date').value || today);
base.setDate(base.getDate() + days);
container.querySelector('#expireDate').value = base.toISOString().split('T')[0];
});
// Si cambia la fecha base, recalcular vencimiento si hay condición seleccionada
container.querySelector('#date').addEventListener('change', (e) => {
const days = parseInt(container.querySelector('#paymentTerms').value);
if (!days || days <= 0) return;
const base = new Date(e.target.value);
base.setDate(base.getDate() + days);
container.querySelector('#expireDate').value = base.toISOString().split('T')[0];
});
// Event listeners para botones de calendario
container.querySelectorAll('.date-picker-btn').forEach(btn => {
btn.addEventListener('click', () => {

View File

@ -1,7 +1,8 @@
import { InvoiceItem } from '../components/InvoiceItem.js';
import { InvoiceModal } from '../components/InvoiceModal.js';
import { apiGet } from '../services/apiClient.js';
import { apiDelete, apiGet, apiPost } from '../services/apiClient.js';
import { icons } from '../services/icons.js';
import { showToast } from '../services/toast.js';
export function renderFacturasPage() {
const container = document.createElement('div');
@ -16,6 +17,7 @@ export function renderFacturasPage() {
let searchTerm = '';
let sortKey = null;
let sortDir = 'asc';
let selectedIds = new Set();
container.innerHTML = /*html*/`
<div class="facturas-header">
@ -33,13 +35,41 @@ export function renderFacturasPage() {
<option value="unpaid">Pte. Pago</option>
<option value="paid">Pagada</option>
</select>
<button class="btn-quarterly" id="btn-quarterly">
<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"/><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>
Resumen trimestral
</button>
<button class="btn-new-invoice">${icons.plus} <span>Nueva Factura</span></button>
</div>
<div class="quarterly-view" id="quarterly-view" style="display:none">
<div class="quarterly-table-wrap" id="quarterly-content">
<p class="quarterly-loading">Calculando...</p>
</div>
</div>
<div class="bulk-action-bar" id="bulk-bar" style="display:none">
<span class="bulk-count" id="bulk-count">0 seleccionadas</span>
<div class="bulk-actions">
<button class="btn-bulk btn-bulk-duplicate" id="btn-duplicate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Duplicar
</button>
<button class="btn-bulk btn-bulk-delete" id="btn-bulk-delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Eliminar
</button>
<button class="btn-bulk btn-bulk-cancel" id="btn-deselect">✕ Cancelar selección</button>
</div>
</div>
<div class="invoices-table-container">
<table class="invoices-table">
<thead>
<tr>
<th class="invoice-select-col">
<input type="checkbox" class="invoice-check-all" id="check-all" title="Seleccionar todo" />
</th>
<th class="invoice-number sortable" data-sort="number">
Número <span class="sort-icon"></span>
</th>
@ -62,7 +92,7 @@ export function renderFacturasPage() {
</tr>
</thead>
<tbody class="invoices-list">
<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>
<tr><td colspan="8" class="loading">Cargando facturas...</td></tr>
</tbody>
</table>
</div>
@ -165,14 +195,20 @@ export function renderFacturasPage() {
invoicesList.innerHTML = '';
if (invoices.length === 0) {
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>`;
invoicesList.innerHTML = `<tr><td colspan="8"><div class="empty-state">${icons.emptyInvoices}<h3>No hay facturas</h3><p>Crea tu primera factura para comenzar.</p></div></td></tr>`;
return;
}
invoices.forEach(invoice => {
const invoiceItem = InvoiceItem(invoice, handleViewInvoice);
// Restore checked state for already-selected rows
const chk = invoiceItem.querySelector('.invoice-check');
if (chk && selectedIds.has(String(invoice.id))) chk.checked = true;
invoicesList.appendChild(invoiceItem);
});
// Sync select-all state
syncSelectAll();
}
function handleViewInvoice(invoiceId) {
@ -184,9 +220,11 @@ export function renderFacturasPage() {
async function loadAllInvoices() {
const invoicesList = container.querySelector('.invoices-list');
selectedIds.clear();
updateBulkBar();
try {
invoicesList.innerHTML = '<tr><td colspan="7" class="loading">Cargando facturas...</td></tr>';
invoicesList.innerHTML = '<tr><td colspan="8" class="loading">Cargando facturas...</td></tr>';
let url = '/api/Invoices?limit=1000';
if (currentFilter) url += `&status=${currentFilter}`;
@ -200,7 +238,7 @@ export function renderFacturasPage() {
renderPage(1);
} catch (error) {
console.error('Error al cargar facturas:', error);
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>`;
invoicesList.innerHTML = `<tr><td colspan="8"><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>`;
}
}
@ -237,6 +275,264 @@ export function renderFacturasPage() {
});
});
// ── Bulk selection ──────────────────────────────────────────────────
function updateBulkBar() {
const bar = container.querySelector('#bulk-bar');
const count = container.querySelector('#bulk-count');
if (selectedIds.size > 0) {
bar.style.display = 'flex';
count.textContent = `${selectedIds.size} seleccionada${selectedIds.size !== 1 ? 's' : ''}`;
} else {
bar.style.display = 'none';
}
}
function syncSelectAll() {
const checkAll = container.querySelector('#check-all');
if (!checkAll) return;
const pageCheckboxes = [...container.querySelectorAll('.invoice-check')];
if (pageCheckboxes.length === 0) { checkAll.checked = false; checkAll.indeterminate = false; return; }
const checkedCount = pageCheckboxes.filter(c => c.checked).length;
checkAll.checked = checkedCount === pageCheckboxes.length;
checkAll.indeterminate = checkedCount > 0 && checkedCount < pageCheckboxes.length;
}
// Event delegation for row checkboxes
container.querySelector('.invoices-list').addEventListener('change', (e) => {
if (!e.target.classList.contains('invoice-check')) return;
const id = String(e.target.value);
if (e.target.checked) selectedIds.add(id); else selectedIds.delete(id);
syncSelectAll();
updateBulkBar();
});
container.querySelector('#check-all').addEventListener('change', (e) => {
const checkboxes = container.querySelectorAll('.invoice-check');
checkboxes.forEach(chk => {
chk.checked = e.target.checked;
if (e.target.checked) selectedIds.add(String(chk.value)); else selectedIds.delete(String(chk.value));
});
updateBulkBar();
});
container.querySelector('#btn-deselect').addEventListener('click', () => {
selectedIds.clear();
container.querySelectorAll('.invoice-check').forEach(c => { c.checked = false; });
syncSelectAll();
updateBulkBar();
});
container.querySelector('#btn-bulk-delete').addEventListener('click', async () => {
if (selectedIds.size === 0) return;
const confirmed = window.confirm(`¿Eliminar ${selectedIds.size} factura${selectedIds.size !== 1 ? 's' : ''}? Esta acción no se puede deshacer.`);
if (!confirmed) return;
const btn = container.querySelector('#btn-bulk-delete');
btn.disabled = true;
btn.textContent = 'Eliminando...';
let ok = 0, fail = 0;
await Promise.allSettled([...selectedIds].map(async id => {
try {
await apiDelete(`/api/Invoices/${id}`);
ok++;
} catch {
fail++;
}
}));
btn.disabled = false;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg> Eliminar`;
if (ok > 0) showToast(`${ok} factura${ok !== 1 ? 's' : ''} eliminada${ok !== 1 ? 's' : ''}`, 'success');
if (fail > 0) showToast(`${fail} factura${fail !== 1 ? 's no pudieron' : ' no pudo'} eliminarse`, 'error');
loadAllInvoices();
});
container.querySelector('#btn-duplicate').addEventListener('click', async () => {
if (selectedIds.size === 0) return;
const btn = container.querySelector('#btn-duplicate');
btn.disabled = true;
btn.textContent = 'Duplicando...';
const today = new Date().toISOString().split('T')[0];
const expire = new Date(); expire.setDate(expire.getDate() + 30);
const expireStr = expire.toISOString().split('T')[0];
let ok = 0, fail = 0;
await Promise.allSettled([...selectedIds].map(async id => {
try {
const inv = await apiGet(`/api/Invoices/${id}`);
const lines = (inv.lines || []).map(l => ({
description: l.description,
quantity: l.quantity,
unitPrice: l.unitPrice,
taxRate: l.taxRate ?? l.tax ?? 0,
}));
await apiPost('/api/Invoices', {
clientId: inv.clientId,
date: today + 'T00:00:00',
expireDate: expireStr + 'T00:00:00',
notePublic: inv.notePublic || null,
notePrivate: inv.notePrivate || null,
lines,
});
ok++;
} catch {
fail++;
}
}));
btn.disabled = false;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Duplicar`;
if (ok > 0) showToast(`${ok} factura${ok !== 1 ? 's' : ''} duplicada${ok !== 1 ? 's' : ''}`, 'success');
if (fail > 0) showToast(`${fail} factura${fail !== 1 ? 's no pudieron' : ' no pudo'} duplicarse`, 'error');
loadAllInvoices();
});
// ── Quarterly summary ─────────────────────────────────────────────
let quarterlyVisible = false;
function buildQuarterlySummary(invoices) {
const quarters = {};
invoices.forEach(inv => {
if (!inv.date) return;
const d = new Date(inv.date);
const year = d.getFullYear();
const q = Math.floor(d.getMonth() / 3) + 1;
const key = `${year}-Q${q}`;
if (!quarters[key]) quarters[key] = { year, q, ht: 0, tax: 0, total: 0, paid: 0, count: 0 };
const ht = parseFloat(inv.totalHt) || 0;
const tax = parseFloat(inv.totalTax) || 0;
const ttc = parseFloat(inv.total) || 0;
const remain = parseFloat(inv.remainToPay) || 0;
quarters[key].ht += ht;
quarters[key].tax += tax;
quarters[key].total += ttc;
quarters[key].paid += (ttc - remain);
quarters[key].count += 1;
});
return Object.values(quarters).sort((a, b) =>
b.year !== a.year ? b.year - a.year : b.q - a.q
);
}
function renderQuarterlySummary() {
const content = container.querySelector('#quarterly-content');
const rows = buildQuarterlySummary(allInvoices);
if (rows.length === 0) {
content.innerHTML = '<p class="quarterly-empty">No hay facturas para mostrar</p>';
return;
}
// Group by year
const byYear = {};
rows.forEach(r => {
if (!byYear[r.year]) byYear[r.year] = [];
byYear[r.year].push(r);
});
const fmt = (n) => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n);
const qLabel = (q) => [`1er`, `2º`, `3er`, `4º`][q - 1] + ` trimestre`;
let html = '';
Object.keys(byYear).sort((a,b) => b - a).forEach(year => {
const yearRows = byYear[year];
const totals = yearRows.reduce((acc, r) => ({
ht: acc.ht + r.ht, tax: acc.tax + r.tax,
total: acc.total + r.total, paid: acc.paid + r.paid,
count: acc.count + r.count
}), { ht: 0, tax: 0, total: 0, paid: 0, count: 0 });
html += `
<div class="quarterly-year-block">
<div class="quarterly-year-header">
<span class="quarterly-year-label">${year}</span>
<span class="quarterly-year-total">${fmt(totals.total)} · ${totals.count} facturas</span>
</div>
<table class="quarterly-table">
<thead>
<tr>
<th>Trimestre</th>
<th>Facturas</th>
<th>Base imponible</th>
<th>IVA</th>
<th>Total</th>
<th>Cobrado</th>
<th>Pendiente</th>
</tr>
</thead>
<tbody>
${yearRows.map(r => {
const pending = r.total - r.paid;
return `<tr>
<td class="quarterly-q-label">T${r.q} · <span>${qLabel(r.q)}</span></td>
<td class="quarterly-count">${r.count}</td>
<td class="quarterly-num">${fmt(r.ht)}</td>
<td class="quarterly-num quarterly-tax">${fmt(r.tax)}</td>
<td class="quarterly-num ${r.total > 0 ? 'quarterly-total--pos' : ''}">${fmt(r.total)}</td>
<td class="quarterly-num ${r.paid > 0 ? 'quarterly-paid' : 'quarterly-zero'}">${fmt(r.paid)}</td>
<td class="quarterly-num ${pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(pending)}</td>
</tr>`;
}).join('')}
</tbody>
<tfoot>
<tr class="quarterly-footer-row">
<td>Total ${year}</td>
<td>${totals.count}</td>
<td>${fmt(totals.ht)}</td>
<td>${fmt(totals.tax)}</td>
<td class="${totals.total > 0 ? 'quarterly-total--pos' : ''}">${fmt(totals.total)}</td>
<td class="${totals.paid > 0 ? 'quarterly-paid' : 'quarterly-zero'}">${fmt(totals.paid)}</td>
<td class="${totals.total - totals.paid > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(totals.total - totals.paid)}</td>
</tr>
</tfoot>
</table>
</div>`;
});
content.innerHTML = html;
}
function toggleQuarterly() {
quarterlyVisible = !quarterlyVisible;
const view = container.querySelector('#quarterly-view');
const table = container.querySelector('.invoices-table-container');
const pag = container.querySelector('.pagination');
const summary = container.querySelector('.invoices-summary');
const btn = container.querySelector('#btn-quarterly');
const bulk = container.querySelector('#bulk-bar');
if (quarterlyVisible) {
view.style.display = 'block';
table.style.display = 'none';
if (pag) pag.style.display = 'none';
if (summary) summary.style.display = 'none';
if (bulk) bulk.style.display = 'none';
btn.classList.add('btn-quarterly--active');
btn.innerHTML = `<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> Cerrar resumen`;
renderQuarterlySummary();
} else {
view.style.display = 'none';
table.style.display = '';
btn.classList.remove('btn-quarterly--active');
btn.innerHTML = `<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"/><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> Resumen trimestral`;
updatePaginationUI();
updateSummary(filteredInvoices);
}
}
container.querySelector('#btn-quarterly').addEventListener('click', toggleQuarterly);
container.querySelector('.btn-new-invoice').addEventListener('click', () => {
window.location.hash = '#create-invoice';
});

View File

@ -3,6 +3,7 @@ import { renderFacturasPage } from './Facturas.js';
import { renderCreateInvoicePage } from './CreateInvoicePage.js';
import { renderClientesPage } from './ClientesPage.js';
import { renderSettingsPage } from './SettingsPage.js';
import { renderBancoPage } from './BancoPage.js';
import { icons } from '../services/icons.js';
export const pagesRegistry = [
@ -42,6 +43,15 @@ export const pagesRegistry = [
showInSidebar: true,
render: renderClientesPage
},
{
route: 'banco',
name: 'Banco',
icon: icons.bank,
voicePatterns: ['banco', 'ir al banco', 'cuentas', 'cuentas bancarias', 'movimientos'],
requiresAuth: true,
showInSidebar: true,
render: renderBancoPage
},
{
route: 'settings',
name: 'Configuración',

View File

@ -42,15 +42,22 @@ export function initRouter() {
const hash = window.location.hash || (DEV_MODE ? '#dashboard' : '#login');
const route = hash.substring(1);
// Auth check first — a forced logout must never be blocked by the unsaved-changes guard
if (!DEV_MODE && !auth.checkAuth() && hash !== '#login') {
navigationGuards.unregister();
window.location.hash = '#login';
return;
}
// Si hay un guard activo y no estamos forzando navegación
if (!forceNavigate && navigationGuards.hasUnsavedChanges()) {
// Guardar el hash al que se quiere ir
pendingHash = hash;
// Volver al hash anterior temporalmente
const previousHash = '#' + (document.querySelector('.main-content')?.dataset?.currentRoute || 'dashboard');
history.pushState(null, '', previousHash);
// Mostrar modal de confirmación
const confirmed = await showConfirmExitModal({
title: 'Cambios sin guardar',
@ -69,11 +76,6 @@ export function initRouter() {
return;
}
if (!DEV_MODE && !auth.checkAuth() && hash !== '#login') {
window.location.hash = '#login';
return;
}
if (typeof currentPageCleanup === 'function') {
currentPageCleanup();
currentPageCleanup = null;

View File

@ -3,6 +3,10 @@ let reloginInProgress = false;
function redirectToLogin() {
if (window.location.hash !== '#login') {
// Close any open modals/overlays appended to body before leaving
document.querySelectorAll('body > [class*="overlay"], body > [class*="modal"]').forEach(el => {
if (!el.classList.contains('connection-lost-overlay')) el.remove();
});
window.location.hash = '#login';
}
}

View File

@ -1,4 +1,4 @@
import { apiGet } from './apiClient.js';
import { apiDelete, apiGet, apiPost, apiPut } from './apiClient.js';
export async function getClients(limit = 50, page = 1) {
return apiGet(`/api/Clients?limit=${limit}&page=${page}`);
@ -7,3 +7,15 @@ export async function getClients(limit = 50, page = 1) {
export async function getClientById(id) {
return apiGet(`/api/Clients/${id}`);
}
export async function createClient(data) {
return apiPost('/api/Clients', data);
}
export async function updateClient(id, data) {
return apiPut(`/api/Clients/${id}`, data);
}
export async function deleteClient(id) {
return apiDelete(`/api/Clients/${id}`);
}

View File

@ -95,5 +95,7 @@ export const icons = {
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>`
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>`,
bank: `<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="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>`
};

View File

@ -28,6 +28,11 @@ export async function deleteInvoiceLine(invoiceId, lineId) {
return true;
}
export async function updateInvoiceLine(invoiceId, lineId, data) {
await apiPut(`/api/Invoices/${invoiceId}/lines/${lineId}`, data);
return true;
}
export async function addPayment(invoiceId, paymentData) {
return apiPost(`/api/Invoices/${invoiceId}/payments`, paymentData);
}

17
src/services/setup.js Normal file
View File

@ -0,0 +1,17 @@
import { apiGet } from './apiClient.js';
export async function getPaymentTypes() {
return apiGet('/api/Setup/payment-types');
}
export async function getPaymentTerms() {
return apiGet('/api/Setup/payment-terms');
}
export async function getCompany() {
return apiGet('/api/Setup/company');
}
export async function getCountries() {
return apiGet('/api/Setup/countries');
}

View File

@ -132,39 +132,39 @@
color-scheme: dark;
}
:root[data-theme='dark'] .status-badge,
:root[data-theme='dark'] .status-badge-client {
border: 1px solid rgba(148, 163, 184, 0.2);
}
:root[data-theme='dark'] .status-paid,
:root[data-theme='dark'] .status-badge-client.status-active {
background-color: rgba(34, 197, 94, 0.18);
color: #86efac;
border-color: rgba(34, 197, 94, 0.35);
background-color: #15803d;
color: #fff;
}
:root[data-theme='dark'] .status-overdue,
:root[data-theme='dark'] .status-unpaid,
:root[data-theme='dark'] .status-pending,
:root[data-theme='dark'] .status-badge-client.status-inactive {
background-color: rgba(248, 113, 113, 0.18);
color: #fca5a5;
border-color: rgba(248, 113, 113, 0.35);
background-color: #c2410c;
color: #fff;
}
:root[data-theme='dark'] .status-pending {
background-color: #b45309;
color: #fff;
}
:root[data-theme='dark'] .status-draft,
:root[data-theme='dark'] .status-canceled,
:root[data-theme='dark'] .status-badge-client.status-unknown {
background-color: rgba(148, 163, 184, 0.14);
color: #cbd5e1;
border-color: rgba(148, 163, 184, 0.24);
background-color: #475569;
color: #fff;
}
:root[data-theme='dark'] .status-canceled {
background-color: #991b1b;
color: #fff;
opacity: 0.75;
}
:root[data-theme='dark'] .status-validated {
background-color: rgba(96, 165, 250, 0.18);
color: #93c5fd;
border-color: rgba(96, 165, 250, 0.35);
background-color: #1d4ed8;
color: #fff;
}
:root[data-theme='dark'] .invoices-table thead,
@ -271,44 +271,46 @@ button:disabled {
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
padding: 3px 10px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
letter-spacing: 0.02em;
border: none;
}
.status-pending {
background-color: var(--amber-50);
color: var(--amber-600);
background-color: #d97706;
color: #fff;
}
.status-paid {
background-color: var(--green-50);
color: var(--green-600);
background-color: #16a34a;
color: #fff;
}
.status-overdue,
.status-unpaid {
background-color: var(--red-50);
color: var(--red-600);
background-color: #ea580c;
color: #fff;
}
.status-draft {
background-color: var(--gray-100);
color: var(--gray-600);
background-color: #64748b;
color: #fff;
}
.status-validated {
background-color: var(--blue-50);
color: var(--blue-600);
background-color: #2563eb;
color: #fff;
}
.status-canceled {
background-color: var(--gray-100);
color: var(--gray-400);
text-decoration: line-through;
background-color: #dc2626;
color: #fff;
opacity: 0.7;
}
/* ============================================

660
src/styles/banco.css Normal file
View File

@ -0,0 +1,660 @@
/* ============================================
Banco Page
============================================ */
.banco-page {
padding: var(--space-6);
max-width: 1200px;
margin: 0 auto;
}
.banco-header {
margin-bottom: var(--space-5);
}
.banco-header h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 var(--space-1);
}
.banco-subtitle {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
/* ── Summary cards ── */
.banco-summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.banco-stat-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
box-shadow: var(--shadow-xs);
}
.banco-stat-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.banco-stat-card--balance .banco-stat-icon { background: var(--blue-50); color: var(--primary); }
.banco-stat-card--income .banco-stat-icon { background: var(--green-50); color: var(--success); }
.banco-stat-card--expense .banco-stat-icon { background: var(--red-50); color: var(--danger); }
.banco-stat-card--accounts .banco-stat-icon { background: var(--amber-50); color: var(--warning); }
.banco-stat-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.banco-stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.banco-stat-value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Content grid ── */
.banco-content-grid {
display: grid;
grid-template-columns: 1fr 1.6fr;
gap: var(--space-4);
}
.banco-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
overflow: hidden;
}
.banco-card-header {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.banco-card-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.btn-banco-new {
background: var(--blue-600) !important;
color: white !important;
border: none !important;
border-radius: var(--radius-md);
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-banco-new:hover { background: var(--blue-700) !important; }
/* ── Accounts list ── */
.banco-accounts-list {
padding: var(--space-2) 0;
}
.banco-account-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
cursor: pointer;
transition: background-color 0.15s, border-left-color 0.15s;
border-left: 3px solid transparent;
}
.banco-account-item:hover {
background: var(--gray-50);
}
.banco-account-item--active {
background: var(--blue-50) !important;
border-left-color: var(--primary);
}
:root[data-theme='dark'] .banco-account-item:hover { background: rgba(30,58,110,0.2); }
:root[data-theme='dark'] .banco-account-item--active { background: rgba(37,99,235,0.12) !important; }
.banco-account-icon {
width: 34px;
height: 34px;
border-radius: var(--radius-md);
background: var(--blue-50);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s;
}
.banco-account-item--active .banco-account-icon {
background: var(--primary);
color: white;
}
.banco-account-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.banco-account-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.banco-account-sub {
font-size: 11px;
color: var(--text-secondary);
font-family: ui-monospace, 'Cascadia Code', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.banco-account-right {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.banco-account-balance {
font-size: 13px;
font-weight: 700;
color: var(--success);
font-variant-numeric: tabular-nums;
}
.banco-account-balance--negative { color: var(--danger); }
.banco-account-arrow {
color: var(--gray-300);
transition: color 0.15s, transform 0.15s;
}
.banco-account-item:hover .banco-account-arrow,
.banco-account-item--active .banco-account-arrow {
color: var(--primary);
transform: translateX(2px);
}
.btn-account-edit {
display: none;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
background: transparent !important;
border: 1px solid var(--border-color) !important;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
flex-shrink: 0;
padding: 0 !important;
}
.btn-account-edit svg {
stroke: var(--gray-500);
transition: stroke 0.15s;
display: block;
}
.banco-account-item:hover .btn-account-edit,
.banco-account-item--active .btn-account-edit {
display: inline-flex;
}
.btn-account-edit:hover {
background: var(--blue-50) !important;
border-color: var(--primary) !important;
}
.btn-account-edit:hover svg {
stroke: var(--primary);
}
:root[data-theme='dark'] .btn-account-edit svg { stroke: var(--gray-400); }
:root[data-theme='dark'] .btn-account-edit:hover { background: rgba(37,99,235,0.15) !important; }
:root[data-theme='dark'] .btn-account-edit:hover svg { stroke: var(--blue-400, #60a5fa); }
/* ── Back button in tx header ── */
.btn-banco-back {
display: inline-flex;
align-items: center;
gap: 4px;
background: none !important;
border: 1px solid var(--border-color) !important;
color: var(--text-secondary) !important;
border-radius: var(--radius-md);
padding: 3px 10px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-banco-back:hover { background: var(--gray-100) !important; color: var(--text-primary) !important; }
:root[data-theme='dark'] .btn-banco-back:hover { background: rgba(30,58,110,0.3) !important; }
.banco-account-balance--negative {
color: var(--danger);
}
/* ── Transactions list ── */
.banco-transactions-list {
padding: var(--space-2) 0;
}
.banco-tx-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
transition: background-color var(--transition-fast);
}
.banco-tx-item:hover {
background: var(--bg-subtle);
}
.banco-tx-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.banco-tx-indicator--credit { background: var(--success); }
.banco-tx-indicator--debit { background: var(--danger); }
.banco-tx-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.banco-tx-label {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.banco-tx-date {
font-size: 11px;
color: var(--text-secondary);
}
.banco-tx-amount {
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.banco-tx-amount--credit { color: var(--success); }
.banco-tx-amount--debit { color: var(--danger); }
/* ── Loading skeleton ── */
.banco-loading {
padding: var(--space-3) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.banco-skeleton {
height: 36px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--bg-subtle) 25%, var(--gray-100) 50%, var(--bg-subtle) 75%);
background-size: 200% 100%;
animation: banco-shimmer 1.4s ease infinite;
}
@keyframes banco-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Empty state ── */
.banco-empty {
padding: var(--space-8) var(--space-5);
text-align: center;
color: var(--text-secondary);
font-size: 13px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
.banco-soon-tag {
display: inline-block;
background: var(--amber-50);
color: var(--warning);
border: 1px solid rgba(217, 119, 6, 0.2);
border-radius: 99px;
font-size: 11px;
font-weight: 600;
padding: 2px 10px;
letter-spacing: 0.04em;
}
/* ── Dark mode ── */
:root[data-theme='dark'] .banco-stat-card,
:root[data-theme='dark'] .banco-card {
background: #081226;
border-color: #1e2f4d;
}
:root[data-theme='dark'] .banco-card-header {
border-color: #1e2f4d;
}
:root[data-theme='dark'] .banco-account-icon {
background: rgba(30, 58, 110, 0.4);
}
:root[data-theme='dark'] .banco-account-item--active .banco-account-icon {
background: var(--primary);
}
:root[data-theme='dark'] .banco-stat-card--balance .banco-stat-icon { background: rgba(37, 99, 235, 0.15); }
:root[data-theme='dark'] .banco-stat-card--income .banco-stat-icon { background: rgba(22, 163, 74, 0.15); }
:root[data-theme='dark'] .banco-stat-card--expense .banco-stat-icon { background: rgba(220, 38, 38, 0.15); }
:root[data-theme='dark'] .banco-stat-card--accounts .banco-stat-icon { background: rgba(217, 119, 6, 0.15); }
:root[data-theme='dark'] .banco-skeleton {
background: linear-gradient(90deg, rgba(15,23,42,0.8) 25%, rgba(30,47,77,0.8) 50%, rgba(15,23,42,0.8) 75%);
background-size: 200% 100%;
}
:root[data-theme='dark'] .banco-soon-tag {
background: rgba(217, 119, 6, 0.12);
border-color: rgba(217, 119, 6, 0.3);
color: #fbbf24;
}
/* ── Create account modal ── */
.banco-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--space-4);
backdrop-filter: blur(2px);
}
.banco-modal {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.18);
animation: fadeScaleIn 0.15s ease;
}
@keyframes fadeScaleIn {
from { opacity: 0; transform: scale(0.97) translateY(4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.banco-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-5) var(--space-5) var(--space-4);
border-bottom: 1px solid var(--border-color);
gap: var(--space-3);
}
.banco-modal-title-group {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.banco-modal-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--blue-50);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.banco-modal-icon--edit {
background: #f0fdf4;
color: #16a34a;
}
:root[data-theme='dark'] .banco-modal-icon--edit {
background: rgba(22,163,74,0.12);
color: #4ade80;
}
.banco-modal-header h2 {
margin: 0 0 2px;
font-size: 15px;
font-weight: 600;
line-height: 1.3;
}
.banco-modal-subtitle {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.banco-modal-close {
background: none !important;
border: none !important;
color: var(--text-tertiary, var(--gray-400));
cursor: pointer;
font-size: 16px;
padding: 4px 8px;
border-radius: var(--radius-sm);
line-height: 1;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.banco-modal-close:hover { background: var(--gray-100) !important; color: var(--text-primary); }
.banco-modal-form { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
/* ── Form sections ── */
.banco-form-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.banco-form-section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.banco-form-section-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-color);
}
.banco-form-section-optional {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
font-size: 11px;
color: var(--text-tertiary, var(--gray-400));
}
.banco-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.banco-form-field { display: flex; flex-direction: column; gap: 5px; }
.banco-form-field--full { grid-column: 1 / -1; }
.banco-form-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.banco-form-label .required { color: var(--danger); margin-left: 1px; }
.banco-form-input {
padding: 8px var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 13px;
font-family: inherit;
background: var(--card-bg);
color: var(--text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
box-sizing: border-box;
}
.banco-form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.banco-form-input::placeholder { color: var(--gray-400); }
select.banco-form-input { cursor: pointer; }
.banco-modal-footer {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
align-items: center;
padding-top: var(--space-4);
border-top: 1px solid var(--border-color);
}
.btn-banco-cancel {
background: transparent !important;
color: var(--text-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius-md);
padding: 8px 18px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-banco-cancel:hover { background: var(--gray-50) !important; color: var(--text-primary) !important; }
.btn-banco-submit {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--blue-600) !important;
color: white !important;
border: none !important;
border-radius: var(--radius-md);
padding: 8px 18px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
.btn-banco-submit:hover:not(:disabled) {
background: var(--blue-700) !important;
box-shadow: 0 4px 12px rgba(37,99,235,0.3);
}
.btn-banco-submit:disabled { opacity: 0.65; cursor: not-allowed; }
:root[data-theme='dark'] .banco-modal { background: #0d1a2e; border-color: #1e2f4d; }
:root[data-theme='dark'] .banco-modal-header { border-color: #1e2f4d; }
:root[data-theme='dark'] .banco-modal-footer { border-color: #1e2f4d; }
:root[data-theme='dark'] .banco-modal-icon { background: rgba(37,99,235,0.15); }
:root[data-theme='dark'] .banco-form-section-label::after { background: #1e2f4d; }
:root[data-theme='dark'] .banco-form-input { background: #081226; border-color: #1e2f4d; }
:root[data-theme='dark'] .banco-form-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,0.12); }
:root[data-theme='dark'] .banco-modal-close:hover { background: rgba(30,58,110,0.3) !important; }
:root[data-theme='dark'] .btn-banco-cancel:hover { background: rgba(30,47,77,0.5) !important; }
/* ── Responsive ── */
@media (max-width: 1024px) {
.banco-summary-grid { grid-template-columns: repeat(2, 1fr); }
.banco-content-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.banco-page { padding: var(--space-3); }
.banco-summary-grid { grid-template-columns: 1fr 1fr; }
}

View File

@ -521,3 +521,185 @@
:root[data-theme='dark'] .no-contacts {
background: rgba(15, 23, 42, 0.9);
}
/* ── Botón nuevo cliente ── */
.btn-new-client {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-4);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition-fast);
white-space: nowrap;
flex-shrink: 0;
}
.btn-new-client:hover {
background: var(--primary-hover);
}
/* ── Modal crear cliente ── */
.create-client-modal {
max-width: 620px;
max-height: 90vh;
}
.create-client-section {
margin-bottom: var(--space-5);
}
.create-client-section:last-child {
margin-bottom: 0;
}
.create-client-section h3 {
margin: 0 0 var(--space-3) 0;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.create-client-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.form-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-field--full {
grid-column: 1 / -1;
}
.form-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.form-required {
color: var(--danger);
}
.form-input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 13px;
font-family: inherit;
background: var(--card-bg);
color: var(--text-primary);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
outline: none;
}
.form-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-subtle);
}
.form-textarea {
resize: vertical;
min-height: 60px;
}
.form-error {
font-size: 11px;
color: var(--danger);
min-height: 14px;
}
.create-client-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--border-color);
background: var(--bg-subtle);
}
.btn-cancel-create {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-4);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast);
}
.btn-cancel-create:hover {
border-color: var(--gray-400);
color: var(--text-primary);
background: transparent;
}
.btn-submit-create {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-5);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition-fast);
min-width: 120px;
justify-content: center;
}
.btn-submit-create:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn-submit-create:disabled {
opacity: 0.7;
cursor: not-allowed;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* dark mode — create client modal */
:root[data-theme='dark'] .form-input {
background: #0b1220;
border-color: #1e2f4d;
color: var(--text-primary);
}
:root[data-theme='dark'] .form-input:focus {
border-color: var(--primary);
}
:root[data-theme='dark'] .create-client-footer {
background: rgba(15, 23, 42, 0.8);
border-color: #1e2f4d;
}
@media (max-width: 580px) {
.create-client-grid {
grid-template-columns: 1fr;
}
.form-field--full {
grid-column: unset;
}
}

View File

@ -2,6 +2,84 @@
Facturas + Tablas + Paginacion
============================================ */
/* ── Checkbox column ── */
.invoice-select-col {
width: 36px;
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4) !important;
text-align: center;
}
.invoice-check,
.invoice-check-all {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary);
}
/* ── Bulk action bar ── */
.bulk-action-bar {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
background: var(--blue-600);
color: white;
border-radius: var(--radius-lg);
margin-bottom: var(--space-3);
animation: slideDown 0.15s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.bulk-count {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.bulk-actions {
display: flex;
gap: var(--space-2);
margin-left: auto;
align-items: center;
}
.btn-bulk {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: var(--radius-md);
border: 1px solid rgba(255,255,255,0.3);
background: rgba(255,255,255,0.12);
color: white;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-bulk:hover { background: rgba(255,255,255,0.22); }
.btn-bulk-delete { border-color: rgba(248,113,113,0.5); }
.btn-bulk-delete:hover { background: rgba(239,68,68,0.25); }
.btn-bulk-cancel {
background: transparent;
border-color: rgba(255,255,255,0.2);
font-size: 12px;
}
.btn-bulk-cancel:hover { background: rgba(255,255,255,0.1); }
:root[data-theme='dark'] .bulk-action-bar {
background: #1d4ed8;
}
.facturas-page {
padding: var(--space-6);
max-width: 1400px;
@ -11,6 +89,168 @@
.facturas-header { margin-bottom: var(--space-5); }
.facturas-header h1 { font-size: 1.25rem; font-weight: 600; }
/* ── Quarterly toggle button ── */
.btn-quarterly {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
background: var(--card-bg) !important;
color: var(--text-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: inherit;
height: 36px;
transition: all 0.2s ease;
white-space: nowrap;
}
.btn-quarterly:hover {
background: var(--gray-50) !important;
color: var(--text-primary) !important;
border-color: var(--gray-300) !important;
}
.btn-quarterly--active {
background: var(--blue-600) !important;
color: white !important;
border-color: var(--blue-600) !important;
}
.btn-quarterly--active:hover {
background: var(--blue-700) !important;
border-color: var(--blue-700) !important;
}
:root[data-theme='dark'] .btn-quarterly {
background: #081226 !important;
border-color: #1e2f4d !important;
color: var(--text-secondary) !important;
}
:root[data-theme='dark'] .btn-quarterly:hover {
background: rgba(30,58,110,0.3) !important;
color: var(--text-primary) !important;
}
/* ── Quarterly summary view ── */
.quarterly-view {
margin-bottom: var(--space-4);
}
.quarterly-year-block {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: var(--space-4);
box-shadow: var(--shadow-xs);
}
.quarterly-year-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-5);
background: var(--gray-50);
border-bottom: 1px solid var(--border-color);
}
.quarterly-year-label {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.quarterly-year-total {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.quarterly-table {
width: 100%;
border-collapse: collapse;
}
.quarterly-table th {
padding: var(--space-2) var(--space-4);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
text-align: right;
background: var(--gray-50);
border-bottom: 1px solid var(--border-color);
}
.quarterly-table th:first-child { text-align: left; }
.quarterly-table td {
padding: var(--space-3) var(--space-4);
font-size: 13px;
border-bottom: 1px solid var(--border-color);
text-align: right;
font-variant-numeric: tabular-nums;
}
.quarterly-table tbody tr:last-child td { border-bottom: none; }
.quarterly-table tbody tr:hover { background: var(--gray-50); }
.quarterly-q-label {
text-align: left !important;
font-weight: 600;
color: var(--text-primary);
}
.quarterly-q-label span {
font-weight: 400;
color: var(--text-secondary);
font-size: 12px;
}
.quarterly-count { color: var(--text-secondary); }
.quarterly-num { color: var(--text-primary); }
.quarterly-tax { color: var(--text-secondary); }
.quarterly-total--pos { color: var(--primary); font-weight: 600; }
.quarterly-paid { color: var(--success); font-weight: 500; }
.quarterly-pending { color: var(--danger); font-weight: 500; }
.quarterly-zero { color: var(--text-secondary); }
.quarterly-footer-row td {
font-weight: 700;
background: var(--gray-50);
border-top: 2px solid var(--border-color);
border-bottom: none;
color: var(--text-primary);
}
.quarterly-loading, .quarterly-empty {
padding: var(--space-8);
text-align: center;
color: var(--text-secondary);
font-size: 14px;
}
:root[data-theme='dark'] .quarterly-year-block {
background: #081226;
border-color: #1e2f4d;
}
:root[data-theme='dark'] .quarterly-year-header,
:root[data-theme='dark'] .quarterly-table th,
:root[data-theme='dark'] .quarterly-footer-row td {
background: rgba(15,23,42,0.95);
border-color: #1e2f4d;
}
:root[data-theme='dark'] .quarterly-table td { border-color: #1e2f4d; }
:root[data-theme='dark'] .quarterly-table tbody tr:hover { background: rgba(30,58,110,0.2); }
.btn-new-invoice {
background: linear-gradient(135deg, var(--blue-600), var(--blue-700));
color: white;
@ -207,8 +447,8 @@
font-variant-numeric: tabular-nums;
}
.summary-item--paid .summary-value { color: var(--success); }
.summary-item--pending .summary-value { color: var(--primary); }
.summary-item--paid .summary-value { color: var(--success); }
.summary-item--pending .summary-value { color: var(--danger); }
:root[data-theme='dark'] .invoices-summary {
background: #081226;
@ -266,11 +506,15 @@
.invoice-total,
.invoice-remain {
font-weight: 500;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
/* ── Amount colours (used everywhere: table, summary, quarterly) ── */
.amount-positive { color: var(--primary); }
.amount-paid { color: var(--success); }
.amount-pending { color: var(--danger); }
.invoice-actions {
display: flex;
gap: var(--space-1);

View File

@ -71,15 +71,20 @@
/* ── Toggle Switch ── */
.settings-theme-toggle {
position: relative;
background: none;
border: none;
background: none !important;
border: none !important;
padding: 0;
cursor: pointer;
outline: none;
outline: none !important;
box-shadow: none !important;
}
.settings-theme-toggle:hover {
background: none !important;
}
.settings-theme-toggle:focus-visible {
outline: 2px solid var(--primary);
outline: 2px solid var(--primary) !important;
outline-offset: 4px;
border-radius: 4px;
}

View File

@ -10,85 +10,91 @@
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
gap: 12px;
}
/* ── FAB ─────────────────────────────────────────────────── */
.va-fab {
width: 50px;
height: 50px;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--primary);
background: var(--primary) !important;
color: #fff;
border: none;
border: none !important;
cursor: pointer;
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.45);
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.4);
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform 0.15s ease;
flex-shrink: 0;
outline: none !important;
}
.va-fab:hover {
background: var(--primary-hover);
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.55);
transform: scale(1.06);
background: var(--primary-hover) !important;
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.5);
transform: scale(1.07);
border-color: transparent;
}
.va-fab--recording {
background: var(--red-500) !important;
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45) !important;
animation: va-pulse-fab 1.2s ease-in-out infinite;
background: #ef4444 !important;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.45) !important;
animation: va-pulse-fab 1.4s ease-in-out infinite;
}
.va-fab--processing {
background: var(--amber-600) !important;
box-shadow: 0 4px 14px rgba(217, 119, 6, 0.4) !important;
box-shadow: 0 4px 16px rgba(217, 119, 6, 0.4) !important;
}
@keyframes va-pulse-fab {
0%, 100% { box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45); }
50% { box-shadow: 0 4px 22px rgba(239, 68, 68, 0.75); transform: scale(1.06); }
0%, 100% { box-shadow: 0 4px 16px rgba(239, 68, 68, 0.45); }
50% { box-shadow: 0 4px 28px rgba(239, 68, 68, 0.7); transform: scale(1.08); }
}
/* ── Panel ───────────────────────────────────────────────── */
.va-panel {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
width: 290px;
box-shadow: var(--shadow-lg);
border-radius: 16px;
width: 300px;
box-shadow: 0 16px 40px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.06);
overflow: hidden;
animation: va-slide-up 0.18s ease;
animation: va-slide-up 0.2s cubic-bezier(0.34, 1.4, 0.64, 1);
}
@keyframes va-slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; transform: translateY(14px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── Header ──────────────────────────────────────────────── */
.va-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 10px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-subtle);
}
.va-panel-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
gap: 7px;
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.07em;
}
.va-close-btn {
background: none;
border: none;
background: none !important;
border: none !important;
outline: none !important;
cursor: pointer;
color: var(--text-secondary);
display: flex;
@ -102,7 +108,7 @@
}
.va-close-btn:hover {
background: var(--gray-100);
background: var(--gray-200) !important;
color: var(--text-primary);
border-color: transparent;
}
@ -112,41 +118,44 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px 16px;
padding: 22px 16px 16px;
gap: 8px;
}
.va-status-icon {
width: 44px;
height: 44px;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--gray-100);
background: var(--bg-subtle);
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: background-color var(--transition-fast), color var(--transition-fast);
transition: all var(--transition-fast);
}
.va-status-icon--recording {
background: rgba(239, 68, 68, 0.12);
color: var(--red-500);
animation: va-pulse-icon 1.2s ease-in-out infinite;
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
animation: va-pulse-icon 1.4s ease-in-out infinite;
}
.va-status-icon--processing {
background: rgba(217, 119, 6, 0.1);
border-color: rgba(217, 119, 6, 0.35);
color: var(--amber-600);
}
@keyframes va-pulse-icon {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.va-status-text {
margin: 0;
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
@ -158,12 +167,13 @@
color: var(--text-secondary);
text-align: center;
min-height: 14px;
line-height: 1.4;
}
/* ── Resultado ───────────────────────────────────────────── */
.va-result {
margin: 0 12px 12px;
background: var(--gray-50);
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 10px 12px;
@ -176,7 +186,7 @@
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.07em;
color: var(--text-secondary);
}
@ -184,31 +194,33 @@
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
font-style: italic;
}
.va-result-action {
font-size: 12px;
color: var(--green-600);
font-weight: 500;
color: var(--success);
font-weight: 600;
}
.va-result-action--unknown {
color: var(--text-secondary);
font-weight: 500;
}
/* ── Lista de comandos ───────────────────────────────────── */
.va-commands {
border-top: 1px solid var(--border-color);
padding: 10px 0 4px;
padding: 8px 0 6px;
}
.va-commands-title {
margin: 0 0 6px;
margin: 0 0 4px;
padding: 0 14px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.07em;
color: var(--text-secondary);
}
@ -228,13 +240,14 @@
}
.va-cmd-item:hover {
background: var(--gray-50);
background: var(--bg-subtle);
}
.va-cmd-icon {
width: 26px;
height: 26px;
width: 28px;
height: 28px;
background: var(--blue-50);
border: 1px solid var(--blue-100);
border-radius: var(--radius-md);
display: flex;
align-items: center;
@ -252,7 +265,7 @@
.va-cmd-label {
font-size: 13px;
font-weight: 500;
font-weight: 600;
color: var(--text-primary);
}
@ -266,13 +279,27 @@
/* ── Dark mode ───────────────────────────────────────────── */
:root[data-theme='dark'] .va-panel {
background: #081226;
background: #0d1b2e;
border-color: #1e2f4d;
box-shadow: 0 16px 40px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.2);
}
:root[data-theme='dark'] .va-panel-header {
background: rgba(15, 23, 42, 0.8);
border-color: #1e2f4d;
}
:root[data-theme='dark'] .va-close-btn:hover,
:root[data-theme='dark'] .va-status-icon {
background: rgba(148, 163, 184, 0.08);
border-color: #1e2f4d;
}
:root[data-theme='dark'] .va-close-btn:hover {
background: rgba(30, 47, 77, 0.7) !important;
}
:root[data-theme='dark'] .va-cmd-item:hover {
background: rgba(30, 58, 110, 0.3);
background: rgba(30, 47, 77, 0.5);
}
:root[data-theme='dark'] .va-result {
@ -280,12 +307,13 @@
border-color: #1e2f4d;
}
:root[data-theme='dark'] .va-status-icon {
background: rgba(148, 163, 184, 0.1);
:root[data-theme='dark'] .va-cmd-icon {
background: rgba(37, 99, 235, 0.15);
border-color: rgba(37, 99, 235, 0.25);
}
:root[data-theme='dark'] .va-cmd-icon {
background: rgba(30, 58, 110, 0.4);
:root[data-theme='dark'] .va-commands {
border-color: #1e2f4d;
}
/* ── Móvil ───────────────────────────────────────────────── */
@ -297,6 +325,6 @@
.va-panel {
width: calc(100vw - 32px);
max-width: 290px;
max-width: 300px;
}
}

View File

@ -4,4 +4,10 @@ export default defineConfig({
optimizeDeps: {
exclude: ['@huggingface/transformers'],
},
server: {
watch: {
usePolling: true,
interval: 300,
},
},
});