Files
trip-plan/public/js/views/costs.js
T
grabowski fe89bb2b1c Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
2026-07-18 23:15:29 +07:00

149 lines
4.5 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';
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 = typeInfo(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;
}