feat: detalle de cuentas por cobrar con opción de cancelar ticket
This commit is contained in:
@@ -97,18 +97,19 @@ const Accounting = (() => {
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
|
||||
const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
|
||||
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
|
||||
const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente');
|
||||
const actionLabel = r.balance > 0 ? 'Cobrar' : 'Ver';
|
||||
return `<tr>
|
||||
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
||||
<td class="td--primary">${r.name || r.customer_name || '-'}</td>
|
||||
<td class="td--primary">${r.customer_name || r.name || '-'}</td>
|
||||
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td class="td--amount">$${fmt(r.total)}</td>
|
||||
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
||||
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
||||
<td>${statusBadge(status, label)}</td>
|
||||
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Cobrar' : 'Ver'}</button></td>
|
||||
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.showReceivableDetail(${r.sale_id})">${esc(actionLabel)}</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -123,6 +124,90 @@ const Accounting = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Receivable detail / cancel ticket ----
|
||||
async function showReceivableDetail(saleId) {
|
||||
try {
|
||||
const sale = await api('/pos/api/sales/' + saleId);
|
||||
if (!sale || sale.error) {
|
||||
alert('No se pudo cargar el detalle de la venta');
|
||||
return;
|
||||
}
|
||||
const itemsHtml = (sale.items || []).map(function (item) {
|
||||
return '<tr>' +
|
||||
'<td>' + esc(item.part_number || '-') + '</td>' +
|
||||
'<td>' + esc(item.name) + '</td>' +
|
||||
'<td style="text-align:right">' + item.quantity + '</td>' +
|
||||
'<td style="text-align:right">$' + fmt(item.unit_price) + '</td>' +
|
||||
'<td style="text-align:right">$' + fmt(item.subtotal) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
const paid = (sale.payments || []).reduce(function (sum, p) { return sum + (p.amount || 0); }, 0) + (sale.amount_paid || 0);
|
||||
const balance = (sale.total || 0) - paid;
|
||||
const canCancel = sale.status !== 'cancelled' && balance > 0;
|
||||
|
||||
const html = '<div class="modal-overlay" id="receivableDetailOverlay" style="display:flex;z-index:2000;">' +
|
||||
'<div class="modal-pago" style="max-width:600px;width:90%;max-height:90vh;overflow:auto;">' +
|
||||
'<div class="modal-header"><h3>Detalle de Venta a Crédito</h3>' +
|
||||
'<button class="modal-close" onclick="Accounting.closeReceivableDetail()">✕</button></div>' +
|
||||
'<div style="padding:var(--space-4);">' +
|
||||
'<p><strong>Ticket:</strong> VTA-' + sale.id + '</p>' +
|
||||
'<p><strong>Cliente:</strong> ' + esc(sale.customer_name || '-') + '</p>' +
|
||||
'<p><strong>Fecha:</strong> ' + (sale.created_at ? new Date(sale.created_at).toLocaleString('es-MX') : '-') + '</p>' +
|
||||
'<p><strong>Estado:</strong> ' + esc(sale.status) + '</p>' +
|
||||
'<p><strong>Total:</strong> $' + fmt(sale.total) + '</p>' +
|
||||
'<p><strong>Pagado:</strong> $' + fmt(paid) + '</p>' +
|
||||
'<p><strong>Saldo:</strong> $' + fmt(balance) + '</p>' +
|
||||
'<h4 style="margin-top:var(--space-4);margin-bottom:var(--space-2);">Artículos</h4>' +
|
||||
'<table class="data-table"><thead><tr><th>Clave</th><th>Producto</th><th>Cant</th><th>P.Unit</th><th>Subtotal</th></tr></thead><tbody>' +
|
||||
(itemsHtml || '<tr><td colspan="5" style="text-align:center;">Sin artículos</td></tr>') +
|
||||
'</tbody></table>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-ghost" onclick="Accounting.closeReceivableDetail()">Cerrar</button>' +
|
||||
(canCancel ? '<button class="btn btn-danger" onclick="Accounting.cancelReceivable(' + sale.id + ')">Cancelar Ticket</button>' : '') +
|
||||
'</div>' +
|
||||
'</div></div>';
|
||||
|
||||
const existing = document.getElementById('receivableDetailOverlay');
|
||||
if (existing) existing.remove();
|
||||
document.body.insertAdjacentHTML('beforeend', html);
|
||||
} catch (e) {
|
||||
alert('Error al cargar detalle: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeReceivableDetail() {
|
||||
const el = document.getElementById('receivableDetailOverlay');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
async function cancelReceivable(saleId) {
|
||||
const reason = prompt('Motivo de cancelación del ticket (mínimo 3 caracteres):');
|
||||
if (!reason || reason.trim().length < 3) {
|
||||
alert('Se requiere un motivo para cancelar.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('¿Estás seguro de cancelar el ticket VTA-' + saleId + '? Esta acción reversa el inventario y el crédito del cliente.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api('/pos/api/sales/' + saleId + '/cancel', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ reason: reason.trim() })
|
||||
});
|
||||
if (res.error) {
|
||||
alert('Error: ' + res.error);
|
||||
return;
|
||||
}
|
||||
alert('Ticket cancelado correctamente.');
|
||||
closeReceivableDetail();
|
||||
loadAging();
|
||||
} catch (e) {
|
||||
alert('Error al cancelar: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tab 2: Cuentas por Pagar ----
|
||||
async function loadAccountsPayable() {
|
||||
const panel = document.getElementById('panel-cxp');
|
||||
@@ -500,12 +585,14 @@ const Accounting = (() => {
|
||||
window.closeNewEntryModal = closeNewEntryModal;
|
||||
window.addEntryLine = addEntryLine;
|
||||
window.submitNewEntry = submitNewEntry;
|
||||
|
||||
return {
|
||||
window.Accounting = {
|
||||
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
|
||||
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
|
||||
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
|
||||
showReceivableDetail, closeReceivableDetail, cancelReceivable,
|
||||
};
|
||||
|
||||
return window.Accounting;
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
||||
// so templates can use cache-busting query params freely.
|
||||
|
||||
const CACHE_NAME = 'nexus-pos-v21';
|
||||
const CACHE_NAME = 'nexus-pos-v22';
|
||||
|
||||
const APP_SHELL = [
|
||||
'/pos/static/css/tokens.css',
|
||||
|
||||
Reference in New Issue
Block a user