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:
@@ -0,0 +1,27 @@
|
||||
/* Expenses (daily spending log) card. Split out of styles.css — which is at
|
||||
its 500-line cap — following the same per-feature stylesheet convention as
|
||||
checklist.css. Reuses the shared palette/vocabulary rather than
|
||||
re-declaring it: rows are `.entry-row`/`.entry-icon`/`.entry-body`/
|
||||
`.entry-title-row`/`.entry-title`/`.entry-price`/`.entry-sub`/
|
||||
`.entry-actions` (day editor), the total banner is `.costs-total` (costs
|
||||
panel), and the add/edit form reuses `.cost-section`-era `.participants`/
|
||||
`.part-item`/`.part-check` plus the generic `.form-row`/`.form-actions`. */
|
||||
|
||||
.expenses-section { display: flex; flex-direction: column; }
|
||||
.expenses-actions { display: flex; gap: 0.4rem; align-items: center; flex-shrink: 0; }
|
||||
.expenses-actions .btn { white-space: nowrap; }
|
||||
.expenses-sort { min-width: 8.5rem; }
|
||||
|
||||
.expenses-total { margin-bottom: 1rem; }
|
||||
|
||||
.expenses-days { display: flex; flex-direction: column; gap: 1.1rem; }
|
||||
.expenses-day-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.expenses-day-total { font-size: 0.78rem; font-weight: 700; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); padding: 0.1rem 0.5rem; border-radius: 999px; }
|
||||
|
||||
.expenses-flat { margin-top: 0.2rem; }
|
||||
|
||||
.expenses-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 1.1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.expenses-actions { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<link rel="stylesheet" href="./css/styles.css" />
|
||||
<link rel="stylesheet" href="./css/flipclock.css" />
|
||||
<link rel="stylesheet" href="./css/checklist.css" />
|
||||
<link rel="stylesheet" href="./css/expenses.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -94,6 +94,12 @@ export const api = {
|
||||
addSuggestions: (tripId, keys, personal) =>
|
||||
post(`/api/trips/${tripId}/checklist/suggestions`, personal === undefined ? { keys } : { keys, personal }),
|
||||
},
|
||||
expenses: {
|
||||
list: (tripId) => get(`/api/trips/${tripId}/expenses`),
|
||||
create: (tripId, payload) => post(`/api/trips/${tripId}/expenses`, payload),
|
||||
update: (expenseId, patchBody) => patch(`/api/expenses/${expenseId}`, patchBody),
|
||||
remove: (expenseId) => del(`/api/expenses/${expenseId}`),
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -43,6 +43,28 @@ export function entryIcon(entry) {
|
||||
return typeInfo(entry && entry.type).icon;
|
||||
}
|
||||
|
||||
// Expense categories (daily spending log) — fixed enum, see docs/API.md.
|
||||
// Display metadata lives here per the API contract ("Display metadata (icon +
|
||||
// label + colour) lives in the frontend ... not the API").
|
||||
export const EXPENSE_CATEGORIES = {
|
||||
food: { label: 'Food', icon: '🍽️', color: '#059669' },
|
||||
drinks: { label: 'Drinks', icon: '🍹', color: '#0891b2' },
|
||||
transport: { label: 'Transport', icon: '🚕', color: '#d97706' },
|
||||
activities: { label: 'Activities', icon: '🎟️', color: '#2563eb' },
|
||||
shopping: { label: 'Shopping', icon: '🛍️', color: '#db2777' },
|
||||
accommodation: { label: 'Accommodation', icon: '🏨', color: '#f59e0b' },
|
||||
other: { label: 'Other', icon: '💸', color: '#64748b' },
|
||||
};
|
||||
|
||||
export const EXPENSE_CATEGORY_LIST = Object.entries(EXPENSE_CATEGORIES).map(([value, meta]) => ({
|
||||
value,
|
||||
...meta,
|
||||
}));
|
||||
|
||||
export function expenseCategoryInfo(category) {
|
||||
return EXPENSE_CATEGORIES[category] || EXPENSE_CATEGORIES.other;
|
||||
}
|
||||
|
||||
// Split modes with the human labels the day-editor select shows.
|
||||
export const SPLIT_MODES = [
|
||||
{ value: 'equal', label: 'Split equally' },
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
import { el } from '../dom.js';
|
||||
import { typeInfo, formatMoney } from '../format.js';
|
||||
|
||||
// byType's "expense" key isn't an entry type (typeInfo only knows
|
||||
// activity/stay/transport/flight/rental/note), so it'd otherwise fall back to
|
||||
// a bare bullet + the raw key. Give it its own icon/label to match the
|
||||
// Expenses card.
|
||||
function byTypeInfo(type) {
|
||||
if (type === 'expense') return { icon: '💸', label: 'Expenses', color: '#64748b' };
|
||||
return typeInfo(type);
|
||||
}
|
||||
|
||||
export function renderCosts(tctx) {
|
||||
const costs = tctx.costs || {};
|
||||
const currency = costs.currency || (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||
@@ -72,7 +81,7 @@ export function renderCosts(tctx) {
|
||||
if (typeKeys.length) {
|
||||
const bt = el('div', { class: 'cost-block' }, el('h3', {}, 'By type'));
|
||||
for (const type of typeKeys) {
|
||||
const info = typeInfo(type);
|
||||
const info = byTypeInfo(type);
|
||||
bt.appendChild(
|
||||
el(
|
||||
'div',
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// Expenses card for the trip detail side column (below Costs, above
|
||||
// Checklist). Self-fetches via GET /api/trips/:id/expenses and re-renders
|
||||
// itself in place after every mutation — it never triggers tctx.refreshTrip().
|
||||
// After add/edit/delete it re-fetches its own data AND calls
|
||||
// tctx.refreshCosts() so the Costs panel (which merges expenses into
|
||||
// settle-up) stays in sync without a full trip refresh.
|
||||
import { api } from '../api.js';
|
||||
import { el, mount, loading, errorBox, emptyState, toast } from '../dom.js';
|
||||
import {
|
||||
EXPENSE_CATEGORY_LIST, expenseCategoryInfo, SPLIT_MODES, splitModeLabel,
|
||||
formatMoney, formatDate, ymd,
|
||||
} from '../format.js';
|
||||
|
||||
export function renderExpenses(tctx) {
|
||||
const state = { expenses: [], summary: { total: 0, byCategory: {}, byDay: [] }, sort: 'date-asc', editing: null };
|
||||
const section = el('section', { class: 'card expenses-section' });
|
||||
|
||||
mount(section, loading('Loading expenses…'));
|
||||
reload();
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const data = await api.expenses.list(tctx.tripId);
|
||||
state.expenses = data.expenses || [];
|
||||
state.summary = data.summary || { total: 0, byCategory: {}, byDay: [] };
|
||||
draw();
|
||||
} catch (err) {
|
||||
mount(section, errorBox(err.message, reload));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch this card's own data and nudge the Costs panel — never the
|
||||
// whole trip.
|
||||
async function afterMutation() {
|
||||
await reload();
|
||||
if (typeof tctx.refreshCosts === 'function') await tctx.refreshCosts();
|
||||
}
|
||||
|
||||
function members() {
|
||||
return tctx.trip.members || [];
|
||||
}
|
||||
function currency() {
|
||||
return (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||
}
|
||||
function memberName(id) {
|
||||
if (id == null) return null;
|
||||
const m = members().find((x) => x.id === id);
|
||||
return m ? m.display_name : null;
|
||||
}
|
||||
|
||||
function isDateSort() {
|
||||
return state.sort === 'date-asc' || state.sort === 'date-desc';
|
||||
}
|
||||
|
||||
function sortedRows() {
|
||||
const rows = [...state.expenses];
|
||||
switch (state.sort) {
|
||||
case 'date-desc':
|
||||
rows.sort((a, b) => (a.date === b.date ? b.id - a.id : b.date.localeCompare(a.date)));
|
||||
break;
|
||||
case 'amount-asc':
|
||||
rows.sort((a, b) => a.amount - b.amount);
|
||||
break;
|
||||
case 'amount-desc':
|
||||
rows.sort((a, b) => b.amount - a.amount);
|
||||
break;
|
||||
case 'category':
|
||||
rows.sort((a, b) => a.category.localeCompare(b.category) || a.date.localeCompare(b.date));
|
||||
break;
|
||||
case 'payer':
|
||||
rows.sort((a, b) =>
|
||||
(memberName(a.paid_by) || '').localeCompare(memberName(b.paid_by) || '') || a.date.localeCompare(b.date));
|
||||
break;
|
||||
default: // date-asc
|
||||
rows.sort((a, b) => (a.date === b.date ? a.id - b.id : a.date.localeCompare(b.date)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const head = el(
|
||||
'div',
|
||||
{ class: 'section-head cal-section-head' },
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('h2', {}, 'Expenses'),
|
||||
el('p', { class: 'muted' }, 'Quick daily spending, split like any other cost.'),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'expenses-actions' },
|
||||
sortSelect(),
|
||||
el('a', {
|
||||
class: 'btn btn-sm btn-ghost',
|
||||
href: `/api/trips/${tctx.tripId}/expenses/export.csv`,
|
||||
}, '⬇ Export CSV'),
|
||||
),
|
||||
);
|
||||
|
||||
const body = [];
|
||||
if (!state.expenses.length) {
|
||||
body.push(emptyState('No expenses logged yet', 'Add one below — lunch, taxi, tickets, whatever.'));
|
||||
} else {
|
||||
body.push(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'costs-total expenses-total' },
|
||||
el('span', { class: 'costs-total-value' }, formatMoney(state.summary.total, currency())),
|
||||
el('span', { class: 'costs-total-label' }, 'total spent'),
|
||||
),
|
||||
);
|
||||
const rows = sortedRows();
|
||||
body.push(isDateSort() ? groupedByDay(rows) : flatList(rows));
|
||||
}
|
||||
|
||||
mount(section, head, ...body, formSection());
|
||||
}
|
||||
|
||||
function sortSelect() {
|
||||
const options = [
|
||||
['date-asc', 'Date ↑'],
|
||||
['date-desc', 'Date ↓'],
|
||||
['amount-asc', 'Amount ↑'],
|
||||
['amount-desc', 'Amount ↓'],
|
||||
['category', 'Category'],
|
||||
['payer', 'Payer'],
|
||||
];
|
||||
const select = el(
|
||||
'select',
|
||||
{
|
||||
class: 'input input-sm expenses-sort',
|
||||
'aria-label': 'Sort expenses',
|
||||
onChange: (e) => { state.sort = e.target.value; draw(); },
|
||||
},
|
||||
...options.map(([value, label]) => el('option', { value }, label)),
|
||||
);
|
||||
select.value = state.sort;
|
||||
return select;
|
||||
}
|
||||
|
||||
function groupedByDay(rows) {
|
||||
const dayTotals = new Map((state.summary.byDay || []).map((d) => [d.date, d.total]));
|
||||
const groups = new Map();
|
||||
for (const exp of rows) {
|
||||
if (!groups.has(exp.date)) groups.set(exp.date, []);
|
||||
groups.get(exp.date).push(exp);
|
||||
}
|
||||
const wrap = el('div', { class: 'expenses-days' });
|
||||
for (const [date, items] of groups) {
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'expenses-day' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'expenses-day-head' },
|
||||
el('h3', {}, formatDate(date, { weekday: 'short', month: 'short', day: 'numeric' })),
|
||||
el('span', { class: 'expenses-day-total' }, formatMoney(dayTotals.get(date) || 0, currency(), { compact: true })),
|
||||
),
|
||||
el('div', { class: 'entry-list' }, ...items.map((exp) => expenseRow(exp, false))),
|
||||
),
|
||||
);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function flatList(rows) {
|
||||
return el('div', { class: 'entry-list expenses-flat' }, ...rows.map((exp) => expenseRow(exp, true)));
|
||||
}
|
||||
|
||||
function expenseRow(exp, showDate) {
|
||||
const info = expenseCategoryInfo(exp.category);
|
||||
const payerName = memberName(exp.paid_by);
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'entry-row', style: { '--chip': info.color } },
|
||||
el('span', { class: 'entry-icon' }, info.icon),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-body' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-title-row' },
|
||||
el('span', { class: 'entry-title' }, exp.description),
|
||||
el('span', { class: 'entry-price' }, formatMoney(exp.amount, currency(), { compact: true })),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-sub muted' },
|
||||
info.label,
|
||||
showDate ? ` · ${formatDate(exp.date)}` : '',
|
||||
` · ${splitModeLabel(exp.split_mode)}`,
|
||||
payerName ? ` · paid by ${payerName}` : ' · no payer set',
|
||||
),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-actions' },
|
||||
el('button', { class: 'icon-btn', type: 'button', title: 'Edit', onClick: () => startEdit(exp) }, '✎'),
|
||||
el('button', { class: 'icon-btn danger', type: 'button', title: 'Delete', onClick: () => onDelete(exp) }, '🗑'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function startEdit(exp) {
|
||||
state.editing = exp;
|
||||
draw();
|
||||
const formEl = section.querySelector('.expenses-form');
|
||||
if (formEl) formEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
async function onDelete(exp) {
|
||||
if (!window.confirm(`Delete "${exp.description}"?`)) return;
|
||||
try {
|
||||
await api.expenses.remove(exp.id);
|
||||
toast('Expense deleted', 'success');
|
||||
if (state.editing && state.editing.id === exp.id) state.editing = null;
|
||||
await afterMutation();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Today clamped into the trip's date range, for the quick-add default.
|
||||
function defaultDate() {
|
||||
const today = ymd(new Date());
|
||||
const { start_date, end_date } = tctx.trip.trip;
|
||||
if (today < start_date) return start_date;
|
||||
if (today > end_date) return end_date;
|
||||
return today;
|
||||
}
|
||||
|
||||
function formSection() {
|
||||
const editing = state.editing;
|
||||
const mem = members();
|
||||
const { start_date, end_date } = tctx.trip.trip;
|
||||
|
||||
const dateInput = el('input', {
|
||||
class: 'input', type: 'date', min: start_date, max: end_date,
|
||||
value: editing ? editing.date : defaultDate(),
|
||||
});
|
||||
const descInput = el('input', {
|
||||
class: 'input field-grow', type: 'text', maxlength: '120', placeholder: 'Description',
|
||||
value: editing ? editing.description : '',
|
||||
});
|
||||
const amountInput = el('input', {
|
||||
class: 'input', type: 'number', min: '0', step: '0.01', placeholder: '0.00',
|
||||
value: editing ? String(editing.amount) : '',
|
||||
});
|
||||
const categorySelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...EXPENSE_CATEGORY_LIST.map((c) => el('option', { value: c.value }, `${c.icon} ${c.label}`)),
|
||||
);
|
||||
categorySelect.value = editing ? editing.category : 'other';
|
||||
const payerSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
el('option', { value: '' }, '— unassigned —'),
|
||||
...mem.map((m) => el('option', { value: String(m.id) }, m.display_name)),
|
||||
);
|
||||
payerSelect.value = editing && editing.paid_by != null ? String(editing.paid_by) : '';
|
||||
const modeSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...SPLIT_MODES.map((m) => el('option', { value: m.value }, m.label)),
|
||||
);
|
||||
modeSelect.value = editing ? editing.split_mode : 'equal';
|
||||
const editingParticipants = editing && Array.isArray(editing.participants) ? editing.participants : [];
|
||||
const participantChecks = mem.map((m) =>
|
||||
el('input', {
|
||||
type: 'checkbox', class: 'part-check', value: String(m.id),
|
||||
checked: editing ? (editingParticipants.length === 0 || editingParticipants.includes(m.id)) : true,
|
||||
}),
|
||||
);
|
||||
const participantsBox = el(
|
||||
'div',
|
||||
{ class: 'participants' },
|
||||
...mem.map((m, i) => el('label', { class: 'part-item' }, participantChecks[i], el('span', {}, m.display_name))),
|
||||
);
|
||||
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const submitBtn = el('button', { class: 'btn btn-primary btn-sm', type: 'submit' }, editing ? 'Save' : '+ Add');
|
||||
const cancelBtn = editing
|
||||
? el('button', {
|
||||
class: 'btn btn-ghost btn-sm', type: 'button',
|
||||
onClick: () => { state.editing = null; draw(); },
|
||||
}, 'Cancel')
|
||||
: null;
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const date = dateInput.value;
|
||||
if (!date) return (errorEl.textContent = 'Date is required.');
|
||||
const description = descInput.value.trim();
|
||||
if (!description) return (errorEl.textContent = 'Description is required.');
|
||||
if (description.length > 120) return (errorEl.textContent = 'Keep it under 120 characters.');
|
||||
const amountRaw = amountInput.value.trim();
|
||||
const amount = Number(amountRaw);
|
||||
if (amountRaw === '' || !Number.isFinite(amount) || amount < 0) {
|
||||
return (errorEl.textContent = 'Amount 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 (errorEl.textContent = "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 (errorEl.textContent = 'Select at least one participant.');
|
||||
const participants = checked.length === mem.length ? [] : checked;
|
||||
|
||||
const payload = {
|
||||
date, description, amount, category: categorySelect.value, paid_by, split_mode, participants,
|
||||
};
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
if (editing) await api.expenses.update(editing.id, payload);
|
||||
else await api.expenses.create(tctx.tripId, payload);
|
||||
toast(editing ? 'Expense updated' : 'Expense added', 'success');
|
||||
state.editing = null;
|
||||
await afterMutation();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return el(
|
||||
'form',
|
||||
{ class: 'expenses-form', onSubmit },
|
||||
el('h3', {}, editing ? 'Edit expense' : 'Add expense'),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Date'), dateInput),
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Description'), descInput),
|
||||
el('label', { class: 'field field-price' }, el('span', { class: 'field-label' }, `Amount (${currency()})`), amountInput),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Category'), categorySelect),
|
||||
),
|
||||
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),
|
||||
errorEl,
|
||||
el('div', { class: 'form-actions' }, cancelBtn, submitBtn),
|
||||
);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user