feat(pos): add accounting frontend — reports, entries, periods
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
593
pos/static/js/accounting.js
Normal file
593
pos/static/js/accounting.js
Normal file
@@ -0,0 +1,593 @@
|
||||
// /home/Autopartes/pos/static/js/accounting.js
|
||||
// Accounting module: chart of accounts, journal entries, financial reports
|
||||
|
||||
const Accounting = (() => {
|
||||
const API = '/pos/api/accounting';
|
||||
let accounts = []; // cached for dropdowns
|
||||
let entryLineCtr = 0; // counter for entry line IDs
|
||||
|
||||
function token() {
|
||||
return localStorage.getItem('pos_token') || '';
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || 'Request failed');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// ─── Tabs ──────────────────────────────────────
|
||||
|
||||
function initTabs() {
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||||
|
||||
// Load data on tab switch
|
||||
const t = tab.dataset.tab;
|
||||
if (t === 'accounts') loadAccounts();
|
||||
if (t === 'entries') loadEntries();
|
||||
if (t === 'trial-balance') loadTrialBalance();
|
||||
if (t === 'income-statement') loadIncomeStatement();
|
||||
if (t === 'balance-sheet') loadBalanceSheet();
|
||||
if (t === 'aging') loadAging();
|
||||
if (t === 'periods') loadPeriods();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chart of Accounts ─────────────────────────
|
||||
|
||||
async function loadAccounts() {
|
||||
try {
|
||||
const res = await api('/accounts');
|
||||
accounts = res.data || [];
|
||||
renderAccountsTree();
|
||||
} catch (e) {
|
||||
document.getElementById('accounts-tree').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAccountsTree() {
|
||||
const container = document.getElementById('accounts-tree');
|
||||
if (!accounts.length) { container.innerHTML = '<p>No hay cuentas.</p>'; return; }
|
||||
|
||||
// Build tree structure
|
||||
const byParent = {};
|
||||
accounts.forEach(a => {
|
||||
const pid = a.parent_id || 'root';
|
||||
if (!byParent[pid]) byParent[pid] = [];
|
||||
byParent[pid].push(a);
|
||||
});
|
||||
|
||||
function buildUl(parentId) {
|
||||
const children = byParent[parentId] || [];
|
||||
if (!children.length) return '';
|
||||
let html = '<ul class="tree">';
|
||||
for (const acct of children) {
|
||||
const hasChildren = byParent[acct.id] && byParent[acct.id].length > 0;
|
||||
const balClass = acct.balance < 0 ? 'negative' : '';
|
||||
html += '<li>';
|
||||
if (hasChildren) {
|
||||
html += `<span class="tree-toggle open" onclick="this.classList.toggle('open');this.nextElementSibling.style.display=this.classList.contains('open')?'block':'none'">`;
|
||||
} else {
|
||||
html += '<span style="display:inline-block;width:1rem;">';
|
||||
}
|
||||
html += `<span class="tree-code">${acct.code}</span> ${acct.name}`;
|
||||
html += `<span class="tree-balance ${balClass}">$${fmt(acct.balance)}</span>`;
|
||||
html += '</span>';
|
||||
if (hasChildren) {
|
||||
html += `<div>${buildUl(acct.id)}</div>`;
|
||||
}
|
||||
html += '</li>';
|
||||
}
|
||||
html += '</ul>';
|
||||
return html;
|
||||
}
|
||||
|
||||
container.innerHTML = buildUl('root');
|
||||
}
|
||||
|
||||
function showNewAccountModal() {
|
||||
const sel = document.getElementById('na-parent');
|
||||
sel.innerHTML = '<option value="">-- Sin padre --</option>';
|
||||
accounts.forEach(a => {
|
||||
sel.innerHTML += `<option value="${a.id}">${a.code} - ${a.name}</option>`;
|
||||
});
|
||||
document.getElementById('na-code').value = '';
|
||||
document.getElementById('na-name').value = '';
|
||||
document.getElementById('new-account-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
try {
|
||||
const parentId = document.getElementById('na-parent').value;
|
||||
await api('/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: document.getElementById('na-code').value,
|
||||
name: document.getElementById('na-name').value,
|
||||
parent_id: parentId ? parseInt(parentId) : null,
|
||||
type: document.getElementById('na-type').value,
|
||||
}),
|
||||
});
|
||||
closeModal('new-account-modal');
|
||||
loadAccounts();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Journal Entries ───────────────────────────
|
||||
|
||||
async function loadEntries() {
|
||||
try {
|
||||
const from = document.getElementById('entries-from').value;
|
||||
const to = document.getElementById('entries-to').value;
|
||||
const type = document.getElementById('entries-type').value;
|
||||
let qs = '?per_page=50';
|
||||
if (from) qs += `&date_from=${from}`;
|
||||
if (to) qs += `&date_to=${to}`;
|
||||
if (type) qs += `&type=${type}`;
|
||||
|
||||
const res = await api(`/entries${qs}`);
|
||||
renderEntries(res.data || []);
|
||||
} catch (e) {
|
||||
document.getElementById('entries-list').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntries(entries) {
|
||||
const container = document.getElementById('entries-list');
|
||||
if (!entries.length) { container.innerHTML = '<p>No hay polizas en este periodo.</p>'; return; }
|
||||
|
||||
let html = `<table class="report-table">
|
||||
<thead><tr>
|
||||
<th>#</th><th>Fecha</th><th>Tipo</th><th>Descripcion</th>
|
||||
<th>Referencia</th><th class="amount">Monto</th><th>Auto</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const e of entries) {
|
||||
html += `<tr style="cursor:pointer;" onclick="Accounting.showEntryDetail(${e.id})">
|
||||
<td>${e.entry_number}</td>
|
||||
<td>${e.date || ''}</td>
|
||||
<td>${e.type || ''}</td>
|
||||
<td>${e.description || ''}</td>
|
||||
<td>${e.reference_type ? `${e.reference_type} #${e.reference_id}` : ''}</td>
|
||||
<td class="amount">$${fmt(e.total_amount)}</td>
|
||||
<td>${e.is_auto ? 'Si' : 'No'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function showEntryDetail(entryId) {
|
||||
try {
|
||||
const entry = await api(`/entries/${entryId}`);
|
||||
let html = `<h3>Poliza #${entry.entry_number}</h3>
|
||||
<div class="entry-header">
|
||||
<span><strong>Fecha:</strong> ${entry.date}</span>
|
||||
<span><strong>Tipo:</strong> ${entry.type}</span>
|
||||
<span><strong>Estado:</strong> ${entry.status}</span>
|
||||
</div>
|
||||
<p>${entry.description || ''}</p>
|
||||
<table class="report-table">
|
||||
<thead><tr>
|
||||
<th>Cuenta</th><th>Nombre</th><th class="amount">Cargo</th>
|
||||
<th class="amount">Abono</th><th>Nota</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const l of entry.lines) {
|
||||
html += `<tr>
|
||||
<td class="tree-code">${l.account_code}</td>
|
||||
<td>${l.account_name}</td>
|
||||
<td class="amount">${l.debit ? '$' + fmt(l.debit) : ''}</td>
|
||||
<td class="amount">${l.credit ? '$' + fmt(l.credit) : ''}</td>
|
||||
<td>${l.description || ''}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `<tr class="total-row">
|
||||
<td colspan="2">Totales</td>
|
||||
<td class="amount">$${fmt(entry.total_debit)}</td>
|
||||
<td class="amount">$${fmt(entry.total_credit)}</td>
|
||||
<td></td>
|
||||
</tr></tbody></table>`;
|
||||
|
||||
document.getElementById('entry-detail-content').innerHTML = html;
|
||||
document.getElementById('entry-detail-modal').classList.add('active');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manual Entry ──────────────────────────────
|
||||
|
||||
function showNewEntryModal() {
|
||||
document.getElementById('ne-date').value = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('ne-description').value = '';
|
||||
document.getElementById('ne-lines').innerHTML = '';
|
||||
document.getElementById('ne-balance').innerHTML = '';
|
||||
entryLineCtr = 0;
|
||||
addEntryLine();
|
||||
addEntryLine();
|
||||
document.getElementById('new-entry-modal').classList.add('active');
|
||||
}
|
||||
|
||||
function addEntryLine() {
|
||||
const id = entryLineCtr++;
|
||||
const tbody = document.getElementById('ne-lines');
|
||||
const tr = document.createElement('tr');
|
||||
tr.id = `ne-line-${id}`;
|
||||
|
||||
let acctOptions = '<option value="">Seleccionar</option>';
|
||||
accounts.forEach(a => {
|
||||
if (a.parent_id) { // Only leaf accounts
|
||||
acctOptions += `<option value="${a.id}">${a.code} - ${a.name}</option>`;
|
||||
}
|
||||
});
|
||||
|
||||
tr.innerHTML = `
|
||||
<td><select onchange="Accounting.updateEntryBalance()">${acctOptions}</select></td>
|
||||
<td><input type="number" step="0.01" min="0" value="0" onchange="Accounting.updateEntryBalance()"></td>
|
||||
<td><input type="number" step="0.01" min="0" value="0" onchange="Accounting.updateEntryBalance()"></td>
|
||||
<td><button onclick="this.closest('tr').remove();Accounting.updateEntryBalance();"
|
||||
style="border:none;background:none;color:var(--danger);cursor:pointer;font-size:1.1rem;">x</button></td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function updateEntryBalance() {
|
||||
const rows = document.querySelectorAll('#ne-lines tr');
|
||||
let totalDebit = 0, totalCredit = 0;
|
||||
rows.forEach(row => {
|
||||
const inputs = row.querySelectorAll('input[type="number"]');
|
||||
totalDebit += parseFloat(inputs[0].value) || 0;
|
||||
totalCredit += parseFloat(inputs[1].value) || 0;
|
||||
});
|
||||
const diff = Math.round((totalDebit - totalCredit) * 100) / 100;
|
||||
const el = document.getElementById('ne-balance');
|
||||
if (diff === 0) {
|
||||
el.innerHTML = `<span style="color:var(--success);">Cuadrada: Cargos $${fmt(totalDebit)} = Abonos $${fmt(totalCredit)}</span>`;
|
||||
} else {
|
||||
el.innerHTML = `<span style="color:var(--danger);">Descuadre: $${fmt(Math.abs(diff))} (Cargos $${fmt(totalDebit)} / Abonos $${fmt(totalCredit)})</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createEntry() {
|
||||
try {
|
||||
const rows = document.querySelectorAll('#ne-lines tr');
|
||||
const lines = [];
|
||||
rows.forEach(row => {
|
||||
const sel = row.querySelector('select');
|
||||
const inputs = row.querySelectorAll('input[type="number"]');
|
||||
const acctId = parseInt(sel.value);
|
||||
const debit = parseFloat(inputs[0].value) || 0;
|
||||
const credit = parseFloat(inputs[1].value) || 0;
|
||||
if (acctId && (debit > 0 || credit > 0)) {
|
||||
lines.push({ account_id: acctId, debit, credit, description: '' });
|
||||
}
|
||||
});
|
||||
|
||||
if (lines.length < 2) { alert('Se requieren al menos 2 lineas.'); return; }
|
||||
|
||||
await api('/entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
date: document.getElementById('ne-date').value,
|
||||
description: document.getElementById('ne-description').value,
|
||||
lines,
|
||||
}),
|
||||
});
|
||||
closeModal('new-entry-modal');
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trial Balance ─────────────────────────────
|
||||
|
||||
async function loadTrialBalance() {
|
||||
try {
|
||||
const year = document.getElementById('tb-year').value;
|
||||
const month = document.getElementById('tb-month').value;
|
||||
const res = await api(`/trial-balance?year=${year}&month=${month}`);
|
||||
renderTrialBalance(res);
|
||||
} catch (e) {
|
||||
document.getElementById('trial-balance-content').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrialBalance(data) {
|
||||
const rows = data.data || [];
|
||||
let html = `<table class="report-table">
|
||||
<thead><tr>
|
||||
<th>Codigo</th><th>Cuenta</th>
|
||||
<th class="amount">Saldo Inicial</th><th class="amount">Cargos</th>
|
||||
<th class="amount">Abonos</th><th class="amount">Saldo Final</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
let totals = { si: 0, c: 0, a: 0, sf: 0 };
|
||||
for (const r of rows) {
|
||||
totals.si += r.saldo_inicial;
|
||||
totals.c += r.cargos;
|
||||
totals.a += r.abonos;
|
||||
totals.sf += r.saldo_final;
|
||||
html += `<tr>
|
||||
<td class="tree-code">${r.code}</td><td>${r.name}</td>
|
||||
<td class="amount">$${fmt(r.saldo_inicial)}</td>
|
||||
<td class="amount">$${fmt(r.cargos)}</td>
|
||||
<td class="amount">$${fmt(r.abonos)}</td>
|
||||
<td class="amount">$${fmt(r.saldo_final)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `<tr class="total-row">
|
||||
<td colspan="2">Totales</td>
|
||||
<td class="amount">$${fmt(totals.si)}</td>
|
||||
<td class="amount">$${fmt(totals.c)}</td>
|
||||
<td class="amount">$${fmt(totals.a)}</td>
|
||||
<td class="amount">$${fmt(totals.sf)}</td>
|
||||
</tr></tbody></table>`;
|
||||
|
||||
document.getElementById('trial-balance-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Income Statement ──────────────────────────
|
||||
|
||||
async function loadIncomeStatement() {
|
||||
try {
|
||||
const year = document.getElementById('is-year').value;
|
||||
const month = document.getElementById('is-month').value;
|
||||
const res = await api(`/income-statement?year=${year}&month=${month}`);
|
||||
renderIncomeStatement(res);
|
||||
} catch (e) {
|
||||
document.getElementById('income-statement-content').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderIncomeStatement(data) {
|
||||
let html = '<table class="report-table"><tbody>';
|
||||
|
||||
// Ingresos
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>INGRESOS</strong></td></tr>';
|
||||
for (const item of data.ingresos.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="subtotal-row"><td>Total Ingresos</td><td class="amount">$${fmt(data.ingresos.total)}</td></tr>`;
|
||||
|
||||
// Costos
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>COSTOS</strong></td></tr>';
|
||||
for (const item of data.costos.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="subtotal-row"><td>Total Costos</td><td class="amount">$${fmt(data.costos.total)}</td></tr>`;
|
||||
|
||||
// Utilidad bruta
|
||||
html += `<tr class="total-row"><td><strong>UTILIDAD BRUTA</strong></td><td class="amount"><strong>$${fmt(data.utilidad_bruta)}</strong></td></tr>`;
|
||||
|
||||
// Gastos
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>GASTOS</strong></td></tr>';
|
||||
for (const item of data.gastos.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="subtotal-row"><td>Total Gastos</td><td class="amount">$${fmt(data.gastos.total)}</td></tr>`;
|
||||
|
||||
// Utilidad neta
|
||||
const netColor = data.utilidad_neta >= 0 ? 'var(--success)' : 'var(--danger)';
|
||||
html += `<tr class="total-row"><td><strong>UTILIDAD NETA</strong></td>
|
||||
<td class="amount" style="color:${netColor};"><strong>$${fmt(data.utilidad_neta)}</strong></td></tr>`;
|
||||
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('income-statement-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Balance Sheet ─────────────────────────────
|
||||
|
||||
async function loadBalanceSheet() {
|
||||
try {
|
||||
const asOf = document.getElementById('bs-date').value;
|
||||
const res = await api(`/balance-sheet?date=${asOf}`);
|
||||
renderBalanceSheet(res);
|
||||
} catch (e) {
|
||||
document.getElementById('balance-sheet-content').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBalanceSheet(data) {
|
||||
const balancedBadge = data.balanced
|
||||
? '<span class="badge badge-open">Cuadrado</span>'
|
||||
: '<span class="badge badge-closed">Descuadrado</span>';
|
||||
|
||||
let html = `<p>Al ${data.as_of} ${balancedBadge}</p>
|
||||
<table class="report-table"><tbody>`;
|
||||
|
||||
// Activo
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>ACTIVO</strong></td></tr>';
|
||||
for (const item of data.activo.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="total-row"><td>Total Activo</td><td class="amount">$${fmt(data.activo.total)}</td></tr>`;
|
||||
|
||||
// Pasivo
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>PASIVO</strong></td></tr>';
|
||||
for (const item of data.pasivo.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="total-row"><td>Total Pasivo</td><td class="amount">$${fmt(data.pasivo.total)}</td></tr>`;
|
||||
|
||||
// Capital
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>CAPITAL</strong></td></tr>';
|
||||
for (const item of data.capital.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="total-row"><td>Total Capital</td><td class="amount">$${fmt(data.capital.total)}</td></tr>`;
|
||||
|
||||
html += `<tr class="total-row"><td><strong>Pasivo + Capital</strong></td>
|
||||
<td class="amount"><strong>$${fmt(data.pasivo.total + data.capital.total)}</strong></td></tr>`;
|
||||
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('balance-sheet-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Aging ─────────────────────────────────────
|
||||
|
||||
async function loadAging() {
|
||||
try {
|
||||
const res = await api('/aging');
|
||||
renderAging(res);
|
||||
} catch (e) {
|
||||
document.getElementById('aging-content').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAging(data) {
|
||||
const rows = data.data || [];
|
||||
let html = `<table class="report-table">
|
||||
<thead><tr>
|
||||
<th>Cliente</th><th class="amount">Corriente</th>
|
||||
<th class="amount">1-30d</th><th class="amount">31-60d</th>
|
||||
<th class="amount">61-90d</th><th class="amount">90+d</th>
|
||||
<th class="amount">Total</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const r of rows) {
|
||||
html += `<tr>
|
||||
<td>${r.name}</td>
|
||||
<td class="amount">$${fmt(r.corriente)}</td>
|
||||
<td class="amount">$${fmt(r.d1_30)}</td>
|
||||
<td class="amount">$${fmt(r.d31_60)}</td>
|
||||
<td class="amount">$${fmt(r.d61_90)}</td>
|
||||
<td class="amount">$${fmt(r.d90_plus)}</td>
|
||||
<td class="amount"><strong>$${fmt(r.total)}</strong></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
const t = data.totals || {};
|
||||
html += `<tr class="total-row">
|
||||
<td>Totales</td>
|
||||
<td class="amount">$${fmt(t.corriente)}</td>
|
||||
<td class="amount">$${fmt(t.d1_30)}</td>
|
||||
<td class="amount">$${fmt(t.d31_60)}</td>
|
||||
<td class="amount">$${fmt(t.d61_90)}</td>
|
||||
<td class="amount">$${fmt(t.d90_plus)}</td>
|
||||
<td class="amount"><strong>$${fmt(t.total)}</strong></td>
|
||||
</tr></tbody></table>`;
|
||||
|
||||
document.getElementById('aging-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Periods ───────────────────────────────────
|
||||
|
||||
async function loadPeriods() {
|
||||
try {
|
||||
const res = await api('/periods');
|
||||
renderPeriods(res.data || []);
|
||||
} catch (e) {
|
||||
document.getElementById('periods-list').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPeriods(periods) {
|
||||
const months = ['', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
let html = `<table class="report-table">
|
||||
<thead><tr><th>Periodo</th><th>Estado</th><th>Cerrado por</th><th>Fecha cierre</th></tr></thead><tbody>`;
|
||||
|
||||
for (const p of periods) {
|
||||
const badge = p.status === 'closed'
|
||||
? '<span class="badge badge-closed">Cerrado</span>'
|
||||
: '<span class="badge badge-open">Abierto</span>';
|
||||
html += `<tr>
|
||||
<td>${months[p.month]} ${p.year}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${p.closed_by_name || '-'}</td>
|
||||
<td>${p.closed_at ? new Date(p.closed_at).toLocaleDateString('es-MX') : '-'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('periods-list').innerHTML = html;
|
||||
}
|
||||
|
||||
async function closePeriod() {
|
||||
const year = parseInt(document.getElementById('cp-year').value);
|
||||
const month = parseInt(document.getElementById('cp-month').value);
|
||||
if (!confirm(`Cerrar periodo ${month}/${year}? Esta accion no se puede revertir.`)) return;
|
||||
|
||||
try {
|
||||
await api('/periods/close', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
loadPeriods();
|
||||
alert('Periodo cerrado exitosamente.');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal helpers ─────────────────────────────
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
initTabs();
|
||||
|
||||
// Set default period values
|
||||
const now = new Date();
|
||||
['tb-year', 'is-year', 'cp-year'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = now.getFullYear();
|
||||
});
|
||||
['tb-month', 'is-month', 'cp-month'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = now.getMonth() + 1;
|
||||
});
|
||||
const bsDate = document.getElementById('bs-date');
|
||||
if (bsDate) bsDate.value = now.toISOString().slice(0, 10);
|
||||
|
||||
// Set default entry date filters
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||
document.getElementById('entries-from').value = firstDay;
|
||||
document.getElementById('entries-to').value = now.toISOString().slice(0, 10);
|
||||
|
||||
loadAccounts();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
// Public API
|
||||
return {
|
||||
loadAccounts, loadEntries, loadTrialBalance, loadIncomeStatement,
|
||||
loadBalanceSheet, loadAging, loadPeriods, closePeriod,
|
||||
showNewAccountModal, createAccount,
|
||||
showNewEntryModal, addEntryLine, updateEntryBalance, createEntry,
|
||||
showEntryDetail, closeModal,
|
||||
};
|
||||
})();
|
||||
292
pos/templates/accounting.html
Normal file
292
pos/templates/accounting.html
Normal file
@@ -0,0 +1,292 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Contabilidad - Nexus POS</title>
|
||||
<link rel="stylesheet" href="/pos/static/css/common.css">
|
||||
<style>
|
||||
.tabs { display: flex; gap: 0; border-bottom: 2px solid var(--border); margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.tab { padding: 0.5rem 1rem; cursor: pointer; border: 1px solid transparent;
|
||||
border-bottom: none; border-radius: 6px 6px 0 0; font-size: 0.85rem;
|
||||
color: var(--text-muted); transition: all 0.2s; }
|
||||
.tab:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tab.active { background: var(--bg); color: var(--primary); border-color: var(--border);
|
||||
border-bottom: 2px solid var(--bg); margin-bottom: -2px; font-weight: 600; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Accounts tree */
|
||||
.tree { list-style: none; padding-left: 0; }
|
||||
.tree ul { list-style: none; padding-left: 1.5rem; }
|
||||
.tree li { padding: 0.25rem 0; }
|
||||
.tree-toggle { cursor: pointer; user-select: none; }
|
||||
.tree-toggle::before { content: '\25B6'; display: inline-block; width: 1rem;
|
||||
font-size: 0.7rem; transition: transform 0.2s; }
|
||||
.tree-toggle.open::before { transform: rotate(90deg); }
|
||||
.tree-code { font-family: monospace; color: var(--text-muted); margin-right: 0.5rem;
|
||||
font-size: 0.85rem; }
|
||||
.tree-balance { float: right; font-family: monospace; font-weight: 600; }
|
||||
.tree-balance.negative { color: var(--danger); }
|
||||
|
||||
/* Report tables */
|
||||
.report-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
.report-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--border);
|
||||
font-weight: 600; color: var(--text-muted); font-size: 0.8rem;
|
||||
text-transform: uppercase; }
|
||||
.report-table td { padding: 0.5rem; border-bottom: 1px solid var(--border-light); }
|
||||
.report-table .amount { text-align: right; font-family: monospace; }
|
||||
.report-table .total-row { font-weight: 700; border-top: 2px solid var(--border); }
|
||||
.report-table .subtotal-row { font-weight: 600; background: var(--bg-subtle); }
|
||||
|
||||
/* Period selector */
|
||||
.period-selector { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.period-selector select, .period-selector input { padding: 0.4rem 0.5rem; border: 1px solid var(--border);
|
||||
border-radius: 4px; font-size: 0.9rem; }
|
||||
|
||||
/* Entry detail */
|
||||
.entry-lines { margin-top: 0.5rem; }
|
||||
.entry-header { display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.5rem; background: var(--bg-subtle); border-radius: 4px;
|
||||
margin-bottom: 0.5rem; }
|
||||
|
||||
/* Status badges */
|
||||
.badge { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-open { background: #dcfce7; color: #166534; }
|
||||
.badge-closed { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Form grid */
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.form-group label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: 4px;
|
||||
font-size: 0.9rem; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5); z-index: 1000; align-items: center;
|
||||
justify-content: center; }
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal { background: var(--bg); padding: 1.5rem; border-radius: 8px; width: 90%;
|
||||
max-width: 600px; max-height: 80vh; overflow-y: auto; }
|
||||
.modal h3 { margin-top: 0; }
|
||||
|
||||
/* Lines editor */
|
||||
.lines-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||||
.lines-table th { text-align: left; font-size: 0.8rem; padding: 0.3rem; }
|
||||
.lines-table td { padding: 0.3rem; }
|
||||
.lines-table input, .lines-table select { width: 100%; padding: 0.3rem; border: 1px solid var(--border);
|
||||
border-radius: 3px; font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Contabilidad</h1>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<a href="/pos/sale" class="btn btn-secondary">POS</a>
|
||||
<a href="/pos/invoicing" class="btn btn-secondary">Facturacion</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="accounts">Catalogo de Cuentas</div>
|
||||
<div class="tab" data-tab="entries">Polizas</div>
|
||||
<div class="tab" data-tab="trial-balance">Balanza</div>
|
||||
<div class="tab" data-tab="income-statement">Estado de Resultados</div>
|
||||
<div class="tab" data-tab="balance-sheet">Balance General</div>
|
||||
<div class="tab" data-tab="aging">Antiguedad Saldos</div>
|
||||
<div class="tab" data-tab="periods">Periodos</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Chart of Accounts -->
|
||||
<div id="tab-accounts" class="tab-content active">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||
<h2 style="margin:0;">Catalogo de Cuentas</h2>
|
||||
<button class="btn btn-primary" onclick="Accounting.showNewAccountModal()">+ Subcuenta</button>
|
||||
</div>
|
||||
<div id="accounts-tree"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Journal Entries -->
|
||||
<div id="tab-entries" class="tab-content">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||
<h2 style="margin:0;">Polizas Contables</h2>
|
||||
<button class="btn btn-primary" onclick="Accounting.showNewEntryModal()">+ Poliza Manual</button>
|
||||
</div>
|
||||
<div class="period-selector">
|
||||
<label>Desde:</label>
|
||||
<input type="date" id="entries-from">
|
||||
<label>Hasta:</label>
|
||||
<input type="date" id="entries-to">
|
||||
<select id="entries-type">
|
||||
<option value="">Todos</option>
|
||||
<option value="ingreso">Ingreso</option>
|
||||
<option value="egreso">Egreso</option>
|
||||
<option value="diario">Diario</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" onclick="Accounting.loadEntries()">Filtrar</button>
|
||||
</div>
|
||||
<div id="entries-list"></div>
|
||||
<div id="entries-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Trial Balance -->
|
||||
<div id="tab-trial-balance" class="tab-content">
|
||||
<h2>Balanza de Comprobacion</h2>
|
||||
<div class="period-selector">
|
||||
<label>Anio:</label>
|
||||
<input type="number" id="tb-year" min="2020" max="2030" style="width:80px;">
|
||||
<label>Mes:</label>
|
||||
<select id="tb-month">
|
||||
<option value="1">Enero</option><option value="2">Febrero</option>
|
||||
<option value="3">Marzo</option><option value="4">Abril</option>
|
||||
<option value="5">Mayo</option><option value="6">Junio</option>
|
||||
<option value="7">Julio</option><option value="8">Agosto</option>
|
||||
<option value="9">Septiembre</option><option value="10">Octubre</option>
|
||||
<option value="11">Noviembre</option><option value="12">Diciembre</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" onclick="Accounting.loadTrialBalance()">Generar</button>
|
||||
</div>
|
||||
<div id="trial-balance-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Income Statement -->
|
||||
<div id="tab-income-statement" class="tab-content">
|
||||
<h2>Estado de Resultados</h2>
|
||||
<div class="period-selector">
|
||||
<label>Anio:</label>
|
||||
<input type="number" id="is-year" min="2020" max="2030" style="width:80px;">
|
||||
<label>Mes:</label>
|
||||
<select id="is-month">
|
||||
<option value="1">Enero</option><option value="2">Febrero</option>
|
||||
<option value="3">Marzo</option><option value="4">Abril</option>
|
||||
<option value="5">Mayo</option><option value="6">Junio</option>
|
||||
<option value="7">Julio</option><option value="8">Agosto</option>
|
||||
<option value="9">Septiembre</option><option value="10">Octubre</option>
|
||||
<option value="11">Noviembre</option><option value="12">Diciembre</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" onclick="Accounting.loadIncomeStatement()">Generar</button>
|
||||
</div>
|
||||
<div id="income-statement-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Balance Sheet -->
|
||||
<div id="tab-balance-sheet" class="tab-content">
|
||||
<h2>Balance General</h2>
|
||||
<div class="period-selector">
|
||||
<label>Al dia:</label>
|
||||
<input type="date" id="bs-date">
|
||||
<button class="btn btn-secondary" onclick="Accounting.loadBalanceSheet()">Generar</button>
|
||||
</div>
|
||||
<div id="balance-sheet-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Aging -->
|
||||
<div id="tab-aging" class="tab-content">
|
||||
<h2>Antiguedad de Saldos</h2>
|
||||
<div id="aging-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Fiscal Periods -->
|
||||
<div id="tab-periods" class="tab-content">
|
||||
<h2>Periodos Fiscales</h2>
|
||||
<div style="margin-bottom:1rem;" id="close-period-form">
|
||||
<div class="period-selector">
|
||||
<label>Cerrar periodo:</label>
|
||||
<input type="number" id="cp-year" min="2020" max="2030" style="width:80px;">
|
||||
<select id="cp-month">
|
||||
<option value="1">Enero</option><option value="2">Febrero</option>
|
||||
<option value="3">Marzo</option><option value="4">Abril</option>
|
||||
<option value="5">Mayo</option><option value="6">Junio</option>
|
||||
<option value="7">Julio</option><option value="8">Agosto</option>
|
||||
<option value="9">Septiembre</option><option value="10">Octubre</option>
|
||||
<option value="11">Noviembre</option><option value="12">Diciembre</option>
|
||||
</select>
|
||||
<button class="btn btn-danger" onclick="Accounting.closePeriod()">Cerrar Periodo</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="periods-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Account Modal -->
|
||||
<div class="modal-overlay" id="new-account-modal">
|
||||
<div class="modal">
|
||||
<h3>Nueva Subcuenta</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Codigo</label>
|
||||
<input type="text" id="na-code" placeholder="110.01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nombre</label>
|
||||
<input type="text" id="na-name" placeholder="Caja chica">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Cuenta padre</label>
|
||||
<select id="na-parent"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tipo</label>
|
||||
<select id="na-type">
|
||||
<option value="activo">Activo</option>
|
||||
<option value="pasivo">Pasivo</option>
|
||||
<option value="capital">Capital</option>
|
||||
<option value="ingreso">Ingreso</option>
|
||||
<option value="costo">Costo</option>
|
||||
<option value="gasto">Gasto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
|
||||
<button class="btn btn-secondary" onclick="Accounting.closeModal('new-account-modal')">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="Accounting.createAccount()">Crear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Entry Modal -->
|
||||
<div class="modal-overlay" id="new-entry-modal">
|
||||
<div class="modal">
|
||||
<h3>Nueva Poliza Manual</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Fecha</label>
|
||||
<input type="date" id="ne-date">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Descripcion</label>
|
||||
<input type="text" id="ne-description" placeholder="Descripcion de la poliza">
|
||||
</div>
|
||||
</div>
|
||||
<h4>Movimientos</h4>
|
||||
<table class="lines-table">
|
||||
<thead>
|
||||
<tr><th>Cuenta</th><th style="width:120px;">Cargo</th><th style="width:120px;">Abono</th><th style="width:30px;"></th></tr>
|
||||
</thead>
|
||||
<tbody id="ne-lines"></tbody>
|
||||
</table>
|
||||
<button class="btn btn-secondary" onclick="Accounting.addEntryLine()" style="margin-top:0.5rem;">+ Linea</button>
|
||||
<div id="ne-balance" style="margin-top:0.5rem;font-weight:600;"></div>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
|
||||
<button class="btn btn-secondary" onclick="Accounting.closeModal('new-entry-modal')">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="Accounting.createEntry()">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry Detail Modal -->
|
||||
<div class="modal-overlay" id="entry-detail-modal">
|
||||
<div class="modal">
|
||||
<div id="entry-detail-content"></div>
|
||||
<div style="text-align:right;margin-top:1rem;">
|
||||
<button class="btn btn-secondary" onclick="Accounting.closeModal('entry-detail-modal')">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/accounting.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user