Add daily expense tracking with sort/split and CSV export

Standalone expenses (date, description, category, amount) with the same
equal/own/payer split machinery as entry costs, merged into the costs
panel and settle-up as a single expense bucket. Self-fetching card
with per-day grouping, client-side sorting, and quick-add. CSV export
interleaves expenses with priced entries, one share column per member
(UTF-8 BOM, RFC 4180, formula-injection guard on text cells).

Also fixes trip deletion, which hit a foreign-key violation and rolled
back for any trip with checklist items.
This commit is contained in:
2026-08-06 17:55:04 +07:00
parent e342cd9a91
commit f272e74b84
19 changed files with 2123 additions and 6 deletions
+356
View File
@@ -0,0 +1,356 @@
// Expenses card for the trip detail side column (below Costs, above
// Checklist). Self-fetches via GET /api/trips/:id/expenses and re-renders
// itself in place after every mutation — it never triggers tctx.refreshTrip().
// After add/edit/delete it re-fetches its own data AND calls
// tctx.refreshCosts() so the Costs panel (which merges expenses into
// settle-up) stays in sync without a full trip refresh.
import { api } from '../api.js';
import { el, mount, loading, errorBox, emptyState, toast } from '../dom.js';
import {
EXPENSE_CATEGORY_LIST, expenseCategoryInfo, SPLIT_MODES, splitModeLabel,
formatMoney, formatDate, ymd,
} from '../format.js';
export function renderExpenses(tctx) {
const state = { expenses: [], summary: { total: 0, byCategory: {}, byDay: [] }, sort: 'date-asc', editing: null };
const section = el('section', { class: 'card expenses-section' });
mount(section, loading('Loading expenses…'));
reload();
async function reload() {
try {
const data = await api.expenses.list(tctx.tripId);
state.expenses = data.expenses || [];
state.summary = data.summary || { total: 0, byCategory: {}, byDay: [] };
draw();
} catch (err) {
mount(section, errorBox(err.message, reload));
}
}
// Re-fetch this card's own data and nudge the Costs panel — never the
// whole trip.
async function afterMutation() {
await reload();
if (typeof tctx.refreshCosts === 'function') await tctx.refreshCosts();
}
function members() {
return tctx.trip.members || [];
}
function currency() {
return (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
}
function memberName(id) {
if (id == null) return null;
const m = members().find((x) => x.id === id);
return m ? m.display_name : null;
}
function isDateSort() {
return state.sort === 'date-asc' || state.sort === 'date-desc';
}
function sortedRows() {
const rows = [...state.expenses];
switch (state.sort) {
case 'date-desc':
rows.sort((a, b) => (a.date === b.date ? b.id - a.id : b.date.localeCompare(a.date)));
break;
case 'amount-asc':
rows.sort((a, b) => a.amount - b.amount);
break;
case 'amount-desc':
rows.sort((a, b) => b.amount - a.amount);
break;
case 'category':
rows.sort((a, b) => a.category.localeCompare(b.category) || a.date.localeCompare(b.date));
break;
case 'payer':
rows.sort((a, b) =>
(memberName(a.paid_by) || '').localeCompare(memberName(b.paid_by) || '') || a.date.localeCompare(b.date));
break;
default: // date-asc
rows.sort((a, b) => (a.date === b.date ? a.id - b.id : a.date.localeCompare(b.date)));
}
return rows;
}
function draw() {
const head = el(
'div',
{ class: 'section-head cal-section-head' },
el(
'div',
{},
el('h2', {}, 'Expenses'),
el('p', { class: 'muted' }, 'Quick daily spending, split like any other cost.'),
),
el(
'div',
{ class: 'expenses-actions' },
sortSelect(),
el('a', {
class: 'btn btn-sm btn-ghost',
href: `/api/trips/${tctx.tripId}/expenses/export.csv`,
}, '⬇ Export CSV'),
),
);
const body = [];
if (!state.expenses.length) {
body.push(emptyState('No expenses logged yet', 'Add one below — lunch, taxi, tickets, whatever.'));
} else {
body.push(
el(
'div',
{ class: 'costs-total expenses-total' },
el('span', { class: 'costs-total-value' }, formatMoney(state.summary.total, currency())),
el('span', { class: 'costs-total-label' }, 'total spent'),
),
);
const rows = sortedRows();
body.push(isDateSort() ? groupedByDay(rows) : flatList(rows));
}
mount(section, head, ...body, formSection());
}
function sortSelect() {
const options = [
['date-asc', 'Date ↑'],
['date-desc', 'Date ↓'],
['amount-asc', 'Amount ↑'],
['amount-desc', 'Amount ↓'],
['category', 'Category'],
['payer', 'Payer'],
];
const select = el(
'select',
{
class: 'input input-sm expenses-sort',
'aria-label': 'Sort expenses',
onChange: (e) => { state.sort = e.target.value; draw(); },
},
...options.map(([value, label]) => el('option', { value }, label)),
);
select.value = state.sort;
return select;
}
function groupedByDay(rows) {
const dayTotals = new Map((state.summary.byDay || []).map((d) => [d.date, d.total]));
const groups = new Map();
for (const exp of rows) {
if (!groups.has(exp.date)) groups.set(exp.date, []);
groups.get(exp.date).push(exp);
}
const wrap = el('div', { class: 'expenses-days' });
for (const [date, items] of groups) {
wrap.appendChild(
el(
'div',
{ class: 'expenses-day' },
el(
'div',
{ class: 'expenses-day-head' },
el('h3', {}, formatDate(date, { weekday: 'short', month: 'short', day: 'numeric' })),
el('span', { class: 'expenses-day-total' }, formatMoney(dayTotals.get(date) || 0, currency(), { compact: true })),
),
el('div', { class: 'entry-list' }, ...items.map((exp) => expenseRow(exp, false))),
),
);
}
return wrap;
}
function flatList(rows) {
return el('div', { class: 'entry-list expenses-flat' }, ...rows.map((exp) => expenseRow(exp, true)));
}
function expenseRow(exp, showDate) {
const info = expenseCategoryInfo(exp.category);
const payerName = memberName(exp.paid_by);
return el(
'div',
{ class: 'entry-row', style: { '--chip': info.color } },
el('span', { class: 'entry-icon' }, info.icon),
el(
'div',
{ class: 'entry-body' },
el(
'div',
{ class: 'entry-title-row' },
el('span', { class: 'entry-title' }, exp.description),
el('span', { class: 'entry-price' }, formatMoney(exp.amount, currency(), { compact: true })),
),
el(
'div',
{ class: 'entry-sub muted' },
info.label,
showDate ? ` · ${formatDate(exp.date)}` : '',
` · ${splitModeLabel(exp.split_mode)}`,
payerName ? ` · paid by ${payerName}` : ' · no payer set',
),
),
el(
'div',
{ class: 'entry-actions' },
el('button', { class: 'icon-btn', type: 'button', title: 'Edit', onClick: () => startEdit(exp) }, '✎'),
el('button', { class: 'icon-btn danger', type: 'button', title: 'Delete', onClick: () => onDelete(exp) }, '🗑'),
),
);
}
function startEdit(exp) {
state.editing = exp;
draw();
const formEl = section.querySelector('.expenses-form');
if (formEl) formEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
async function onDelete(exp) {
if (!window.confirm(`Delete "${exp.description}"?`)) return;
try {
await api.expenses.remove(exp.id);
toast('Expense deleted', 'success');
if (state.editing && state.editing.id === exp.id) state.editing = null;
await afterMutation();
} catch (err) {
toast(err.message);
}
}
// Today clamped into the trip's date range, for the quick-add default.
function defaultDate() {
const today = ymd(new Date());
const { start_date, end_date } = tctx.trip.trip;
if (today < start_date) return start_date;
if (today > end_date) return end_date;
return today;
}
function formSection() {
const editing = state.editing;
const mem = members();
const { start_date, end_date } = tctx.trip.trip;
const dateInput = el('input', {
class: 'input', type: 'date', min: start_date, max: end_date,
value: editing ? editing.date : defaultDate(),
});
const descInput = el('input', {
class: 'input field-grow', type: 'text', maxlength: '120', placeholder: 'Description',
value: editing ? editing.description : '',
});
const amountInput = el('input', {
class: 'input', type: 'number', min: '0', step: '0.01', placeholder: '0.00',
value: editing ? String(editing.amount) : '',
});
const categorySelect = el(
'select',
{ class: 'input' },
...EXPENSE_CATEGORY_LIST.map((c) => el('option', { value: c.value }, `${c.icon} ${c.label}`)),
);
categorySelect.value = editing ? editing.category : 'other';
const payerSelect = el(
'select',
{ class: 'input' },
el('option', { value: '' }, '— unassigned —'),
...mem.map((m) => el('option', { value: String(m.id) }, m.display_name)),
);
payerSelect.value = editing && editing.paid_by != null ? String(editing.paid_by) : '';
const modeSelect = el(
'select',
{ class: 'input' },
...SPLIT_MODES.map((m) => el('option', { value: m.value }, m.label)),
);
modeSelect.value = editing ? editing.split_mode : 'equal';
const editingParticipants = editing && Array.isArray(editing.participants) ? editing.participants : [];
const participantChecks = mem.map((m) =>
el('input', {
type: 'checkbox', class: 'part-check', value: String(m.id),
checked: editing ? (editingParticipants.length === 0 || editingParticipants.includes(m.id)) : true,
}),
);
const participantsBox = el(
'div',
{ class: 'participants' },
...mem.map((m, i) => el('label', { class: 'part-item' }, participantChecks[i], el('span', {}, m.display_name))),
);
const errorEl = el('p', { class: 'form-error' });
const submitBtn = el('button', { class: 'btn btn-primary btn-sm', type: 'submit' }, editing ? 'Save' : '+ Add');
const cancelBtn = editing
? el('button', {
class: 'btn btn-ghost btn-sm', type: 'button',
onClick: () => { state.editing = null; draw(); },
}, 'Cancel')
: null;
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const date = dateInput.value;
if (!date) return (errorEl.textContent = 'Date is required.');
const description = descInput.value.trim();
if (!description) return (errorEl.textContent = 'Description is required.');
if (description.length > 120) return (errorEl.textContent = 'Keep it under 120 characters.');
const amountRaw = amountInput.value.trim();
const amount = Number(amountRaw);
if (amountRaw === '' || !Number.isFinite(amount) || amount < 0) {
return (errorEl.textContent = 'Amount must be a number ≥ 0.');
}
const split_mode = modeSelect.value;
const paid_by = payerSelect.value ? Number(payerSelect.value) : null;
if (split_mode === 'payer' && paid_by == null) {
return (errorEl.textContent = "Choose who paid for a payer's own expense.");
}
const checked = participantChecks.filter((c) => c.checked).map((c) => Number(c.value));
if (checked.length === 0) return (errorEl.textContent = 'Select at least one participant.');
const participants = checked.length === mem.length ? [] : checked;
const payload = {
date, description, amount, category: categorySelect.value, paid_by, split_mode, participants,
};
submitBtn.disabled = true;
try {
if (editing) await api.expenses.update(editing.id, payload);
else await api.expenses.create(tctx.tripId, payload);
toast(editing ? 'Expense updated' : 'Expense added', 'success');
state.editing = null;
await afterMutation();
} catch (err) {
errorEl.textContent = err.message;
submitBtn.disabled = false;
}
}
return el(
'form',
{ class: 'expenses-form', onSubmit },
el('h3', {}, editing ? 'Edit expense' : 'Add expense'),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Date'), dateInput),
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Description'), descInput),
el('label', { class: 'field field-price' }, el('span', { class: 'field-label' }, `Amount (${currency()})`), amountInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Category'), categorySelect),
),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Paid by'), payerSelect),
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Split'), modeSelect),
),
el('div', { class: 'field' }, el('span', { class: 'field-label' }, 'Participants'), participantsBox),
errorEl,
el('div', { class: 'form-actions' }, cancelBtn, submitBtn),
);
}
return section;
}