// 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; }