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.
158 lines
4.9 KiB
JavaScript
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;
|
|
}
|