BillTracker/legacy/js/tracker.js

425 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ── 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 };
})();