795 lines
36 KiB
JavaScript
795 lines
36 KiB
JavaScript
import { icons } from '../services/icons.js';
|
||
import {
|
||
getSupplierInvoices, getSupplierInvoiceById, createSupplierInvoice,
|
||
updateSupplierInvoice, deleteSupplierInvoice, changeSupplierInvoiceStatus,
|
||
addSupplierInvoiceLine, updateSupplierInvoiceLine, deleteSupplierInvoiceLine,
|
||
getSupplierInvoicePayments, addSupplierInvoicePayment
|
||
} from '../services/supplierInvoices.js';
|
||
import { showToast } from '../services/toast.js';
|
||
import { apiGet } from '../services/apiClient.js';
|
||
import { getPaymentTypes } from '../services/setup.js';
|
||
|
||
export function renderFacturasProveedoresPage() {
|
||
const container = document.createElement('div');
|
||
container.className = 'facturas-page page-enter';
|
||
|
||
let allInvoices = [];
|
||
let filteredInvoices = [];
|
||
let currentFilter = '';
|
||
let searchTerm = '';
|
||
let currentInvoice = null;
|
||
let payments = [];
|
||
let paymentTypes = [];
|
||
|
||
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') : '—';
|
||
const fmtDateInput = (d) => d ? new Date(d).toISOString().split('T')[0] : '';
|
||
|
||
const STATUS_TEXT = {
|
||
draft: 'Borrador',
|
||
unpaid: 'Pte. Pago',
|
||
paid: 'Pagada',
|
||
cancelled: 'Cancelada',
|
||
unknown: 'Desconocido'
|
||
};
|
||
const STATUS_CLASS = {
|
||
draft: 'status-draft',
|
||
unpaid: 'status-unpaid',
|
||
paid: 'status-paid',
|
||
cancelled: 'status-canceled',
|
||
unknown: 'status-draft'
|
||
};
|
||
|
||
function escHtml(str) {
|
||
if (str == null) return '';
|
||
const d = document.createElement('div');
|
||
d.textContent = String(str);
|
||
return d.innerHTML;
|
||
}
|
||
|
||
container.innerHTML = /*html*/`
|
||
<div class="facturas-header">
|
||
<h1>Facturas de Proveedores</h1>
|
||
</div>
|
||
|
||
<div class="facturas-filters">
|
||
<div class="search-wrapper">
|
||
<span class="search-icon">${icons.search}</span>
|
||
<input type="search" placeholder="Buscar proveedor o referencia..." class="search-input search-input--with-icon" id="sup-search" />
|
||
</div>
|
||
<select class="filter-status" id="sup-filter">
|
||
<option value="">Todos los estados</option>
|
||
<option value="draft">Borrador</option>
|
||
<option value="unpaid">Pte. Pago</option>
|
||
<option value="paid">Pagada</option>
|
||
</select>
|
||
<button class="btn-new-invoice" id="btn-new-sup">${icons.plus} <span>Nueva factura</span></button>
|
||
</div>
|
||
|
||
<div class="invoices-summary" id="sup-summary" style="display:none">
|
||
<div class="summary-item">
|
||
<span class="summary-label">Facturas</span>
|
||
<span class="summary-value" id="sum-count">—</span>
|
||
</div>
|
||
<div class="summary-divider"></div>
|
||
<div class="summary-item">
|
||
<span class="summary-label">Base imponible</span>
|
||
<span class="summary-value" id="sum-ht">—</span>
|
||
</div>
|
||
<div class="summary-divider"></div>
|
||
<div class="summary-item">
|
||
<span class="summary-label">IVA soportado</span>
|
||
<span class="summary-value" id="sum-tax">—</span>
|
||
</div>
|
||
<div class="summary-divider"></div>
|
||
<div class="summary-item">
|
||
<span class="summary-label">Total</span>
|
||
<span class="summary-value" id="sum-total">—</span>
|
||
</div>
|
||
<div class="summary-divider"></div>
|
||
<div class="summary-item summary-item--pending">
|
||
<span class="summary-label">Pendiente</span>
|
||
<span class="summary-value" id="sum-pending">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="invoices-table-container">
|
||
<table class="invoices-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Número</th>
|
||
<th>Estado</th>
|
||
<th>Proveedor</th>
|
||
<th>Fecha</th>
|
||
<th class="text-right">Total</th>
|
||
<th class="text-right">Pendiente</th>
|
||
<th>Acciones</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="sup-tbody">
|
||
<tr><td colspan="7" class="facturas-empty">Cargando...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Modal detalle -->
|
||
<div class="modal-overlay sup-detail-overlay" id="sup-detail-overlay" style="display:none">
|
||
<div class="modal-content sup-detail-modal" id="sup-detail-modal">
|
||
<div class="modal-header">
|
||
<h2 id="sup-detail-title">Detalle</h2>
|
||
<button class="btn-close" id="sup-detail-close">×</button>
|
||
</div>
|
||
<div class="modal-body" id="sup-detail-body">
|
||
<div class="loading">Cargando...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal crear factura proveedor -->
|
||
<div class="modal-overlay sup-detail-overlay" id="sup-create-overlay" style="display:none">
|
||
<div class="modal-content sup-detail-modal" id="sup-create-modal">
|
||
<div class="modal-header">
|
||
<h2>Nueva factura de proveedor</h2>
|
||
<button class="btn-close" id="sup-create-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="sup-create-form">
|
||
<div class="invoice-detail-grid" style="margin-bottom:1rem">
|
||
<div class="form-field-compact full-width" style="grid-column:1/-1">
|
||
<label>Proveedor *</label>
|
||
<select id="sup-supplier-select" required style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--card-bg);color:var(--text-primary)">
|
||
<option value="">Cargando proveedores...</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-field-compact">
|
||
<label>Ref. proveedor *</label>
|
||
<input type="text" id="sup-ref-supplier" required placeholder="Ej: FAC-2024-001"
|
||
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
</div>
|
||
<div class="form-field-compact">
|
||
<label>Fecha *</label>
|
||
<input type="date" id="sup-date" required
|
||
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
</div>
|
||
<div class="form-field-compact">
|
||
<label>Vencimiento</label>
|
||
<input type="date" id="sup-expire"
|
||
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
</div>
|
||
<div class="form-field-compact">
|
||
<label>Nota pública</label>
|
||
<input type="text" id="sup-note-public" placeholder="Opcional"
|
||
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
||
<h3 class="lines-section-title" style="margin:0">Líneas *</h3>
|
||
<button type="button" class="btn-add-compact" id="sup-add-line">+ Añadir línea</button>
|
||
</div>
|
||
<div id="sup-lines-container"></div>
|
||
|
||
<div class="form-actions-compact" style="margin-top:1.5rem">
|
||
<button type="button" class="btn-cancel-compact" id="sup-create-cancel">Cancelar</button>
|
||
<button type="submit" class="btn-submit-compact" id="sup-create-submit">Crear factura</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const tbody = container.querySelector('#sup-tbody');
|
||
const searchInput = container.querySelector('#sup-search');
|
||
const filterSelect = container.querySelector('#sup-filter');
|
||
const summary = container.querySelector('#sup-summary');
|
||
const overlay = container.querySelector('#sup-detail-overlay');
|
||
const detailBody = container.querySelector('#sup-detail-body');
|
||
const detailTitle = container.querySelector('#sup-detail-title');
|
||
container.querySelector('#sup-detail-close').addEventListener('click', closeDetail);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDetail(); });
|
||
|
||
function applyFilters() {
|
||
filteredInvoices = allInvoices.filter(inv => {
|
||
const matchStatus = !currentFilter || inv.status === currentFilter;
|
||
const q = searchTerm.toLowerCase();
|
||
const matchSearch = !q
|
||
|| (inv.number || '').toLowerCase().includes(q)
|
||
|| (inv.supplierRef || '').toLowerCase().includes(q)
|
||
|| (inv.supplierName || '').toLowerCase().includes(q);
|
||
return matchStatus && matchSearch;
|
||
});
|
||
renderTable();
|
||
renderSummary();
|
||
}
|
||
|
||
function renderTable() {
|
||
if (filteredInvoices.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="facturas-empty">No hay facturas de proveedores</td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = filteredInvoices.map(inv => `
|
||
<tr class="invoice-item" data-id="${inv.id}">
|
||
<td class="invoice-number">
|
||
<button class="btn-invoice-num" data-id="${inv.id}">${escHtml(inv.number)}</button>
|
||
</td>
|
||
<td class="invoice-status">
|
||
<span class="status-badge ${STATUS_CLASS[inv.status] || 'status-draft'}">
|
||
${STATUS_TEXT[inv.status] || inv.status}
|
||
</span>
|
||
</td>
|
||
<td class="invoice-client">
|
||
<span class="client-icon">${icons.user}</span>
|
||
${escHtml(inv.supplierName || 'Sin nombre')}
|
||
</td>
|
||
<td class="invoice-date">${fmtDate(inv.date)}</td>
|
||
<td class="invoice-total amount-positive">${fmt(inv.total)}</td>
|
||
<td class="invoice-remain ${parseFloat(inv.remainToPay) > 0.009 ? 'amount-pending' : 'amount-paid'}">
|
||
${fmt(inv.remainToPay)}
|
||
</td>
|
||
<td class="invoice-actions">
|
||
<button class="btn-action btn-view" data-id="${inv.id}" title="Ver detalle">
|
||
${icons.eye}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
tbody.querySelectorAll('[data-id]').forEach(el => {
|
||
el.addEventListener('click', () => openDetail(Number(el.dataset.id)));
|
||
});
|
||
}
|
||
|
||
function renderSummary() {
|
||
if (filteredInvoices.length === 0) { summary.style.display = 'none'; return; }
|
||
summary.style.display = 'flex';
|
||
const totalHt = filteredInvoices.reduce((s, i) => s + (i.totalHt || 0), 0);
|
||
const totalTax = filteredInvoices.reduce((s, i) => s + (i.totalTax || 0), 0);
|
||
const total = filteredInvoices.reduce((s, i) => s + (i.total || 0), 0);
|
||
const pending = filteredInvoices.reduce((s, i) => s + (i.remainToPay || 0), 0);
|
||
container.querySelector('#sum-count').textContent = filteredInvoices.length;
|
||
container.querySelector('#sum-ht').textContent = fmt(totalHt);
|
||
container.querySelector('#sum-tax').textContent = fmt(totalTax);
|
||
container.querySelector('#sum-total').textContent = fmt(total);
|
||
container.querySelector('#sum-pending').textContent = fmt(pending);
|
||
}
|
||
|
||
async function openDetail(id) {
|
||
overlay.style.display = 'flex';
|
||
detailTitle.textContent = 'Cargando...';
|
||
detailBody.innerHTML = '<div class="loading">Cargando detalle...</div>';
|
||
payments = [];
|
||
|
||
// Load payment types once per page instance
|
||
if (paymentTypes.length === 0) {
|
||
try { paymentTypes = await getPaymentTypes(); } catch { paymentTypes = []; }
|
||
}
|
||
|
||
try {
|
||
const inv = await getSupplierInvoiceById(id);
|
||
currentInvoice = inv;
|
||
detailTitle.textContent = `Factura ${inv.number}`;
|
||
await loadPayments(id);
|
||
renderDetail(inv);
|
||
} catch (err) {
|
||
detailBody.innerHTML = `<div class="error">No se pudo cargar el detalle: ${escHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function refreshDetail() {
|
||
if (!currentInvoice) return;
|
||
try {
|
||
const inv = await getSupplierInvoiceById(currentInvoice.id);
|
||
currentInvoice = inv;
|
||
detailTitle.textContent = `Factura ${inv.number}`;
|
||
await loadPayments(inv.id);
|
||
renderDetail(inv);
|
||
// Refresh list row too
|
||
const idx = allInvoices.findIndex(x => x.id === inv.id);
|
||
if (idx >= 0) allInvoices[idx] = { ...allInvoices[idx], ...inv };
|
||
applyFilters();
|
||
} catch (err) {
|
||
showToast(`Error al recargar: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function loadPayments(id) {
|
||
try {
|
||
payments = await getSupplierInvoicePayments(id);
|
||
} catch {
|
||
payments = [];
|
||
}
|
||
}
|
||
|
||
function renderDetail(inv) {
|
||
const isDraft = inv.status === 'draft';
|
||
const isUnpaid = inv.status === 'unpaid';
|
||
const lines = inv.lines || [];
|
||
|
||
detailBody.innerHTML = /*html*/`
|
||
<!-- Header info -->
|
||
<div class="invoice-detail-grid" id="sup-detail-info">
|
||
<div class="detail-row">
|
||
<span class="detail-label">Proveedor</span>
|
||
<span class="detail-value">${escHtml(inv.supplierName || '—')}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">Ref. interna</span>
|
||
<span class="detail-value">${escHtml(inv.number || '—')}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">Ref. proveedor</span>
|
||
<span class="detail-value" id="sup-d-ref">${escHtml(inv.supplierRef || '—')}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">Estado</span>
|
||
<span class="detail-value">
|
||
<span class="status-badge ${STATUS_CLASS[inv.status] || 'status-draft'}">
|
||
${STATUS_TEXT[inv.status] || inv.status}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">Fecha</span>
|
||
<span class="detail-value">${fmtDate(inv.date)}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">Vencimiento</span>
|
||
<span class="detail-value" id="sup-d-expire">${fmtDate(inv.expireDate)}</span>
|
||
</div>
|
||
${inv.notePublic ? `<div class="detail-row full-width"><span class="detail-label">Nota pública</span><span class="detail-value" id="sup-d-note">${escHtml(inv.notePublic)}</span></div>` : ''}
|
||
</div>
|
||
|
||
<!-- Actions bar -->
|
||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin:1rem 0">
|
||
${isDraft ? `<button class="btn-submit-compact" id="sup-btn-validate">Validar factura</button>` : ''}
|
||
${isUnpaid ? `<button class="btn-submit-compact" id="sup-btn-paid" style="background:var(--success,#5dd39e);color:#000">Marcar como pagada</button>` : ''}
|
||
${inv.status === 'paid' || isUnpaid ? `<button class="btn-cancel-compact" id="sup-btn-draft">Volver a borrador</button>` : ''}
|
||
${isDraft ? `<button class="btn-cancel-compact" id="sup-btn-edit">Editar</button>` : ''}
|
||
${isDraft ? `<button class="btn-cancel-compact" id="sup-btn-delete" style="color:var(--danger)">Eliminar</button>` : ''}
|
||
</div>
|
||
|
||
<!-- Lines -->
|
||
<h3 class="lines-section-title">Líneas</h3>
|
||
<div class="invoice-lines-table-wrap">
|
||
<table class="invoice-lines-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Descripción</th>
|
||
<th class="text-right">Cant.</th>
|
||
<th class="text-right">P. Unit.</th>
|
||
<th class="text-right">IVA %</th>
|
||
<th class="text-right">Total</th>
|
||
${isDraft ? '<th></th>' : ''}
|
||
</tr>
|
||
</thead>
|
||
<tbody id="sup-lines-tbody">
|
||
${lines.length === 0
|
||
? `<tr><td colspan="${isDraft ? 6 : 5}" class="no-lines">Sin líneas</td></tr>`
|
||
: lines.map(l => `
|
||
<tr data-line-id="${l.id}">
|
||
<td>${escHtml(l.description || '—')}</td>
|
||
<td class="text-right">${l.quantity}</td>
|
||
<td class="text-right">${fmt(l.unitPrice)}</td>
|
||
<td class="text-right">${l.taxRate}%</td>
|
||
<td class="text-right amount-positive">${fmt(l.total)}</td>
|
||
${isDraft ? `<td style="white-space:nowrap">
|
||
<button class="btn-action" data-edit-line="${l.id}" title="Editar">${icons.eye}</button>
|
||
<button class="btn-action" data-del-line="${l.id}" title="Eliminar" style="color:var(--danger)">${icons.close}</button>
|
||
</td>` : ''}
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
${isDraft ? `
|
||
<div id="sup-add-line-form" style="margin-top:0.75rem;display:none">
|
||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||
<input type="text" id="nl-desc" placeholder="Descripción *" style="flex:2;min-width:140px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<input type="number" id="nl-qty" placeholder="Cant." value="1" min="0.01" step="0.01" style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<input type="number" id="nl-price" placeholder="P.Unit." min="0" step="0.01" style="width:90px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<input type="number" id="nl-tax" placeholder="IVA%" value="21" min="0" max="100" step="0.01" style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<button type="button" class="btn-submit-compact" id="nl-save">Añadir</button>
|
||
<button type="button" class="btn-cancel-compact" id="nl-cancel">Cancelar</button>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn-add-compact" id="sup-show-add-line" style="margin-top:0.5rem">+ Añadir línea</button>
|
||
` : ''}
|
||
|
||
<!-- Totals -->
|
||
<div class="invoice-totals">
|
||
${inv.totalHt != null ? `<div class="total-row subtotal"><span>Base imponible:</span><strong>${fmt(inv.totalHt)}</strong></div>` : ''}
|
||
${inv.totalTax != null ? `<div class="total-row subtotal"><span>IVA soportado:</span><strong>${fmt(inv.totalTax)}</strong></div>` : ''}
|
||
<div class="total-row"><span>Total:</span><strong class="amount-positive">${fmt(inv.total)}</strong></div>
|
||
<div class="total-row paid"><span>Pagado:</span><strong class="amount-paid">${fmt((inv.total || 0) - (inv.remainToPay || 0))}</strong></div>
|
||
<div class="total-row pending"><span>Pendiente:</span><strong class="amount-pending">${fmt(inv.remainToPay)}</strong></div>
|
||
</div>
|
||
|
||
<!-- Payments -->
|
||
<h3 class="lines-section-title" style="margin-top:1.5rem">Pagos</h3>
|
||
<div class="invoice-lines-table-wrap">
|
||
<table class="invoice-lines-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Fecha</th>
|
||
<th>Referencia</th>
|
||
<th>Tipo</th>
|
||
<th class="text-right">Importe</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="sup-payments-tbody">
|
||
${payments.length === 0
|
||
? `<tr><td colspan="4" class="no-lines">Sin pagos registrados</td></tr>`
|
||
: payments.map(p => `
|
||
<tr>
|
||
<td>${p.paymentDate ? new Date(p.paymentDate).toLocaleDateString('es-ES') : '—'}</td>
|
||
<td>${escHtml(p.ref || '—')}</td>
|
||
<td>${escHtml(p.type || '—')}</td>
|
||
<td class="text-right amount-paid">${fmt(p.amount)}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
${isUnpaid ? `
|
||
<div id="sup-pay-form" style="margin-top:1rem;display:none">
|
||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||
<input type="date" id="pay-date" style="padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<input type="number" id="pay-amount" placeholder="Importe (vacío=total)" min="0.01" step="0.01" style="width:130px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<select id="pay-type" style="padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--card-bg);color:var(--text-primary)">
|
||
${paymentTypes.length
|
||
? paymentTypes.map(t => `<option value="${t.id}">${escHtml(t.label || t.code || t.id)}</option>`).join('')
|
||
: `<option value="6">Transferencia</option><option value="4">Cheque</option><option value="7">Efectivo</option>`}
|
||
</select>
|
||
<button type="button" class="btn-submit-compact" id="pay-save">Registrar pago</button>
|
||
<button type="button" class="btn-cancel-compact" id="pay-cancel">Cancelar</button>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn-add-compact" id="sup-show-pay-form" style="margin-top:0.5rem">+ Registrar pago</button>
|
||
` : ''}
|
||
`;
|
||
|
||
// Wire up status buttons
|
||
detailBody.querySelector('#sup-btn-validate')?.addEventListener('click', () => changeStatus('unpaid'));
|
||
detailBody.querySelector('#sup-btn-paid')?.addEventListener('click', () => changeStatus('paid'));
|
||
detailBody.querySelector('#sup-btn-draft')?.addEventListener('click', () => changeStatus('draft'));
|
||
detailBody.querySelector('#sup-btn-delete')?.addEventListener('click', handleDelete);
|
||
detailBody.querySelector('#sup-btn-edit')?.addEventListener('click', () => renderEditForm(inv));
|
||
|
||
// Wire up line actions
|
||
detailBody.querySelectorAll('[data-del-line]').forEach(btn => {
|
||
btn.addEventListener('click', () => handleDeleteLine(parseInt(btn.dataset.delLine)));
|
||
});
|
||
detailBody.querySelectorAll('[data-edit-line]').forEach(btn => {
|
||
btn.addEventListener('click', () => openEditLineForm(parseInt(btn.dataset.editLine), inv));
|
||
});
|
||
|
||
// Add line form
|
||
const showAddLine = detailBody.querySelector('#sup-show-add-line');
|
||
const addLineForm = detailBody.querySelector('#sup-add-line-form');
|
||
showAddLine?.addEventListener('click', () => {
|
||
addLineForm.style.display = 'flex';
|
||
showAddLine.style.display = 'none';
|
||
detailBody.querySelector('#nl-desc').focus();
|
||
});
|
||
detailBody.querySelector('#nl-cancel')?.addEventListener('click', () => {
|
||
addLineForm.style.display = 'none';
|
||
showAddLine.style.display = '';
|
||
});
|
||
detailBody.querySelector('#nl-save')?.addEventListener('click', handleAddLine);
|
||
|
||
// Payment form
|
||
const showPayForm = detailBody.querySelector('#sup-show-pay-form');
|
||
const payForm = detailBody.querySelector('#sup-pay-form');
|
||
showPayForm?.addEventListener('click', () => {
|
||
payForm.style.display = 'flex';
|
||
showPayForm.style.display = 'none';
|
||
const dateInput = detailBody.querySelector('#pay-date');
|
||
if (dateInput) dateInput.value = new Date().toISOString().split('T')[0];
|
||
});
|
||
detailBody.querySelector('#pay-cancel')?.addEventListener('click', () => {
|
||
payForm.style.display = 'none';
|
||
showPayForm.style.display = '';
|
||
});
|
||
detailBody.querySelector('#pay-save')?.addEventListener('click', handleAddPayment);
|
||
}
|
||
|
||
function renderEditForm(inv) {
|
||
const editSection = detailBody.querySelector('#sup-detail-info');
|
||
if (!editSection) return;
|
||
|
||
const actionsBar = detailBody.querySelector('div[style*="flex-wrap"]');
|
||
|
||
// Replace info grid with edit form
|
||
const form = document.createElement('form');
|
||
form.id = 'sup-edit-form';
|
||
form.innerHTML = /*html*/`
|
||
<div class="invoice-detail-grid" style="margin-bottom:1rem">
|
||
<div class="form-field-compact">
|
||
<label>Ref. proveedor</label>
|
||
<input type="text" id="ed-ref" value="${escHtml(inv.supplierRef || '')}"
|
||
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
</div>
|
||
<div class="form-field-compact">
|
||
<label>Vencimiento</label>
|
||
<input type="date" id="ed-expire" value="${fmtDateInput(inv.expireDate)}"
|
||
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
</div>
|
||
<div class="form-field-compact full-width" style="grid-column:1/-1">
|
||
<label>Nota pública</label>
|
||
<input type="text" id="ed-note" value="${escHtml(inv.notePublic || '')}"
|
||
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
</div>
|
||
</div>
|
||
<div class="form-actions-compact">
|
||
<button type="button" class="btn-cancel-compact" id="ed-cancel">Cancelar</button>
|
||
<button type="submit" class="btn-submit-compact" id="ed-save">Guardar cambios</button>
|
||
</div>
|
||
`;
|
||
|
||
detailBody.querySelector('#sup-detail-info').replaceWith(form);
|
||
if (actionsBar) actionsBar.style.display = 'none';
|
||
|
||
form.querySelector('#ed-cancel').addEventListener('click', () => renderDetail(inv));
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const saveBtn = form.querySelector('#ed-save');
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = 'Guardando...';
|
||
try {
|
||
await updateSupplierInvoice(inv.id, {
|
||
supplierRef: form.querySelector('#ed-ref').value.trim() || undefined,
|
||
expireDate: form.querySelector('#ed-expire').value || undefined,
|
||
notePublic: form.querySelector('#ed-note').value.trim() || undefined,
|
||
});
|
||
showToast('Factura actualizada', 'success');
|
||
await refreshDetail();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = 'Guardar cambios';
|
||
}
|
||
});
|
||
}
|
||
|
||
async function changeStatus(status) {
|
||
try {
|
||
await changeSupplierInvoiceStatus(currentInvoice.id, status);
|
||
showToast(`Estado actualizado a ${STATUS_TEXT[status] || status}`, 'success');
|
||
await refreshDetail();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function handleDelete() {
|
||
if (!confirm(`¿Eliminar la factura ${currentInvoice?.number}? Esta acción no se puede deshacer.`)) return;
|
||
try {
|
||
await deleteSupplierInvoice(currentInvoice.id);
|
||
showToast('Factura eliminada', 'success');
|
||
closeDetail();
|
||
allInvoices = allInvoices.filter(i => i.id !== currentInvoice.id);
|
||
applyFilters();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function handleAddLine() {
|
||
const desc = detailBody.querySelector('#nl-desc').value.trim();
|
||
const qty = parseFloat(detailBody.querySelector('#nl-qty').value) || 1;
|
||
const price = parseFloat(detailBody.querySelector('#nl-price').value) || 0;
|
||
const tax = parseFloat(detailBody.querySelector('#nl-tax').value) || 0;
|
||
if (!desc) { showToast('La descripción es obligatoria', 'error'); return; }
|
||
const saveBtn = detailBody.querySelector('#nl-save');
|
||
saveBtn.disabled = true;
|
||
try {
|
||
await addSupplierInvoiceLine(currentInvoice.id, {
|
||
description: desc, quantity: qty, unitPrice: price, taxRate: tax
|
||
});
|
||
showToast('Línea añadida', 'success');
|
||
await refreshDetail();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function openEditLineForm(lineId, inv) {
|
||
const line = inv.lines?.find(l => l.id === lineId);
|
||
if (!line) return;
|
||
const row = detailBody.querySelector(`[data-line-id="${lineId}"]`);
|
||
if (!row) return;
|
||
|
||
row.innerHTML = /*html*/`
|
||
<td><input type="text" value="${escHtml(line.description || '')}" class="el-desc" style="width:100%;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
|
||
<td><input type="number" value="${line.quantity}" class="el-qty" min="0.01" step="0.01" style="width:70px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
|
||
<td><input type="number" value="${line.unitPrice}" class="el-price" min="0" step="0.01" style="width:90px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
|
||
<td><input type="number" value="${line.taxRate}" class="el-tax" min="0" max="100" step="0.01" style="width:70px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
|
||
<td></td>
|
||
<td style="white-space:nowrap">
|
||
<button class="btn-submit-compact el-save" style="padding:0.25rem 0.5rem;font-size:0.8rem">OK</button>
|
||
<button class="btn-cancel-compact el-cancel" style="padding:0.25rem 0.5rem;font-size:0.8rem">✕</button>
|
||
</td>
|
||
`;
|
||
|
||
row.querySelector('.el-cancel').addEventListener('click', () => renderDetail(inv));
|
||
row.querySelector('.el-save').addEventListener('click', async () => {
|
||
const btn = row.querySelector('.el-save');
|
||
btn.disabled = true;
|
||
try {
|
||
await updateSupplierInvoiceLine(currentInvoice.id, lineId, {
|
||
description: row.querySelector('.el-desc').value.trim() || undefined,
|
||
quantity: parseFloat(row.querySelector('.el-qty').value) || undefined,
|
||
unitPrice: parseFloat(row.querySelector('.el-price').value) || undefined,
|
||
taxRate: parseFloat(row.querySelector('.el-tax').value) ?? undefined,
|
||
});
|
||
showToast('Línea actualizada', 'success');
|
||
await refreshDetail();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleDeleteLine(lineId) {
|
||
if (!confirm('¿Eliminar esta línea?')) return;
|
||
try {
|
||
await deleteSupplierInvoiceLine(currentInvoice.id, lineId);
|
||
showToast('Línea eliminada', 'success');
|
||
await refreshDetail();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function handleAddPayment() {
|
||
const date = detailBody.querySelector('#pay-date').value;
|
||
const amountRaw = detailBody.querySelector('#pay-amount').value;
|
||
const paymentModeId = parseInt(detailBody.querySelector('#pay-type').value) || 6;
|
||
if (!date) { showToast('Selecciona una fecha de pago', 'error'); return; }
|
||
const saveBtn = detailBody.querySelector('#pay-save');
|
||
saveBtn.disabled = true;
|
||
try {
|
||
await addSupplierInvoicePayment(currentInvoice.id, {
|
||
paymentDate: date,
|
||
amount: amountRaw ? parseFloat(amountRaw) : undefined,
|
||
paymentModeId,
|
||
closePaidInvoices: 'yes',
|
||
accountId: 1,
|
||
});
|
||
showToast('Pago registrado', 'success');
|
||
await refreshDetail();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function closeDetail() {
|
||
overlay.style.display = 'none';
|
||
currentInvoice = null;
|
||
}
|
||
|
||
async function load() {
|
||
try {
|
||
allInvoices = await getSupplierInvoices({ limit: 200 });
|
||
applyFilters();
|
||
} catch (err) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="facturas-empty">Error al cargar: ${escHtml(err.message)}</td></tr>`;
|
||
showToast('Error al cargar facturas de proveedores', 'error');
|
||
}
|
||
}
|
||
|
||
searchInput.addEventListener('input', () => { searchTerm = searchInput.value; applyFilters(); });
|
||
filterSelect.addEventListener('change', () => { currentFilter = filterSelect.value; applyFilters(); });
|
||
|
||
// ===== CREAR FACTURA =====
|
||
const createOverlay = container.querySelector('#sup-create-overlay');
|
||
const supplierSelect = container.querySelector('#sup-supplier-select');
|
||
const linesContainer = container.querySelector('#sup-lines-container');
|
||
let lineCount = 0;
|
||
|
||
function addLine() {
|
||
lineCount++;
|
||
const row = document.createElement('div');
|
||
row.className = 'line-row-compact';
|
||
row.dataset.line = lineCount;
|
||
row.innerHTML = `
|
||
<input type="text" placeholder="Descripción *" class="line-desc" required style="flex:2;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<input type="number" placeholder="Cant." class="line-qty" value="1" min="0.01" step="0.01" required style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<input type="number" placeholder="P.Unit." class="line-price" min="0" step="0.01" required style="width:90px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<input type="number" placeholder="IVA%" class="line-tax" value="21" min="0" max="100" step="0.01" required style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||
<button type="button" class="btn-remove-line" title="Eliminar" style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:1.1rem;padding:0 0.25rem">✕</button>
|
||
`;
|
||
row.querySelector('.btn-remove-line').addEventListener('click', () => {
|
||
row.remove();
|
||
if (linesContainer.children.length === 0) addLine();
|
||
});
|
||
linesContainer.appendChild(row);
|
||
}
|
||
|
||
function openCreateModal() {
|
||
createOverlay.style.display = 'flex';
|
||
linesContainer.innerHTML = '';
|
||
lineCount = 0;
|
||
container.querySelector('#sup-ref-supplier').value = '';
|
||
container.querySelector('#sup-date').value = new Date().toISOString().split('T')[0];
|
||
container.querySelector('#sup-expire').value = '';
|
||
container.querySelector('#sup-note-public').value = '';
|
||
addLine();
|
||
}
|
||
|
||
function closeCreate() {
|
||
createOverlay.style.display = 'none';
|
||
}
|
||
|
||
async function loadSuppliers() {
|
||
try {
|
||
const clients = await apiGet('/api/Clients?limit=500&supplier=true');
|
||
supplierSelect.innerHTML = '<option value="">Seleccionar proveedor...</option>';
|
||
clients.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = c.name;
|
||
supplierSelect.appendChild(opt);
|
||
});
|
||
} catch {
|
||
supplierSelect.innerHTML = '<option value="">Error al cargar</option>';
|
||
}
|
||
}
|
||
|
||
container.querySelector('#btn-new-sup').addEventListener('click', () => {
|
||
openCreateModal();
|
||
loadSuppliers();
|
||
});
|
||
container.querySelector('#sup-create-close').addEventListener('click', closeCreate);
|
||
container.querySelector('#sup-create-cancel').addEventListener('click', closeCreate);
|
||
createOverlay.addEventListener('click', (e) => { if (e.target === createOverlay) closeCreate(); });
|
||
container.querySelector('#sup-add-line').addEventListener('click', addLine);
|
||
|
||
container.querySelector('#sup-create-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const submitBtn = container.querySelector('#sup-create-submit');
|
||
const supplierId = parseInt(supplierSelect.value);
|
||
if (!supplierId) { showToast('Selecciona un proveedor', 'error'); return; }
|
||
|
||
const lines = [...linesContainer.querySelectorAll('.line-row-compact')].map(row => ({
|
||
description: row.querySelector('.line-desc').value.trim(),
|
||
quantity: parseFloat(row.querySelector('.line-qty').value) || 1,
|
||
unitPrice: parseFloat(row.querySelector('.line-price').value) || 0,
|
||
taxRate: parseFloat(row.querySelector('.line-tax').value) || 0,
|
||
}));
|
||
|
||
if (lines.some(l => !l.description)) { showToast('Rellena la descripción de todas las líneas', 'error'); return; }
|
||
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Creando...';
|
||
|
||
try {
|
||
await createSupplierInvoice({
|
||
supplierId,
|
||
supplierRef: container.querySelector('#sup-ref-supplier').value.trim(),
|
||
date: container.querySelector('#sup-date').value,
|
||
expireDate: container.querySelector('#sup-expire').value || null,
|
||
notePublic: container.querySelector('#sup-note-public').value.trim() || null,
|
||
lines
|
||
});
|
||
showToast('Factura de proveedor creada', 'success');
|
||
closeCreate();
|
||
allInvoices = await getSupplierInvoices({ limit: 200 });
|
||
applyFilters();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Crear factura';
|
||
}
|
||
});
|
||
|
||
load();
|
||
|
||
return container;
|
||
}
|