Files
trip-plan/public/js/views/tripDetail.js
T
grabowski f272e74b84 Add daily expense tracking with sort/split and CSV export
Standalone expenses (date, description, category, amount) with the same
equal/own/payer split machinery as entry costs, merged into the costs
panel and settle-up as a single expense bucket. Self-fetching card
with per-day grouping, client-side sorting, and quick-add. CSV export
interleaves expenses with priced entries, one share column per member
(UTF-8 BOM, RFC 4180, formula-injection guard on text cells).

Also fixes trip deletion, which hit a foreign-key violation and rolled
back for any trip with checklist items.
2026-08-06 17:55:04 +07:00

297 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Orchestrates the trip page: header + calendar, then map + summary.
// Owns the shared trip context passed to the calendar, map, summary and
// day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip,
// openDay }. After any mutation, refreshTrip() re-fetches the trip and its
// derived /route data so all three panels stay in sync.
import { api } from '../api.js';
import { el, clear, mount, loading, errorBox, toast } from '../dom.js';
import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js';
import { renderCalendar } from './calendar.js';
import { renderMap } from './map.js';
import { renderSummary } from './summary.js';
import { renderCosts } from './costs.js';
import { renderExpenses } from './expenses.js';
import { renderChecklist } from './checklist.js';
import { renderCountdown } from './flipclock.js';
import { openDayEditor } from './dayEditor.js';
export function renderTripDetail(container, ctx, id) {
const tctx = {
...ctx,
tripId: id,
trip: null,
route: null,
costs: null,
_onModalRefresh: null,
};
// The mounted Costs section node, so refreshCosts() can swap it in place
// without redrawing the whole page (self-fetching cards like Expenses
// trigger this instead of tctx.refreshTrip()).
let costsSection = null;
mount(container, loading('Loading trip…'));
init();
async function load() {
const [trip, route, costs] = await Promise.all([
api.trips.get(id),
api.trips.route(id),
api.trips.costs(id),
]);
tctx.trip = trip;
tctx.route = route;
tctx.costs = costs;
}
tctx.refreshTrip = async () => {
try {
await load();
draw();
if (typeof tctx._onModalRefresh === 'function') tctx._onModalRefresh();
} catch (err) {
toast(err.message);
}
};
// Lighter-weight than refreshTrip(): re-fetches only /costs and swaps the
// Costs section in place, so a self-fetching card (Expenses) can keep the
// settle-up numbers current without re-fetching the trip/route or
// re-mounting every other panel.
tctx.refreshCosts = async () => {
try {
tctx.costs = await api.trips.costs(id);
if (costsSection && costsSection.isConnected) {
const next = renderCosts(tctx);
costsSection.replaceWith(next);
costsSection = next;
}
} catch (err) {
toast(err.message);
}
};
tctx.openDay = (date) => openDayEditor(tctx, date);
async function init() {
try {
await load();
draw();
} catch (err) {
clear(container);
if (err.status === 404) {
toast('Trip not found (or you are not a member).');
ctx.navigate('#/trips');
return;
}
mount(container, errorBox(err.message, init));
}
}
function draw() {
const page = el('div', { class: 'page' });
page.appendChild(renderHeader());
page.appendChild(renderCountdown(tctx));
page.appendChild(renderCalendar(tctx));
costsSection = renderCosts(tctx);
page.appendChild(
el(
'div',
{ class: 'detail-grid' },
renderMap(tctx),
el(
'div',
{ class: 'detail-side' },
renderSummary(tctx),
costsSection,
renderExpenses(tctx),
renderChecklist(tctx),
),
),
);
mount(container, page);
}
function renderHeader() {
const { trip, members } = tctx.trip;
const isOwner = trip.owner_id === tctx.state.user.id;
const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date);
const header = el('div', { class: 'trip-header card' });
const titleRow = el(
'div',
{ class: 'trip-header-top' },
el(
'div',
{},
el(
'a',
{ class: 'back-link', href: '#/trips' },
'← All trips',
),
el('h1', { class: 'trip-title' }, trip.name),
el(
'p',
{ class: 'trip-subtitle muted' },
'📅 ', formatRange(trip.start_date, trip.end_date),
' · ', pluralize(dayCount, 'day', 'days'),
' · ', el('span', { class: 'currency-tag' }, trip.currency || 'USD'),
),
),
el(
'div',
{ class: 'trip-header-actions' },
el('button', { class: 'btn btn-ghost', onClick: () => toggleEdit(header) }, '✎ Edit'),
isOwner
? el('button', { class: 'btn btn-danger-ghost', onClick: onDelete }, 'Delete')
: null,
),
);
const membersRow = el(
'div',
{ class: 'members-row' },
el('span', { class: 'members-label' }, 'Members:'),
...members.map((m) =>
el(
'span',
{ class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` },
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()),
m.display_name,
isOwner && m.role !== 'owner'
? el('button', {
class: 'member-remove',
title: `Remove ${m.display_name}`,
onClick: () => onRemoveMember(m),
}, '×')
: null,
),
),
);
header.appendChild(titleRow);
header.appendChild(joinCodeRow(trip, isOwner));
header.appendChild(membersRow);
return header;
}
// Share code for inviting others — display only, not a credential.
function joinCodeRow(trip, isOwner) {
const grouped = groupCode(trip.join_code, 4);
const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(grouped);
toast('Join code copied', 'success');
} catch {
toast('Copy failed — select and copy it manually');
}
});
async function onRegenerate() {
if (!window.confirm('Regenerate the join code? The old code will stop working immediately.')) return;
try {
await api.trips.regenerateJoinCode(tctx.tripId);
toast('Join code regenerated', 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
return el(
'div',
{ class: 'joincode-row' },
el('span', { class: 'members-label' }, 'Join code:'),
el('span', { class: 'joincode-chip' }, grouped),
copyBtn,
isOwner
? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate')
: null,
);
}
function toggleEdit(header) {
const existing = header.querySelector('.trip-edit');
if (existing) {
existing.remove();
return;
}
const { trip } = tctx.trip;
const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', value: trip.name });
const startInput = el('input', { class: 'input', type: 'date', value: trip.start_date });
const endInput = el('input', { class: 'input', type: 'date', value: trip.end_date });
const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: trip.currency || 'USD', 'aria-label': 'Currency code' });
const errorEl = el('p', { class: 'form-error' });
const saveBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Save changes');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const name = nameInput.value.trim();
const start_date = startInput.value;
const end_date = endInput.value;
const currency = currencyInput.value.trim().toUpperCase() || 'USD';
if (!name) return (errorEl.textContent = 'Name cannot be empty.');
if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.');
if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
try {
await api.trips.update(tctx.tripId, { name, start_date, end_date, currency });
toast('Trip updated', 'success');
await tctx.refreshTrip();
} catch (err) {
errorEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = 'Save changes';
}
}
const editBox = el(
'form',
{ class: 'trip-edit', onSubmit },
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput),
),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput),
el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput),
),
el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'),
errorEl,
el('div', { class: 'form-actions' }, saveBtn),
);
header.appendChild(editBox);
}
async function onRemoveMember(member) {
if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return;
try {
await api.trips.removeMember(tctx.tripId, member.id);
toast(`${member.display_name} removed`, 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
async function onDelete() {
const { trip } = tctx.trip;
if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;
try {
await api.trips.remove(tctx.tripId);
toast('Trip deleted', 'success');
ctx.navigate('#/trips');
} catch (err) {
toast(err.message);
}
}
}