BillTracker/legacy/js/tracker.js

425 lines
14 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
/* ── Tracker page ── */
const TrackerPage = (() => {
let currentYear, currentMonth;
let trackerData = null;
const MONTH_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
const STATUS_META = {
paid: { label: 'Paid', cls: 'badge-paid' },
upcoming: { label: 'Upcoming', cls: 'badge-upcoming' },
due_soon: { label: 'Due Soon', cls: 'badge-due-soon' },
late: { label: 'Late', cls: 'badge-late' },
missed: { label: 'Missed', cls: 'badge-missed' },
autodraft: { label: 'Autodraft', cls: 'badge-autodraft' },
};
function fmt(amount) {
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function fmtDate(dateStr) {
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-');
return `${parseInt(m)}/${parseInt(d)}/${y}`;
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function init(container) {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
render(container);
}
function render(container) {
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Tracker</h1>
<div class="month-nav">
<button class="btn btn-ghost btn-sm" id="prev-month">&#8592;</button>
<span class="month-label" id="month-label"></span>
<button class="btn btn-ghost btn-sm" id="next-month">&#8594;</button>
<button class="btn btn-ghost btn-sm" id="today-btn">Today</button>
</div>
</div>
<div class="summary-bar" id="summary-bar"></div>
<div id="tracker-body"><div class="loading">Loading...</div></div>
`;
document.getElementById('month-label').textContent =
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
document.getElementById('prev-month').onclick = () => navigate(-1, container);
document.getElementById('next-month').onclick = () => navigate(1, container);
document.getElementById('today-btn').onclick = () => {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
loadData(container);
};
loadData(container);
}
function navigate(delta, container) {
currentMonth += delta;
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
document.getElementById('month-label').textContent =
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
loadData(container);
}
async function loadData(container) {
document.getElementById('tracker-body').innerHTML = '<div class="loading">Loading...</div>';
try {
trackerData = await API.tracker(currentYear, currentMonth);
renderSummary(trackerData.summary);
renderRows(trackerData.rows, container);
} catch (e) {
document.getElementById('tracker-body').innerHTML =
`<div class="empty-state"><p>Failed to load tracker: ${e.message}</p></div>`;
}
}
function renderSummary(s) {
document.getElementById('summary-bar').innerHTML = `
<div class="summary-card">
<div class="label">Total Expected</div>
<div class="value">${fmt(s.total_expected)}</div>
</div>
<div class="summary-card success">
<div class="label">Total Paid</div>
<div class="value">${fmt(s.total_paid)}</div>
</div>
<div class="summary-card">
<div class="label">Remaining</div>
<div class="value">${fmt(s.remaining)}</div>
</div>
<div class="summary-card ${s.overdue > 0 ? 'danger' : ''}">
<div class="label">Overdue</div>
<div class="value">${fmt(s.overdue)}</div>
</div>
`;
}
function renderRows(rows, container) {
const body = document.getElementById('tracker-body');
if (!rows || rows.length === 0) {
body.innerHTML = `<div class="empty-state">
<p>No bills this month. <a href="#bills" class="btn-link">Add a bill</a></p>
</div>`;
return;
}
const first = rows.filter(r => r.bucket === '1st');
const second = rows.filter(r => r.bucket === '15th');
body.innerHTML = '';
if (first.length) body.appendChild(renderBucket('1st14th', first));
if (second.length) body.appendChild(renderBucket('15th31st', second));
// Attach event listeners after render
attachTableListeners(container);
}
function renderBucket(label, rows) {
const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
const section = document.createElement('div');
section.className = 'bucket-section';
section.innerHTML = `
<div class="bucket-header">
<span class="bucket-label">${label}</span>
<span class="bucket-totals">${fmt(totalPaid)} / ${fmt(totalExpected)}</span>
</div>
<table class="tracker-table">
<thead>
<tr>
<th>Bill</th>
<th>Due</th>
<th>Expected</th>
<th>Amount Paid</th>
<th>Paid Date</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${rows.map(renderRow).join('')}
</tbody>
</table>
`;
return section;
}
function renderRow(row) {
const meta = STATUS_META[row.status] || STATUS_META.upcoming;
const rowCls = `row-${row.status}`;
const paidDate = row.last_paid_date ? fmtDate(row.last_paid_date) : '';
const paidAmt = row.total_paid > 0 ? fmt(row.total_paid) : '';
const mismatch = row.total_paid > 0 && row.total_paid !== row.expected_amount;
const isPaid = row.status === 'paid' || row.status === 'autodraft';
const autopayDot = row.autopay_enabled
? `<span class="autopay-dot" title="Autopay"></span>`
: '';
return `
<tr class="${rowCls}" data-bill-id="${row.id}">
<td>
<div class="td-inner bill-name-cell">
${autopayDot}
<div>
<div>${escHtml(row.name)}</div>
${row.category_name ? `<div class="bill-category">${escHtml(row.category_name)}</div>` : ''}
</div>
</div>
</td>
<td>
<div class="td-inner">${fmtDate(row.due_date)}</div>
</td>
<td>
<div class="td-inner amount-expected">${fmt(row.expected_amount)}</div>
</td>
<td>
<div class="td-inner">
<span class="editable-cell amount-cell ${paidAmt ? '' : 'empty'} ${mismatch ? 'amount-mismatch' : ''}"
data-bill-id="${row.id}" data-field="amount"
title="Click to edit payment amount">
${paidAmt || '—'}
</span>
</div>
</td>
<td>
<div class="td-inner">
<span class="editable-cell date-cell ${paidDate ? '' : 'empty'}"
data-bill-id="${row.id}" data-field="date"
title="Click to edit paid date">
${paidDate || '—'}
</span>
</div>
</td>
<td>
<div class="td-inner">
<span class="badge ${meta.cls}">${meta.label}</span>
</div>
</td>
<td>
<div class="action-cell">
${!isPaid ? `
<div class="quick-pay-group">
<input type="number" class="quick-pay-amount" min="0" step="0.01"
value="${row.expected_amount}"
data-bill-id="${row.id}"
title="Payment amount">
<button class="btn-pay btn-quick-pay" data-bill-id="${row.id}"
title="Mark paid today">Pay</button>
</div>` : ''}
${row.payments && row.payments.length > 0
? `<button class="btn-icon btn-edit-payment"
data-bill-id="${row.id}"
data-payment='${JSON.stringify(row.payments[0])}'
title="Edit payment">&#9998;</button>`
: ''}
</div>
</td>
</tr>
`;
}
function attachTableListeners(container) {
// Quick pay buttons — read amount from the sibling input
container.querySelectorAll('.btn-quick-pay').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const billId = btn.dataset.billId;
const amtInput = btn.closest('.quick-pay-group')?.querySelector('.quick-pay-amount');
const amount = amtInput ? parseFloat(amtInput.value) || 0 : 0;
if (amount <= 0) { showToast('Enter a payment amount', 'error'); return; }
btn.disabled = true;
try {
await API.quickPay({ bill_id: billId, amount, paid_date: todayStr() });
showToast('Marked as paid', 'success');
loadData(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
btn.disabled = false;
}
};
});
// Edit payment buttons
container.querySelectorAll('.btn-edit-payment').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const payment = JSON.parse(btn.dataset.payment);
openPaymentModal(payment, () => loadData(container));
};
});
// Inline editable amount cells
container.querySelectorAll('.amount-cell').forEach(cell => {
cell.onclick = () => startInlineEdit(cell, 'number', container);
});
// Inline editable date cells
container.querySelectorAll('.date-cell').forEach(cell => {
cell.onclick = () => startInlineEdit(cell, 'date', container);
});
}
function startInlineEdit(cell, type, container) {
if (cell.querySelector('input')) return; // already editing
const billId = cell.dataset.billId;
const field = cell.dataset.field;
const row = trackerData?.rows?.find(r => r.id == billId);
if (!row) return;
let currentVal = '';
if (field === 'amount') currentVal = row.total_paid > 0 ? String(row.total_paid) : '';
if (field === 'date') currentVal = row.last_paid_date || '';
const input = document.createElement('input');
input.type = type === 'date' ? 'date' : 'number';
if (type === 'number') { input.step = '0.01'; input.min = '0'; }
input.value = currentVal;
input.style.cssText = 'width:100%;min-width:80px;';
const origText = cell.textContent.trim();
cell.textContent = '';
cell.appendChild(input);
cell.classList.remove('empty');
input.focus();
input.select();
async function commit() {
const val = input.value.trim();
if (!val) { cell.textContent = origText || '—'; cell.classList.add('empty'); return; }
try {
if (row.payments && row.payments.length > 0) {
const p = row.payments[0];
const update = {};
if (field === 'amount') update.amount = parseFloat(val);
if (field === 'date') update.paid_date = val;
await API.updatePayment(p.id, update);
} else {
// Create new payment
const paidDate = field === 'date' ? val : todayStr();
const amount = field === 'amount' ? parseFloat(val) : row.expected_amount;
await API.createPayment({ bill_id: billId, amount, paid_date: paidDate });
}
showToast('Saved', 'success');
loadData(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
cell.textContent = origText || '—';
}
}
input.addEventListener('blur', commit);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') input.blur();
if (e.key === 'Escape') {
cell.textContent = origText || '—';
if (!origText) cell.classList.add('empty');
}
});
}
function openPaymentModal(payment, onSave) {
const modal = document.getElementById('payment-modal');
document.getElementById('payment-bill-id').value = payment.bill_id;
document.getElementById('payment-id').value = payment.id;
document.getElementById('payment-amount').value = payment.amount;
document.getElementById('payment-date').value = payment.paid_date;
document.getElementById('payment-method').value = payment.method || '';
document.getElementById('payment-notes').value = payment.notes || '';
document.getElementById('payment-modal-title').textContent = 'Edit Payment';
modal.classList.remove('hidden');
const close = () => modal.classList.add('hidden');
document.getElementById('payment-modal-close').onclick = close;
document.getElementById('payment-modal-cancel').onclick = close;
modal.querySelector('.modal-overlay').onclick = close;
// ✅ UPDATED DELETE LOGIC WITH CLEAR INTENT
document.getElementById('payment-delete').onclick = async () => {
const confirmDelete = confirm(
'Remove this payment?\n\n' +
'- The BILL will NOT be deleted\n' +
'- This will remove the payment record\n' +
'- The bill will become UNPAID\n\n' +
'Continue?'
);
if (!confirmDelete) return;
try {
await API.deletePayment(payment.id);
close();
showToast(
'Payment removed. Bill is now marked as unpaid.',
'success'
);
onSave(); // refresh tracker
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
};
// normal save logic untouched
document.getElementById('payment-form').onsubmit = async (e) => {
e.preventDefault();
const data = {
amount: parseFloat(document.getElementById('payment-amount').value),
paid_date: document.getElementById('payment-date').value,
method: document.getElementById('payment-method').value || null,
notes: document.getElementById('payment-notes').value || null,
};
try {
await API.updatePayment(payment.id, data);
close();
showToast('Payment saved', 'success');
onSave();
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
}
function escHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init };
})();