Files
trip-plan/public/js/views/costs.js
T
grabowski f272e74b84 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.
2026-08-06 17:55:04 +07:00

158 lines
4.9 KiB
JavaScript

// Costs & splitting panel from GET /api/trips/:id/costs.
// Total + currency, breakdown by entry type, a per-user share/paid/net table
// (net colored green when owed to them, red when they owe), a settle-up list,
// and a hint when some priced entries have no payer assigned.
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';
const total = costs.totalCost || 0;
const perUser = costs.perUser || [];
const byType = costs.byType || {};
const settlements = costs.settlements || [];
const unassigned = costs.unassigned || 0;
const money = (n) => formatMoney(n, currency);
const nameById = new Map(perUser.map((u) => [u.userId, u.displayName]));
const memberName = (id) => {
if (nameById.has(id)) return nameById.get(id);
const m = (tctx.trip.members || []).find((x) => x.id === id);
return m ? m.display_name : `user ${id}`;
};
const section = el('section', { class: 'card costs-section' });
section.appendChild(
el(
'div',
{ class: 'section-head' },
el('h2', {}, 'Costs'),
el('p', { class: 'muted' }, 'Who owes whom, split across the trip.'),
),
);
const hasCosts = total > 0 || perUser.some((u) => u.share || u.paid);
if (!hasCosts) {
section.appendChild(
el(
'div',
{ class: 'costs-empty' },
el('div', { class: 'empty-icon' }, '💰'),
el('p', {}, 'No costs tracked yet.'),
el('p', { class: 'muted' }, 'Add a price to an entry to start splitting expenses.'),
),
);
return section;
}
// Total.
section.appendChild(
el(
'div',
{ class: 'costs-total' },
el('span', { class: 'costs-total-value' }, money(total)),
el('span', { class: 'costs-total-label' }, 'total trip cost'),
),
);
if (unassigned > 0) {
section.appendChild(
el(
'div',
{ class: 'costs-warn' },
`⚠ ${money(unassigned)} of priced entries have no payer assigned — assign payers to settle them.`,
),
);
}
// Breakdown by type.
const typeKeys = Object.keys(byType);
if (typeKeys.length) {
const bt = el('div', { class: 'cost-block' }, el('h3', {}, 'By type'));
for (const type of typeKeys) {
const info = byTypeInfo(type);
bt.appendChild(
el(
'div',
{ class: 'bytype-row' },
el('span', { class: 'bytype-dot', style: { background: info.color } }),
el('span', { class: 'bytype-name' }, `${info.icon} ${info.label}`),
el('span', { class: 'bytype-amount' }, money(byType[type])),
),
);
}
section.appendChild(bt);
}
// Per-user table.
if (perUser.length) {
const table = el(
'table',
{ class: 'cost-table' },
el(
'thead',
{},
el(
'tr',
{},
el('th', {}, 'Member'),
el('th', { class: 'num' }, 'Share'),
el('th', { class: 'num' }, 'Paid'),
el('th', { class: 'num' }, 'Net'),
),
),
el(
'tbody',
{},
...perUser.map((u) => {
const net = u.net || 0;
const netClass = net > 0.004 ? 'net-pos' : net < -0.004 ? 'net-neg' : 'net-zero';
const netText = `${net > 0 ? '+' : ''}${money(net)}`;
return el(
'tr',
{},
el('td', {}, u.displayName),
el('td', { class: 'num' }, money(u.share || 0)),
el('td', { class: 'num' }, money(u.paid || 0)),
el('td', { class: `num ${netClass}` }, netText),
);
}),
),
);
section.appendChild(el('div', { class: 'cost-block' }, el('h3', {}, 'Per person'), table));
}
// Settle-up list.
const settleBlock = el('div', { class: 'cost-block' }, el('h3', {}, 'Settle up'));
if (!settlements.length) {
settleBlock.appendChild(el('p', { class: 'muted' }, 'All square — no transfers needed.'));
} else {
for (const s of settlements) {
settleBlock.appendChild(
el(
'div',
{ class: 'settle-row' },
el('span', { class: 'settle-from' }, memberName(s.fromUserId)),
el('span', { class: 'settle-arrow' }, '→'),
el('span', { class: 'settle-to' }, memberName(s.toUserId)),
el('span', { class: 'settle-amount' }, money(s.amount)),
),
);
}
}
section.appendChild(settleBlock);
return section;
}