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.
149 lines
4.5 KiB
JavaScript
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;
|
|
}
|