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:
@@ -5,6 +5,15 @@
|
||||
import { el } from '../dom.js';
|
||||
import { typeInfo, formatMoney } from '../format.js';
|
||||
|
||||
// byType's "expense" key isn't an entry type (typeInfo only knows
|
||||
// activity/stay/transport/flight/rental/note), so it'd otherwise fall back to
|
||||
// a bare bullet + the raw key. Give it its own icon/label to match the
|
||||
// Expenses card.
|
||||
function byTypeInfo(type) {
|
||||
if (type === 'expense') return { icon: '💸', label: 'Expenses', color: '#64748b' };
|
||||
return typeInfo(type);
|
||||
}
|
||||
|
||||
export function renderCosts(tctx) {
|
||||
const costs = tctx.costs || {};
|
||||
const currency = costs.currency || (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||
@@ -72,7 +81,7 @@ export function renderCosts(tctx) {
|
||||
if (typeKeys.length) {
|
||||
const bt = el('div', { class: 'cost-block' }, el('h3', {}, 'By type'));
|
||||
for (const type of typeKeys) {
|
||||
const info = typeInfo(type);
|
||||
const info = byTypeInfo(type);
|
||||
bt.appendChild(
|
||||
el(
|
||||
'div',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { renderCalendar } from './calendar.js';
|
||||
import { renderMap } from './map.js';
|
||||
import { renderSummary } from './summary.js';
|
||||
import { renderCosts } from './costs.js';
|
||||
import { renderExpenses } from './expenses.js';
|
||||
import { renderChecklist } from './checklist.js';
|
||||
import { renderCountdown } from './flipclock.js';
|
||||
import { openDayEditor } from './dayEditor.js';
|
||||
@@ -24,6 +25,11 @@ export function renderTripDetail(container, ctx, id) {
|
||||
_onModalRefresh: null,
|
||||
};
|
||||
|
||||
// The mounted Costs section node, so refreshCosts() can swap it in place
|
||||
// without redrawing the whole page (self-fetching cards like Expenses
|
||||
// trigger this instead of tctx.refreshTrip()).
|
||||
let costsSection = null;
|
||||
|
||||
mount(container, loading('Loading trip…'));
|
||||
init();
|
||||
|
||||
@@ -48,6 +54,23 @@ export function renderTripDetail(container, ctx, id) {
|
||||
}
|
||||
};
|
||||
|
||||
// Lighter-weight than refreshTrip(): re-fetches only /costs and swaps the
|
||||
// Costs section in place, so a self-fetching card (Expenses) can keep the
|
||||
// settle-up numbers current without re-fetching the trip/route or
|
||||
// re-mounting every other panel.
|
||||
tctx.refreshCosts = async () => {
|
||||
try {
|
||||
tctx.costs = await api.trips.costs(id);
|
||||
if (costsSection && costsSection.isConnected) {
|
||||
const next = renderCosts(tctx);
|
||||
costsSection.replaceWith(next);
|
||||
costsSection = next;
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
tctx.openDay = (date) => openDayEditor(tctx, date);
|
||||
|
||||
async function init() {
|
||||
@@ -70,12 +93,20 @@ export function renderTripDetail(container, ctx, id) {
|
||||
page.appendChild(renderHeader());
|
||||
page.appendChild(renderCountdown(tctx));
|
||||
page.appendChild(renderCalendar(tctx));
|
||||
costsSection = renderCosts(tctx);
|
||||
page.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'detail-grid' },
|
||||
renderMap(tctx),
|
||||
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx), renderChecklist(tctx)),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'detail-side' },
|
||||
renderSummary(tctx),
|
||||
costsSection,
|
||||
renderExpenses(tctx),
|
||||
renderChecklist(tctx),
|
||||
),
|
||||
),
|
||||
);
|
||||
mount(container, page);
|
||||
|
||||
Reference in New Issue
Block a user