425 lines
14 KiB
JavaScript
425 lines
14 KiB
JavaScript
/* ── 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">←</button>
|
||
<span class="month-label" id="month-label"></span>
|
||
<button class="btn btn-ghost btn-sm" id="next-month">→</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('1st–14th', first));
|
||
if (second.length) body.appendChild(renderBucket('15th–31st', 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">✎</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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
return { init };
|
||
})();
|