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.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user