ProyectoGrupal/doli-front/src/pages/FacturasPage.js

567 lines
22 KiB
JavaScript
Raw Normal View History

import { InvoiceItem } from '../components/InvoiceItem.js';
import { InvoiceModal } from '../components/InvoiceModal.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');
container.className = 'facturas-page page-enter';
let currentPage = 1;
let totalPages = 1;
const pageSize = 20;
let allInvoices = [];
let filteredInvoices = [];
let currentFilter = '';
let searchTerm = '';
let sortKey = null;
let sortDir = 'asc';
let selectedIds = new Set();
container.innerHTML = /*html*/`
<div class="facturas-header">
<h1>Facturas</h1>
</div>
<div class="facturas-filters">
<div class="search-wrapper">
<span class="search-icon">${icons.search}</span>
<input type="search" placeholder="Buscar facturas..." class="search-input search-input--with-icon" />
</div>
<select class="filter-status">
<option value="">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="unpaid">Pte. Pago</option>
<option value="paid">Pagada</option>
</select>
<button class="btn-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>
<th class="invoice-status sortable" data-sort="status">
Estado <span class="sort-icon"></span>
</th>
<th class="invoice-client sortable" data-sort="clientName">
Cliente <span class="sort-icon"></span>
</th>
<th class="invoice-date sortable" data-sort="date">
Fecha <span class="sort-icon"></span>
</th>
<th class="invoice-total sortable" data-sort="total">
Total <span class="sort-icon"></span>
</th>
<th class="invoice-remain sortable" data-sort="remainToPay">
Pendiente <span class="sort-icon"></span>
</th>
<th class="invoice-actions">Acciones</th>
</tr>
</thead>
<tbody class="invoices-list">
<tr><td colspan="8" class="loading">Cargando facturas...</td></tr>
</tbody>
</table>
</div>
<div class="invoices-summary" style="display:none">
<div class="summary-item">
<span class="summary-label">Facturas</span>
<span class="summary-value" data-summary="count"></span>
</div>
<div class="summary-divider"></div>
<div class="summary-item">
<span class="summary-label">Total facturado</span>
<span class="summary-value" data-summary="total"></span>
</div>
<div class="summary-item summary-item--paid">
<span class="summary-label">Pagado</span>
<span class="summary-value" data-summary="paid"></span>
</div>
<div class="summary-item summary-item--pending">
<span class="summary-label">Pendiente</span>
<span class="summary-value" data-summary="pending"></span>
</div>
</div>
<div class="pagination">
<button class="btn-pagination btn-prev"> Anterior</button>
<div class="pagination-info">
<span class="current-page">1</span> / <span class="total-pages">1</span>
</div>
<button class="btn-pagination btn-next">Siguiente </button>
</div>
`;
const formatCurrency = (n) =>
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n || 0);
function sortInvoices(invoices) {
if (!sortKey) return invoices;
return [...invoices].sort((a, b) => {
let va = a[sortKey], vb = b[sortKey];
if (sortKey === 'total' || sortKey === 'remainToPay') {
va = parseFloat(va) || 0;
vb = parseFloat(vb) || 0;
} else if (sortKey === 'date') {
va = new Date(va).getTime() || 0;
vb = new Date(vb).getTime() || 0;
} else {
va = String(va ?? '').toLowerCase();
vb = String(vb ?? '').toLowerCase();
}
if (va < vb) return sortDir === 'asc' ? -1 : 1;
if (va > vb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
}
function updateSortIcons() {
container.querySelectorAll('th.sortable').forEach(th => {
const key = th.dataset.sort;
const icon = th.querySelector('.sort-icon');
if (key === sortKey) {
icon.textContent = sortDir === 'asc' ? ' ↑' : ' ↓';
th.classList.add('sort-active');
} else {
icon.textContent = '';
th.classList.remove('sort-active');
}
});
}
function updateSummary(invoices) {
const summaryEl = container.querySelector('.invoices-summary');
if (!invoices.length) { summaryEl.style.display = 'none'; return; }
const total = invoices.reduce((s, i) => s + (parseFloat(i.total) || 0), 0);
const pending = invoices.reduce((s, i) => s + (parseFloat(i.remainToPay) || 0), 0);
const paid = total - pending;
container.querySelector('[data-summary="count"]').textContent = invoices.length;
container.querySelector('[data-summary="total"]').textContent = formatCurrency(total);
container.querySelector('[data-summary="paid"]').textContent = formatCurrency(paid);
container.querySelector('[data-summary="pending"]').textContent = formatCurrency(pending);
summaryEl.style.display = 'flex';
}
function renderPage(page = 1) {
const invoicesList = container.querySelector('.invoices-list');
currentPage = page;
const sorted = sortInvoices(filteredInvoices);
totalPages = Math.ceil(sorted.length / pageSize);
const startIndex = (page - 1) * pageSize;
const invoices = sorted.slice(startIndex, startIndex + pageSize);
updatePaginationUI();
updateSortIcons();
updateSummary(filteredInvoices);
invoicesList.innerHTML = '';
if (invoices.length === 0) {
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) {
const modal = InvoiceModal(invoiceId, null, () => {
loadAllInvoices();
});
document.body.appendChild(modal);
}
async function loadAllInvoices() {
const invoicesList = container.querySelector('.invoices-list');
selectedIds.clear();
updateBulkBar();
try {
invoicesList.innerHTML = '<tr><td colspan="8" class="loading">Cargando facturas...</td></tr>';
let url = '/api/Invoices?limit=1000';
if (currentFilter) url += `&status=${currentFilter}`;
if (searchTerm) url += `&search=${encodeURIComponent(searchTerm)}`;
const data = await apiGet(url);
allInvoices = Array.isArray(data) ? data : data.data || data.invoices || [];
filteredInvoices = [...allInvoices].reverse();
renderPage(1);
} catch (error) {
console.error('Error al cargar facturas:', error);
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>`;
}
}
function applyFilters() {
loadAllInvoices();
}
function updatePaginationUI() {
const paginationDiv = container.querySelector('.pagination');
const currentPageSpan = container.querySelector('.current-page');
const totalPagesSpan = container.querySelector('.total-pages');
const btnPrev = container.querySelector('.btn-prev');
const btnNext = container.querySelector('.btn-next');
currentPageSpan.textContent = currentPage;
totalPagesSpan.textContent = totalPages;
paginationDiv.style.display = totalPages <= 1 ? 'none' : 'flex';
btnPrev.disabled = currentPage === 1;
btnNext.disabled = currentPage === totalPages;
}
container.querySelectorAll('th.sortable').forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDir = 'asc';
}
renderPage(1);
});
});
// ── 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, pending: 0, count: 0 };
const ttc = parseFloat(inv.total) || 0;
quarters[key].ht += parseFloat(inv.totalHt) || 0;
quarters[key].tax += parseFloat(inv.totalTax) || 0;
quarters[key].total += ttc;
if (inv.status === 'paid') quarters[key].paid += ttc;
else if (inv.status === 'unpaid') quarters[key].pending += ttc;
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`, ``, `3er`, ``][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,
pending: acc.pending + r.pending, count: acc.count + r.count
}), { ht: 0, tax: 0, total: 0, paid: 0, pending: 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 => {
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-paid' : r.total < 0 ? 'quarterly-pending' : ''}">${fmt(r.total)}</td>
<td class="quarterly-num ${r.paid > 0 ? 'quarterly-paid' : r.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${r.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(r.paid)}</td>
<td class="quarterly-num ${r.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(r.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-paid' : totals.total < 0 ? 'quarterly-pending' : ''}">${fmt(totals.total)}</td>
<td class="${totals.paid > 0 ? 'quarterly-paid' : totals.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${totals.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(totals.paid)}</td>
<td class="${totals.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(totals.pending)}</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';
});
const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
const inputValue = e.target.value.trim();
clearTimeout(searchInput._debounceTimer);
searchInput._debounceTimer = setTimeout(() => {
searchTerm = /\d/.test(inputValue) ? inputValue : '';
applyFilters();
}, 500);
});
container.querySelector('.filter-status').addEventListener('change', (e) => {
currentFilter = e.target.value;
applyFilters();
});
container.querySelector('.btn-prev').addEventListener('click', () => {
if (currentPage > 1) { renderPage(currentPage - 1); scrollToTop(); }
});
container.querySelector('.btn-next').addEventListener('click', () => {
if (currentPage < totalPages) { renderPage(currentPage + 1); scrollToTop(); }
});
function scrollToTop() {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
loadAllInvoices();
return container;
}