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.
357 lines
13 KiB
JavaScript
357 lines
13 KiB
JavaScript
// 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;
|
|
}
|