162 lines
6.7 KiB
JavaScript
162 lines
6.7 KiB
JavaScript
/* ── Bills management page ── */
|
|
|
|
const BillsPage = (() => {
|
|
let categories = [];
|
|
|
|
const CYCLE_LABELS = {
|
|
monthly: 'Monthly', quarterly: 'Quarterly',
|
|
annually: 'Annually', irregular: 'Irregular',
|
|
};
|
|
|
|
function fmt(amount) {
|
|
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return String(str || '')
|
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
async function init(container) {
|
|
container.innerHTML = `<div class="loading">Loading...</div>`;
|
|
try {
|
|
[categories] = await Promise.all([API.categories()]);
|
|
render(container);
|
|
} catch (e) {
|
|
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
|
|
}
|
|
}
|
|
|
|
async function render(container) {
|
|
const bills = await API.allBills();
|
|
const active = bills.filter(b => b.active);
|
|
const inactive = bills.filter(b => !b.active);
|
|
|
|
container.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">Bills</h1>
|
|
<button class="btn btn-primary" id="add-bill-btn">+ Add Bill</button>
|
|
</div>
|
|
<div class="bills-grid" id="bills-list">
|
|
${active.map(b => renderCard(b)).join('')}
|
|
${inactive.length ? `
|
|
<div style="margin-top:24px">
|
|
<div class="bucket-label" style="margin-bottom:12px">INACTIVE</div>
|
|
${inactive.map(b => renderCard(b, true)).join('')}
|
|
</div>` : ''}
|
|
${bills.length === 0 ? `<div class="empty-state"><p>No bills yet. Add your first bill!</p></div>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('add-bill-btn').onclick = () => openBillModal(null, () => render(container));
|
|
|
|
container.querySelectorAll('.btn-edit-bill').forEach(btn => {
|
|
btn.onclick = async () => {
|
|
const bill = await API.bill(btn.dataset.id);
|
|
openBillModal(bill, () => render(container));
|
|
};
|
|
});
|
|
|
|
container.querySelectorAll('.btn-toggle-bill').forEach(btn => {
|
|
btn.onclick = async () => {
|
|
const active = btn.dataset.active === '1';
|
|
if (!active || confirm('Deactivate this bill? It will be hidden from the tracker.')) {
|
|
await API.updateBill(btn.dataset.id, { active: active ? 0 : 1 });
|
|
render(container);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
function renderCard(bill, inactive = false) {
|
|
const catName = categories.find(c => c.id === bill.category_id)?.name || '';
|
|
return `
|
|
<div class="bill-card ${inactive ? 'inactive' : ''}">
|
|
<div class="bill-card-info">
|
|
<div class="bill-card-name">${escHtml(bill.name)}</div>
|
|
<div class="bill-card-meta">
|
|
Day ${bill.due_day}
|
|
${catName ? ` · ${escHtml(catName)}` : ''}
|
|
· ${CYCLE_LABELS[bill.billing_cycle] || bill.billing_cycle}
|
|
${bill.autopay_enabled ? ' · <span style="color:var(--warning)">Autopay</span>' : ''}
|
|
</div>
|
|
</div>
|
|
<div class="bill-card-amount">${fmt(bill.expected_amount)}</div>
|
|
<div class="bill-card-actions">
|
|
<button class="btn btn-ghost btn-sm btn-edit-bill" data-id="${bill.id}">Edit</button>
|
|
<button class="btn btn-ghost btn-sm btn-toggle-bill"
|
|
data-id="${bill.id}" data-active="${bill.active}">
|
|
${bill.active ? 'Deactivate' : 'Activate'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function openBillModal(bill, onSave) {
|
|
const modal = document.getElementById('bill-modal');
|
|
const isNew = !bill;
|
|
|
|
document.getElementById('bill-modal-title').textContent = isNew ? 'Add Bill' : 'Edit Bill';
|
|
document.getElementById('bill-id').value = bill?.id || '';
|
|
document.getElementById('bill-name').value = bill?.name || '';
|
|
document.getElementById('bill-due-day').value = bill?.due_day || '';
|
|
document.getElementById('bill-expected').value = bill?.expected_amount || '';
|
|
document.getElementById('bill-interest-rate').value = bill?.interest_rate ?? '';
|
|
document.getElementById('bill-cycle').value = bill?.billing_cycle || 'monthly';
|
|
document.getElementById('bill-autopay').checked = !!bill?.autopay_enabled;
|
|
document.getElementById('bill-2fa').checked = !!bill?.has_2fa;
|
|
document.getElementById('bill-website').value = bill?.website || '';
|
|
document.getElementById('bill-username').value = bill?.username || '';
|
|
document.getElementById('bill-account-info').value = bill?.account_info || '';
|
|
document.getElementById('bill-notes').value = bill?.notes || '';
|
|
|
|
// Populate category select
|
|
const catSelect = document.getElementById('bill-category');
|
|
catSelect.innerHTML = '<option value="">— none —</option>' +
|
|
categories.map(c => `<option value="${c.id}" ${bill?.category_id == c.id ? 'selected' : ''}>${escHtml(c.name)}</option>`).join('');
|
|
|
|
modal.classList.remove('hidden');
|
|
|
|
const close = () => modal.classList.add('hidden');
|
|
document.getElementById('bill-modal-close').onclick = close;
|
|
document.getElementById('bill-modal-cancel').onclick = close;
|
|
modal.querySelector('.modal-overlay').onclick = close;
|
|
|
|
document.getElementById('bill-form').onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const data = {
|
|
name: document.getElementById('bill-name').value.trim(),
|
|
category_id: document.getElementById('bill-category').value || null,
|
|
due_day: parseInt(document.getElementById('bill-due-day').value, 10),
|
|
expected_amount: parseFloat(document.getElementById('bill-expected').value) || 0,
|
|
interest_rate: document.getElementById('bill-interest-rate').value === '' ? null : parseFloat(document.getElementById('bill-interest-rate').value),
|
|
billing_cycle: document.getElementById('bill-cycle').value,
|
|
autopay_enabled: document.getElementById('bill-autopay').checked,
|
|
has_2fa: document.getElementById('bill-2fa').checked,
|
|
website: document.getElementById('bill-website').value || null,
|
|
username: document.getElementById('bill-username').value || null,
|
|
account_info: document.getElementById('bill-account-info').value || null,
|
|
notes: document.getElementById('bill-notes').value || null,
|
|
};
|
|
|
|
try {
|
|
if (isNew) {
|
|
await API.createBill(data);
|
|
showToast('Bill added', 'success');
|
|
} else {
|
|
await API.updateBill(bill.id, data);
|
|
showToast('Bill updated', 'success');
|
|
}
|
|
close();
|
|
onSave();
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message, 'error');
|
|
}
|
|
};
|
|
}
|
|
|
|
return { init };
|
|
})();
|