Split the trip page into tabs and move the header into the navbar
Three deep-linkable tabs (#/trip/:id/:tab): Trip (countdown, calendar, map, summary), Money (costs + expenses side by side), Checklist. Panels stay mounted; switching toggles visibility and updates the URL via history.replaceState so an open day editor and the live tickers are undisturbed. The Leaflet map revalidates its size on every trip-tab activation but only auto-fits once, when its container first gains real size - later visits keep the user's pan/zoom. The full-width trip-header card is gone: the navbar now carries a compact identity (back link, truncated name, date/days/currency) plus a single overflow menu holding members, join code, edit (now a centered modal) and delete. Dead header CSS removed; new styles live in tabs.css.
This commit is contained in:
+29
-6
@@ -17,6 +17,13 @@ export function renderMap(tctx) {
|
||||
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
|
||||
const stops = route.stops || [];
|
||||
|
||||
// Leaflet initialised while its tab panel is hidden gets a zero-size
|
||||
// container; tripDetail calls this on every activation of the trip tab so
|
||||
// the map (and its fitBounds zoom, computed against whatever size the
|
||||
// container had at creation time) catches up. No-op until the map actually
|
||||
// exists — overwritten at the end of initMap() below.
|
||||
tctx.mapInvalidate = () => {};
|
||||
|
||||
const section = el('section', { class: 'card map-section' });
|
||||
section.appendChild(
|
||||
el(
|
||||
@@ -54,7 +61,7 @@ export function renderMap(tctx) {
|
||||
|
||||
// Leaflet needs the container attached with a real size, so init on the
|
||||
// next tick after this section is mounted into the page.
|
||||
setTimeout(() => initMap(mapDiv, stops, legs, kmEls), 0);
|
||||
setTimeout(() => initMap(mapDiv, stops, legs, kmEls, tctx), 0);
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -75,7 +82,7 @@ function visibleLegs(route, stops) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function initMap(mapDiv, stops, legs, kmEls) {
|
||||
function initMap(mapDiv, stops, legs, kmEls, tctx) {
|
||||
const L = window.L;
|
||||
if (!L) {
|
||||
mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.'));
|
||||
@@ -126,13 +133,29 @@ function initMap(mapDiv, stops, legs, kmEls) {
|
||||
return { leg, a, b, line, label, color };
|
||||
});
|
||||
|
||||
if (points.length === 1) {
|
||||
map.setView(points[0], 10);
|
||||
} else {
|
||||
map.fitBounds(L.latLngBounds(points).pad(0.2));
|
||||
function fit() {
|
||||
if (points.length === 1) map.setView(points[0], 10);
|
||||
else map.fitBounds(L.latLngBounds(points).pad(0.2));
|
||||
}
|
||||
fit();
|
||||
map.invalidateSize();
|
||||
|
||||
// A container sized 0 at creation time (hidden tab panel) gives fitBounds a
|
||||
// bogus zoom that invalidateSize() alone won't correct, so the first
|
||||
// activation after the container actually gets a real size must re-fit.
|
||||
// But re-fitting on EVERY activation would silently discard any pan/zoom
|
||||
// the user did on a previous visit to the tab — so fit() only runs once,
|
||||
// the first time the container transitions from zero-width to sized.
|
||||
let fitted = mapDiv.offsetWidth > 0;
|
||||
tctx.mapInvalidate = () => {
|
||||
if (!mapDiv.isConnected) return;
|
||||
map.invalidateSize();
|
||||
if (!fitted && mapDiv.offsetWidth > 0) {
|
||||
fit();
|
||||
fitted = true;
|
||||
}
|
||||
};
|
||||
|
||||
fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls);
|
||||
}
|
||||
|
||||
|
||||
+264
-108
@@ -1,8 +1,11 @@
|
||||
// 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.
|
||||
// Orchestrates the trip page: a compact nav-bar trip identity block, a
|
||||
// Trip/Money/Checklist tab bar, and three always-mounted panels. Owns the
|
||||
// shared trip context passed to the calendar, map, summary, costs, expenses,
|
||||
// checklist and day-editor sub-views: { state, navigate, navTripSlot,
|
||||
// tripId, trip, route, costs, refreshTrip, refreshCosts, openDay,
|
||||
// mapInvalidate }. After any mutation, refreshTrip() re-fetches the trip and
|
||||
// its derived /route data so all panels (including the nav identity block)
|
||||
// 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';
|
||||
@@ -15,7 +18,22 @@ import { renderChecklist } from './checklist.js';
|
||||
import { renderCountdown } from './flipclock.js';
|
||||
import { openDayEditor } from './dayEditor.js';
|
||||
|
||||
export function renderTripDetail(container, ctx, id) {
|
||||
const TABS = [
|
||||
{ id: 'trip', icon: '🗓️', label: 'Trip' },
|
||||
{ id: 'money', icon: '💰', label: 'Money' },
|
||||
{ id: 'checklist', icon: '🧳', label: 'Checklist' },
|
||||
];
|
||||
const TAB_IDS = TABS.map((t) => t.id);
|
||||
|
||||
// The nav trip-menu dropdown attaches document-level listeners (outside
|
||||
// click, Escape, hashchange) while open. Only one trip page is ever mounted
|
||||
// at a time, so — same convention as calendar.js's now-slider ticker — a
|
||||
// module-level closer lets a fresh renderNavTripContext() tear down a
|
||||
// still-open dropdown's listeners before it rebuilds the slot (refreshTrip
|
||||
// re-draws the nav without navigating away, which would otherwise leak them).
|
||||
let closeActiveDropdown = () => {};
|
||||
|
||||
export function renderTripDetail(container, ctx, id, tab) {
|
||||
const tctx = {
|
||||
...ctx,
|
||||
tripId: id,
|
||||
@@ -25,10 +43,14 @@ export function renderTripDetail(container, ctx, id) {
|
||||
_onModalRefresh: null,
|
||||
};
|
||||
|
||||
let activeTab = TAB_IDS.includes(tab) ? tab : 'trip';
|
||||
|
||||
// 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;
|
||||
let tabButtons = [];
|
||||
let tabPanels = [];
|
||||
|
||||
mount(container, loading('Loading trip…'));
|
||||
init();
|
||||
@@ -89,95 +111,184 @@ export function renderTripDetail(container, ctx, id) {
|
||||
}
|
||||
|
||||
function draw() {
|
||||
renderNavTripContext();
|
||||
|
||||
const page = el('div', { class: 'page' });
|
||||
page.appendChild(renderHeader());
|
||||
page.appendChild(renderCountdown(tctx));
|
||||
page.appendChild(renderCalendar(tctx));
|
||||
costsSection = renderCosts(tctx);
|
||||
page.appendChild(
|
||||
page.appendChild(renderTabBar());
|
||||
|
||||
const tripPanel = el('section', {
|
||||
class: 'tab-panel', id: 'tab-panel-trip', role: 'tabpanel', 'aria-labelledby': 'tab-trip',
|
||||
});
|
||||
tripPanel.appendChild(renderCountdown(tctx));
|
||||
tripPanel.appendChild(renderCalendar(tctx));
|
||||
tripPanel.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'detail-grid' },
|
||||
renderMap(tctx),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'detail-side' },
|
||||
renderSummary(tctx),
|
||||
costsSection,
|
||||
renderExpenses(tctx),
|
||||
renderChecklist(tctx),
|
||||
),
|
||||
el('div', { class: 'detail-side' }, renderSummary(tctx)),
|
||||
),
|
||||
);
|
||||
|
||||
costsSection = renderCosts(tctx);
|
||||
const moneyPanel = el('section', {
|
||||
class: 'tab-panel', id: 'tab-panel-money', role: 'tabpanel', 'aria-labelledby': 'tab-money',
|
||||
});
|
||||
moneyPanel.appendChild(el('div', { class: 'money-grid' }, costsSection, renderExpenses(tctx)));
|
||||
|
||||
const checklistPanel = el('section', {
|
||||
class: 'tab-panel', id: 'tab-panel-checklist', role: 'tabpanel', 'aria-labelledby': 'tab-checklist',
|
||||
});
|
||||
checklistPanel.appendChild(el('div', { class: 'checklist-wrap' }, renderChecklist(tctx)));
|
||||
|
||||
tabPanels = [tripPanel, moneyPanel, checklistPanel];
|
||||
page.appendChild(tripPanel);
|
||||
page.appendChild(moneyPanel);
|
||||
page.appendChild(checklistPanel);
|
||||
|
||||
mount(container, page);
|
||||
updateTabUI();
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
// ---------- Tab bar ----------
|
||||
|
||||
function renderTabBar() {
|
||||
tabButtons = TABS.map((t) => el(
|
||||
'button',
|
||||
{
|
||||
class: 'tab-btn',
|
||||
type: 'button',
|
||||
id: `tab-${t.id}`,
|
||||
role: 'tab',
|
||||
'aria-controls': `tab-panel-${t.id}`,
|
||||
'aria-selected': 'false',
|
||||
tabindex: '-1',
|
||||
onClick: () => setActiveTab(t.id),
|
||||
},
|
||||
el('span', { class: 'tab-icon' }, t.icon),
|
||||
el('span', { class: 'tab-label' }, t.label),
|
||||
));
|
||||
|
||||
const bar = el('div', { class: 'tab-bar', role: 'tablist', 'aria-label': 'Trip sections' }, ...tabButtons);
|
||||
bar.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
e.preventDefault();
|
||||
const idx = TAB_IDS.indexOf(activeTab);
|
||||
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
||||
const next = TABS[(idx + dir + TABS.length) % TABS.length];
|
||||
setActiveTab(next.id);
|
||||
document.getElementById(`tab-${next.id}`).focus();
|
||||
});
|
||||
return bar;
|
||||
}
|
||||
|
||||
// Switching is NOT navigation: toggles panel visibility and updates the
|
||||
// address bar via history.replaceState — never location.hash (a
|
||||
// hashchange would close the day-editor overlay and stop the calendar's
|
||||
// now-ticker / countdown, see docs/API.md).
|
||||
function setActiveTab(nextTab) {
|
||||
activeTab = TAB_IDS.includes(nextTab) ? nextTab : 'trip';
|
||||
updateTabUI();
|
||||
history.replaceState(null, '', `#/trip/${tctx.tripId}/${activeTab}`);
|
||||
if (activeTab === 'trip' && typeof tctx.mapInvalidate === 'function') tctx.mapInvalidate();
|
||||
}
|
||||
|
||||
function updateTabUI() {
|
||||
for (const btn of tabButtons) {
|
||||
const isActive = btn.id === `tab-${activeTab}`;
|
||||
btn.classList.toggle('active', isActive);
|
||||
btn.setAttribute('aria-selected', String(isActive));
|
||||
btn.tabIndex = isActive ? 0 : -1;
|
||||
}
|
||||
for (const panel of tabPanels) {
|
||||
if (panel.id === `tab-panel-${activeTab}`) panel.removeAttribute('hidden');
|
||||
else panel.setAttribute('hidden', '');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Nav trip-identity block ----------
|
||||
|
||||
function renderNavTripContext() {
|
||||
closeActiveDropdown();
|
||||
const slot = tctx.navTripSlot;
|
||||
if (!slot) return;
|
||||
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 menuBtn = el('button', {
|
||||
class: 'icon-btn nav-trip-menu-btn', type: 'button', title: 'Trip menu',
|
||||
'aria-haspopup': 'true', 'aria-expanded': 'false',
|
||||
}, '⋯');
|
||||
|
||||
const titleRow = el(
|
||||
'div',
|
||||
{ class: 'trip-header-top' },
|
||||
let outsideHandler = null;
|
||||
let escHandler = null;
|
||||
let hashHandler = null;
|
||||
|
||||
function closeMenu() {
|
||||
dropdown.classList.remove('open');
|
||||
menuBtn.setAttribute('aria-expanded', 'false');
|
||||
if (outsideHandler) { document.removeEventListener('click', outsideHandler); outsideHandler = null; }
|
||||
if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; }
|
||||
if (hashHandler) { window.removeEventListener('hashchange', hashHandler); hashHandler = null; }
|
||||
}
|
||||
function openMenu() {
|
||||
dropdown.classList.add('open');
|
||||
menuBtn.setAttribute('aria-expanded', 'true');
|
||||
outsideHandler = (e) => { if (!dropdown.contains(e.target) && e.target !== menuBtn) closeMenu(); };
|
||||
escHandler = (e) => { if (e.key === 'Escape') closeMenu(); };
|
||||
hashHandler = closeMenu;
|
||||
document.addEventListener('click', outsideHandler);
|
||||
document.addEventListener('keydown', escHandler);
|
||||
window.addEventListener('hashchange', hashHandler);
|
||||
}
|
||||
|
||||
const dropdown = buildDropdown(trip, members, isOwner, closeMenu);
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (dropdown.classList.contains('open')) closeMenu(); else openMenu();
|
||||
});
|
||||
closeActiveDropdown = closeMenu;
|
||||
|
||||
mount(
|
||||
slot,
|
||||
el('a', { class: 'nav-trip-back', href: '#/trips', title: 'All trips', 'aria-label': 'Back to all trips' }, '←'),
|
||||
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) =>
|
||||
{ class: 'nav-trip-id' },
|
||||
el('span', { class: 'nav-trip-name', title: trip.name }, trip.name),
|
||||
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,
|
||||
{ class: 'nav-trip-sub muted' },
|
||||
formatRange(trip.start_date, trip.end_date),
|
||||
' · ', pluralize(dayCount, 'day', 'days'),
|
||||
' · ', trip.currency || 'USD',
|
||||
),
|
||||
),
|
||||
el('div', { class: 'nav-trip-menu' }, menuBtn, dropdown),
|
||||
);
|
||||
|
||||
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) {
|
||||
function buildDropdown(trip, members, isOwner, closeMenu) {
|
||||
const membersList = el(
|
||||
'div',
|
||||
{ class: 'trip-menu-members' },
|
||||
el('div', { class: 'trip-menu-label' }, 'Members'),
|
||||
...members.map((m) => el(
|
||||
'div',
|
||||
{ class: 'trip-menu-member' },
|
||||
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()),
|
||||
el('span', { class: 'trip-menu-member-name' }, m.display_name),
|
||||
m.role === 'owner' ? el('span', { class: 'role-badge role-owner' }, 'Owner') : null,
|
||||
isOwner && m.role !== 'owner'
|
||||
? el('button', {
|
||||
class: 'member-remove', type: 'button', title: `Remove ${m.display_name}`,
|
||||
onClick: () => { closeMenu(); onRemoveMember(m); },
|
||||
}, '×')
|
||||
: null,
|
||||
)),
|
||||
);
|
||||
|
||||
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 () => {
|
||||
@@ -188,43 +299,65 @@ export function renderTripDetail(container, ctx, id) {
|
||||
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(
|
||||
const joinRow = el(
|
||||
'div',
|
||||
{ class: 'joincode-row' },
|
||||
el('span', { class: 'members-label' }, 'Join code:'),
|
||||
{ class: 'trip-menu-joincode' },
|
||||
el('span', { class: 'joincode-chip' }, grouped),
|
||||
copyBtn,
|
||||
isOwner
|
||||
? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate')
|
||||
? el('button', {
|
||||
class: 'btn btn-sm btn-ghost', type: 'button',
|
||||
onClick: () => { closeMenu(); onRegenerate(); },
|
||||
}, '↻ Regenerate')
|
||||
: null,
|
||||
);
|
||||
|
||||
const actions = el(
|
||||
'div',
|
||||
{ class: 'trip-menu-actions' },
|
||||
el('button', {
|
||||
class: 'btn btn-sm', type: 'button', onClick: () => { closeMenu(); openEditModal(); },
|
||||
}, '✎ Edit trip'),
|
||||
isOwner
|
||||
? el('button', {
|
||||
class: 'btn btn-sm btn-danger-ghost', type: 'button',
|
||||
onClick: () => { closeMenu(); onDelete(); },
|
||||
}, 'Delete trip')
|
||||
: null,
|
||||
);
|
||||
|
||||
return el('div', { class: 'trip-menu-dropdown', role: 'menu' }, membersList, joinRow, actions);
|
||||
}
|
||||
|
||||
function toggleEdit(header) {
|
||||
const existing = header.querySelector('.trip-edit');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
// ---------- Edit-trip modal ----------
|
||||
|
||||
function openEditModal() {
|
||||
const { trip } = tctx.trip;
|
||||
const overlay = el('div', { class: 'overlay overlay-center' });
|
||||
const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true' });
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
document.body.classList.add('no-scroll');
|
||||
|
||||
function close() {
|
||||
document.body.classList.remove('no-scroll');
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
}
|
||||
function onKey(e) { if (e.key === 'Escape') close(); }
|
||||
document.addEventListener('keydown', onKey);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
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 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');
|
||||
const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button', onClick: close }, 'Cancel');
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -241,6 +374,7 @@ export function renderTripDetail(container, ctx, id) {
|
||||
try {
|
||||
await api.trips.update(tctx.tripId, { name, start_date, end_date, currency });
|
||||
toast('Trip updated', 'success');
|
||||
close();
|
||||
await tctx.refreshTrip();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
@@ -249,28 +383,39 @@ export function renderTripDetail(container, ctx, id) {
|
||||
}
|
||||
}
|
||||
|
||||
const editBox = el(
|
||||
'form',
|
||||
{ class: 'trip-edit', onSubmit },
|
||||
mount(
|
||||
modal,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput),
|
||||
{ class: 'slideover-head' },
|
||||
el('h2', {}, '✎ Edit trip'),
|
||||
el('button', { class: 'icon-btn', type: 'button', title: 'Close', onClick: close }, '×'),
|
||||
),
|
||||
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),
|
||||
'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' }, cancelBtn, saveBtn),
|
||||
),
|
||||
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);
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
// ---------- Member / trip mutations ----------
|
||||
|
||||
async function onRemoveMember(member) {
|
||||
if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return;
|
||||
try {
|
||||
@@ -282,6 +427,17 @@ export function renderTripDetail(container, ctx, id) {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
const { trip } = tctx.trip;
|
||||
if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;
|
||||
|
||||
Reference in New Issue
Block a user