- New other_purchases table (date, total, notes) - /api/other CRUD endpoints - Budget stats now include other costs in cost/egg and cost/dozen math - Budget page: new Log Other Purchases form, stat cards for other costs, combined Purchase History table showing feed and other entries together Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
let feedData = [];
|
|
let otherData = [];
|
|
|
|
async function loadBudget() {
|
|
const msg = document.getElementById('msg');
|
|
try {
|
|
const [stats, purchases, otherPurchases] = await Promise.all([
|
|
API.get('/api/stats/budget'),
|
|
API.get('/api/feed'),
|
|
API.get('/api/other'),
|
|
]);
|
|
|
|
// All-time stats
|
|
document.getElementById('b-cost-total').textContent = fmtMoney(stats.total_feed_cost);
|
|
document.getElementById('b-other-total').textContent = fmtMoney(stats.total_other_cost);
|
|
document.getElementById('b-eggs-total').textContent = stats.total_eggs_alltime;
|
|
document.getElementById('b-cpe').textContent = fmtMoneyFull(stats.cost_per_egg);
|
|
document.getElementById('b-cpd').textContent = fmtMoney(stats.cost_per_dozen);
|
|
|
|
// Last 30 days
|
|
document.getElementById('b-cost-30d').textContent = fmtMoney(stats.total_feed_cost_30d);
|
|
document.getElementById('b-other-30d').textContent = fmtMoney(stats.total_other_cost_30d);
|
|
document.getElementById('b-eggs-30d').textContent = stats.total_eggs_30d;
|
|
document.getElementById('b-cpe-30d').textContent = fmtMoneyFull(stats.cost_per_egg_30d);
|
|
document.getElementById('b-cpd-30d').textContent = fmtMoney(stats.cost_per_dozen_30d);
|
|
|
|
feedData = purchases;
|
|
otherData = otherPurchases;
|
|
renderTable();
|
|
} catch (err) {
|
|
showMessage(msg, `Failed to load budget data: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function renderTable() {
|
|
const tbody = document.getElementById('purchase-body');
|
|
const tfoot = document.getElementById('purchase-foot');
|
|
|
|
const combined = [
|
|
...feedData.map(e => ({ ...e, _type: 'feed' })),
|
|
...otherData.map(e => ({ ...e, _type: 'other' })),
|
|
].sort((a, b) => {
|
|
if (b.date !== a.date) return b.date.localeCompare(a.date);
|
|
return b.created_at.localeCompare(a.created_at);
|
|
});
|
|
|
|
if (combined.length === 0) {
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No purchases logged yet.</td></tr>';
|
|
tfoot.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = combined.map(e => {
|
|
if (e._type === 'feed') {
|
|
const total = (parseFloat(e.bags) * parseFloat(e.price_per_bag)).toFixed(2);
|
|
return `
|
|
<tr data-id="${e.id}" data-type="feed">
|
|
<td>${fmtDate(e.date)}</td>
|
|
<td>Feed</td>
|
|
<td>${parseFloat(e.bags)} bags @ ${fmtMoney(e.price_per_bag)}/bag</td>
|
|
<td>${fmtMoney(total)}</td>
|
|
<td class="notes">${e.notes || ''}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-ghost btn-sm" onclick="startEditFeed(${e.id})">Edit</button>
|
|
<button class="btn btn-danger btn-sm" onclick="deleteFeed(${e.id})">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
} else {
|
|
return `
|
|
<tr data-id="${e.id}" data-type="other">
|
|
<td>${fmtDate(e.date)}</td>
|
|
<td>Other</td>
|
|
<td>—</td>
|
|
<td>${fmtMoney(e.total)}</td>
|
|
<td class="notes">${e.notes || ''}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-ghost btn-sm" onclick="startEditOther(${e.id})">Edit</button>
|
|
<button class="btn btn-danger btn-sm" onclick="deleteOther(${e.id})">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
}).join('');
|
|
|
|
const feedTotal = feedData.reduce((sum, e) => sum + parseFloat(e.bags) * parseFloat(e.price_per_bag), 0);
|
|
const otherTotal = otherData.reduce((sum, e) => sum + parseFloat(e.total), 0);
|
|
const grandTotal = feedTotal + otherTotal;
|
|
|
|
tfoot.innerHTML = `
|
|
<tr class="total-row">
|
|
<td colspan="3">Total</td>
|
|
<td>${fmtMoney(grandTotal)}</td>
|
|
<td colspan="2">${combined.length} purchase${combined.length === 1 ? '' : 's'}</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
// ── Feed edit / delete ────────────────────────────────────────────────────────
|
|
|
|
function startEditFeed(id) {
|
|
const entry = feedData.find(e => e.id === id);
|
|
const row = document.querySelector(`tr[data-id="${id}"][data-type="feed"]`);
|
|
|
|
row.innerHTML = `
|
|
<td><input type="date" value="${entry.date}"></td>
|
|
<td>Feed</td>
|
|
<td>
|
|
<input type="number" min="0.01" step="0.01" value="${parseFloat(entry.bags)}" placeholder="bags" style="width:70px;">
|
|
bags @
|
|
<input type="number" min="0.01" step="0.01" value="${parseFloat(entry.price_per_bag)}" placeholder="price" style="width:80px;">/bag
|
|
</td>
|
|
<td>—</td>
|
|
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
|
|
<td class="actions">
|
|
<button class="btn btn-primary btn-sm" onclick="saveEditFeed(${id})">Save</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
|
|
</td>
|
|
`;
|
|
}
|
|
|
|
async function saveEditFeed(id) {
|
|
const msg = document.getElementById('msg');
|
|
const row = document.querySelector(`tr[data-id="${id}"][data-type="feed"]`);
|
|
const [dateInput, bagsInput, priceInput, notesInput] = row.querySelectorAll('input');
|
|
|
|
try {
|
|
const updated = await API.put(`/api/feed/${id}`, {
|
|
date: dateInput.value,
|
|
bags: parseFloat(bagsInput.value),
|
|
price_per_bag: parseFloat(priceInput.value),
|
|
notes: notesInput.value.trim() || null,
|
|
});
|
|
feedData[feedData.findIndex(e => e.id === id)] = updated;
|
|
renderTable();
|
|
loadBudget();
|
|
showMessage(msg, 'Purchase updated.');
|
|
} catch (err) {
|
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteFeed(id) {
|
|
if (!confirm('Delete this purchase?')) return;
|
|
const msg = document.getElementById('msg');
|
|
try {
|
|
await API.del(`/api/feed/${id}`);
|
|
feedData = feedData.filter(e => e.id !== id);
|
|
renderTable();
|
|
loadBudget();
|
|
showMessage(msg, 'Purchase deleted.');
|
|
} catch (err) {
|
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Other edit / delete ───────────────────────────────────────────────────────
|
|
|
|
function startEditOther(id) {
|
|
const entry = otherData.find(e => e.id === id);
|
|
const row = document.querySelector(`tr[data-id="${id}"][data-type="other"]`);
|
|
|
|
row.innerHTML = `
|
|
<td><input type="date" value="${entry.date}"></td>
|
|
<td>Other</td>
|
|
<td>—</td>
|
|
<td><input type="number" min="0.01" step="0.01" value="${parseFloat(entry.total)}" style="width:100px;"></td>
|
|
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
|
|
<td class="actions">
|
|
<button class="btn btn-primary btn-sm" onclick="saveEditOther(${id})">Save</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
|
|
</td>
|
|
`;
|
|
}
|
|
|
|
async function saveEditOther(id) {
|
|
const msg = document.getElementById('msg');
|
|
const row = document.querySelector(`tr[data-id="${id}"][data-type="other"]`);
|
|
const [dateInput, totalInput, notesInput] = row.querySelectorAll('input');
|
|
|
|
try {
|
|
const updated = await API.put(`/api/other/${id}`, {
|
|
date: dateInput.value,
|
|
total: parseFloat(totalInput.value),
|
|
notes: notesInput.value.trim() || null,
|
|
});
|
|
otherData[otherData.findIndex(e => e.id === id)] = updated;
|
|
renderTable();
|
|
loadBudget();
|
|
showMessage(msg, 'Purchase updated.');
|
|
} catch (err) {
|
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteOther(id) {
|
|
if (!confirm('Delete this purchase?')) return;
|
|
const msg = document.getElementById('msg');
|
|
try {
|
|
await API.del(`/api/other/${id}`);
|
|
otherData = otherData.filter(e => e.id !== id);
|
|
renderTable();
|
|
loadBudget();
|
|
showMessage(msg, 'Purchase deleted.');
|
|
} catch (err) {
|
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const feedForm = document.getElementById('feed-form');
|
|
const otherForm = document.getElementById('other-form');
|
|
const msg = document.getElementById('msg');
|
|
const bagsInput = document.getElementById('bags');
|
|
const priceInput = document.getElementById('price');
|
|
const totalDisplay = document.getElementById('total-display');
|
|
|
|
setToday(document.getElementById('date'));
|
|
setToday(document.getElementById('other-date'));
|
|
|
|
// Live total calculation for feed form
|
|
function updateTotal() {
|
|
const bags = parseFloat(bagsInput.value) || 0;
|
|
const price = parseFloat(priceInput.value) || 0;
|
|
totalDisplay.value = bags && price ? fmtMoney(bags * price) : '';
|
|
}
|
|
bagsInput.addEventListener('input', updateTotal);
|
|
priceInput.addEventListener('input', updateTotal);
|
|
|
|
feedForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const data = {
|
|
date: document.getElementById('date').value,
|
|
bags: parseFloat(bagsInput.value),
|
|
price_per_bag: parseFloat(priceInput.value),
|
|
notes: document.getElementById('notes').value.trim() || null,
|
|
};
|
|
try {
|
|
await API.post('/api/feed', data);
|
|
showMessage(msg, 'Feed purchase saved!');
|
|
feedForm.reset();
|
|
totalDisplay.value = '';
|
|
setToday(document.getElementById('date'));
|
|
loadBudget();
|
|
} catch (err) {
|
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
|
}
|
|
});
|
|
|
|
otherForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const data = {
|
|
date: document.getElementById('other-date').value,
|
|
total: parseFloat(document.getElementById('other-total').value),
|
|
notes: document.getElementById('other-notes').value.trim() || null,
|
|
};
|
|
try {
|
|
await API.post('/api/other', data);
|
|
showMessage(msg, 'Purchase saved!');
|
|
otherForm.reset();
|
|
setToday(document.getElementById('other-date'));
|
|
loadBudget();
|
|
} catch (err) {
|
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
|
}
|
|
});
|
|
|
|
loadBudget();
|
|
});
|