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.
This commit is contained in:
2026-08-06 17:55:04 +07:00
parent e342cd9a91
commit f272e74b84
19 changed files with 2123 additions and 6 deletions
+32 -1
View File
@@ -10,6 +10,7 @@ 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';
@@ -24,6 +25,11 @@ export function renderTripDetail(container, ctx, id) {
_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();
@@ -48,6 +54,23 @@ export function renderTripDetail(container, ctx, id) {
}
};
// 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() {
@@ -70,12 +93,20 @@ export function renderTripDetail(container, ctx, id) {
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), renderCosts(tctx), renderChecklist(tctx)),
el(
'div',
{ class: 'detail-side' },
renderSummary(tctx),
costsSection,
renderExpenses(tctx),
renderChecklist(tctx),
),
),
);
mount(container, page);