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.
87 lines
3.6 KiB
JavaScript
87 lines
3.6 KiB
JavaScript
// Cost & splitting subsection for the day editor. Self-contained so dayEditor
|
|
// stays small. read() returns the cost fields for the entry payload:
|
|
// { price: null } — no cost
|
|
// { price, paid_by, split_mode, participants } — cost set
|
|
// { error } — validation message
|
|
import { el } from '../dom.js';
|
|
import { SPLIT_MODES } from '../format.js';
|
|
|
|
export function createCostForm({ members, currency }) {
|
|
const priceInput = el('input', { class: 'input', type: 'number', min: '0', step: '0.01', placeholder: '0.00' });
|
|
const payerSelect = el(
|
|
'select',
|
|
{ class: 'input' },
|
|
el('option', { value: '' }, '— unassigned —'),
|
|
...members.map((m) => el('option', { value: String(m.id) }, m.display_name)),
|
|
);
|
|
const modeSelect = el(
|
|
'select',
|
|
{ class: 'input' },
|
|
...SPLIT_MODES.map((m) => el('option', { value: m.value }, m.label)),
|
|
);
|
|
const participantChecks = members.map((m) =>
|
|
el('input', { type: 'checkbox', class: 'part-check', value: String(m.id), checked: true }),
|
|
);
|
|
const participantsBox = el(
|
|
'div',
|
|
{ class: 'participants' },
|
|
...members.map((m, i) =>
|
|
el('label', { class: 'part-item' }, participantChecks[i], el('span', {}, m.display_name)),
|
|
),
|
|
);
|
|
const costDetail = el(
|
|
'div',
|
|
{ class: 'cost-detail' },
|
|
el('div', { class: 'form-row' },
|
|
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Paid by'), payerSelect),
|
|
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Split'), modeSelect)),
|
|
el('div', { class: 'field' }, el('span', { class: 'field-label' }, 'Participants'), participantsBox),
|
|
);
|
|
const node = el(
|
|
'div',
|
|
{ class: 'cost-section' },
|
|
el('div', { class: 'form-row' },
|
|
el('label', { class: 'field field-price' }, el('span', { class: 'field-label' }, `Price (${currency})`), priceInput)),
|
|
costDetail,
|
|
);
|
|
|
|
// Dim the payer/split/participants controls until a price is entered.
|
|
function syncVisibility() {
|
|
costDetail.classList.toggle('disabled', priceInput.value.trim() === '');
|
|
}
|
|
priceInput.addEventListener('input', syncVisibility);
|
|
|
|
function read() {
|
|
const priceRaw = priceInput.value.trim();
|
|
if (priceRaw === '') return { price: null };
|
|
const price = Number(priceRaw);
|
|
if (!Number.isFinite(price) || price < 0) return { error: 'Price must be a number ≥ 0.' };
|
|
const split_mode = modeSelect.value;
|
|
const paid_by = payerSelect.value ? Number(payerSelect.value) : null;
|
|
if (split_mode === 'payer' && paid_by == null) {
|
|
return { error: "Choose who paid for a payer's own expense." };
|
|
}
|
|
const checked = participantChecks.filter((c) => c.checked).map((c) => Number(c.value));
|
|
if (checked.length === 0) {
|
|
return { error: 'Select at least one participant (or clear the price to drop the cost).' };
|
|
}
|
|
// [] means "all trip members"; only send an explicit list for a subset.
|
|
const participants = checked.length === members.length ? [] : checked;
|
|
return { price, paid_by, split_mode, participants };
|
|
}
|
|
|
|
function prefill(entry) {
|
|
priceInput.value = entry.price != null ? String(entry.price) : '';
|
|
payerSelect.value = entry.paid_by != null ? String(entry.paid_by) : '';
|
|
modeSelect.value = entry.split_mode || 'equal';
|
|
const parts = Array.isArray(entry.participants) ? entry.participants : [];
|
|
for (const cb of participantChecks) {
|
|
cb.checked = parts.length === 0 || parts.includes(Number(cb.value));
|
|
}
|
|
syncVisibility();
|
|
}
|
|
|
|
syncVisibility();
|
|
return { node, read, prefill };
|
|
}
|