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