From b598b978655d00a538dec6bd04dfe3dac8a334bf Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 12:08:25 +0200 Subject: [PATCH] refactor(planner): share the trip sheet wiring between desktop and phone The phone trip sheet host wrote out by hand three pieces of planner wiring the desktop planner already had. useBookingExpenseEditor now holds the expense editor both planners open over the trip: the booking request state, the receipt fallback, the base currency, the current user and the save that reloads the budget. TripPlannerPage and MTripSheets both read it. applyTripCoverUpdate patches a new trip cover into the store for the trip form on both sides. The transport editor and the transit route change moved into openTransportEditorWith and changeTransitRouteWith, which take the planner's setters. usePlannerDialogs builds openTransportEditor and changeTransitRoute on them, and the phone sheet calls the same helpers on the planner it receives. There are no differences between the two views to keep as options. --- .../screens/trip/sheets/MTripSheets.tsx | 65 ++------ client/src/pages/TripPlannerPage.tsx | 30 ++-- .../transportEditorOpeners.test.ts | 96 ++++++++++++ .../tripPlanner/transportEditorOpeners.ts | 55 +++++++ .../src/pages/tripPlanner/tripCover.test.ts | 31 ++++ client/src/pages/tripPlanner/tripCover.ts | 11 ++ .../useBookingExpenseEditor.test.ts | 140 ++++++++++++++++++ .../tripPlanner/useBookingExpenseEditor.ts | 51 +++++++ .../pages/tripPlanner/usePlannerDialogs.ts | 29 +--- client/tests/helpers/mobileTrip.ts | 9 +- .../unit/mobile/trip/MTripSheets.test.tsx | 16 +- 11 files changed, 436 insertions(+), 97 deletions(-) create mode 100644 client/src/pages/tripPlanner/transportEditorOpeners.test.ts create mode 100644 client/src/pages/tripPlanner/transportEditorOpeners.ts create mode 100644 client/src/pages/tripPlanner/tripCover.test.ts create mode 100644 client/src/pages/tripPlanner/tripCover.ts create mode 100644 client/src/pages/tripPlanner/useBookingExpenseEditor.test.ts create mode 100644 client/src/pages/tripPlanner/useBookingExpenseEditor.ts diff --git a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx index 5b9fcb8e2..9ad403b29 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import TransitJourneyModal from '../../../../components/Planner/TransitJourneyModal' import BookingImportModal from '../../../../components/Planner/BookingImportModal' @@ -6,11 +6,9 @@ import AirTrailImportModal from '../../../../components/Planner/AirTrailImportMo import TripFormModal from '../../../../components/Trips/TripFormModal' import TripMembersModal from '../../../../components/Trips/TripMembersModal' import TourDetailDialog from '../../../../components/Tours/TourDetailDialog' -import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel' -import { expenseEditorFor } from '../../../../components/Budget/CostsPanel.helpers' -import { useAuthStore } from '../../../../store/authStore' -import { useSettingsStore } from '../../../../store/settingsStore' -import { useTripStore } from '../../../../store/tripStore' +import { useBookingExpenseEditor } from '../../../../pages/tripPlanner/useBookingExpenseEditor' +import { applyTripCoverUpdate } from '../../../../pages/tripPlanner/tripCover' +import { latestReservation } from '../../../../pages/tripPlanner/transportEditorOpeners' import MConfirmSheet from '../../settings/MConfirmSheet' import MDayImpactList from '../../../components/MDayImpactList' import MDaySheet from './MDaySheet' @@ -34,8 +32,6 @@ import MRtKindSheet from '../roadtrip/MRtKindSheet' import MRtInfoSheet from '../roadtrip/MRtInfoSheet' import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet' import MRtDraftSheet from '../roadtrip/MRtDraftSheet' -import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types' -import type { BudgetItem } from '../../../../types' import type { MTripSheetsProps } from '../MTripShell' import { lockBodyScroll } from '../../../../utils/bodyScrollLock' import { focusDialog, trapTab } from '../../../../components/shared/dialogFocus' @@ -110,16 +106,9 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) { // Booking-linked expense editor (save-then-open from the booking modals) — // same page-level wiring as the desktop planner. - const meId = useAuthStore(s => s.user?.id ?? -1) - const displayCurrency = useSettingsStore(s => s.settings.default_currency) - const loadBudgetItems = useTripStore(s => s.loadBudgetItems) - const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null) - const openBookingExpense = (req: BookingExpenseRequest) => { - if (req.editItem) setBookingExpense({ editing: req.editItem }) - else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill }) - } - const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase() - const expenseEditor = expenseEditorFor(bookingExpense, () => setBookingExpense(null), planner.receiptExpense, planner.clearReceiptExpense) + const { meId, costsBase, openBookingExpense, expenseEditor, onExpenseSaved } = useBookingExpenseEditor({ + tripId, tripCurrency: trip?.currency, receiptExpense: planner.receiptExpense, clearReceiptExpense: planner.clearReceiptExpense, + }) return ( <> @@ -180,34 +169,12 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) { await planner.handleDeleteReservation(planner.transitJourney!.id) planner.setTransitJourney(null) }} - onChangeRoute={() => { - // Re-enter the transit search seeded with this journey's route; the - // existing reservation is replaced on save. - const journey = planner.transitJourney! - const eps = journey.endpoints || [] - const from = eps.find(e => e.role === 'from') - const to = eps.find(e => e.role === 'to') - planner.setTransitPrefill({ - from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null, - to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null, - }) - planner.setEditingTransport(journey) - planner.setTransportModalDayId(journey.day_id ?? null) - planner.setTransportModalAutomated(true) - planner.setTransitJourney(null) - planner.setShowTransportModal(true) - }} - onEditDetails={() => { - // Hand off to the full transport editor for the booking fields — - // same target as the transports tab's pencil (#2148). - const journey = planner.reservations.find(r => r.id === planner.transitJourney!.id) ?? planner.transitJourney! - planner.setEditingTransport(journey) - planner.setTransportModalDayId(journey.day_id ?? null) - planner.setTransportModalAutomated(false) - planner.setTransitPrefill(null) - planner.setTransitJourney(null) - planner.setShowTransportModal(true) - }} + // Re-enter the transit search seeded with this journey's route; the + // existing reservation is replaced on save. + onChangeRoute={() => planner.changeTransitRoute(planner.transitJourney!)} + // Hand off to the full transport editor for the booking fields, the same + // target as the transports tab's pencil (#2148). The store copy may be newer. + onEditDetails={() => planner.openTransportEditor(latestReservation(planner.reservations, planner.transitJourney!))} /> )} @@ -221,7 +188,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) { editing={expenseEditor.editing} prefill={expenseEditor.prefill} onClose={expenseEditor.close} - onSaved={() => { expenseEditor.close(); loadBudgetItems(tripId) }} + onSaved={onExpenseSaved} /> )} @@ -237,9 +204,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) { toast.success(t('trip.toast.tripUpdated')) }} trip={trip} - onCoverUpdate={(_, coverUrl) => useTripStore.setState(state => ({ - trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip, - }))} + onCoverUpdate={applyTripCoverUpdate} /> s.user?.id ?? -1) - const displayCurrency = useSettingsStore(s => s.settings.default_currency) + const { meId, costsBase, openBookingExpense, expenseEditor, onExpenseSaved } = useBookingExpenseEditor({ + tripId, tripCurrency: trip?.currency, receiptExpense, clearReceiptExpense, + }) const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase() // Transit search departs against a real date, so the whole Automated mode — // the day-header tram button and the modal's mode switch — is off without one. const tripHasDates = Boolean(trip?.start_date && trip?.end_date) - const loadBudgetItems = useTripStore(s => s.loadBudgetItems) - const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null) - const openBookingExpense = (req: BookingExpenseRequest) => { - if (req.editItem) setBookingExpense({ editing: req.editItem }) - else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill }) - } - // One expense editor for both openers: a booking's Costs block, and a scanned - // receipt sent here from the background tasks widget. - const expenseEditor = expenseEditorFor(bookingExpense, () => setBookingExpense(null), receiptExpense, clearReceiptExpense) if (isLoading || !splashDone) { return ( @@ -946,7 +934,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); loadAccommodations(); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} - onCoverUpdate={(_, coverUrl) => useTripStore.setState(state => ({ trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip }))} + onCoverUpdate={applyTripCoverUpdate} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} onMembersChanged={refreshMembers} /> { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} tripMembers={tripMembers} /> @@ -967,7 +955,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onDelete={async () => { await handleDeleteReservation(transitJourney.id); setTransitJourney(null) }} onChangeRoute={() => changeTransitRoute(transitJourney)} // The store copy may be newer than the journey held in state. - onEditDetails={() => openTransportEditor(reservations.find(r => r.id === transitJourney.id) ?? transitJourney)} + onEditDetails={() => openTransportEditor(latestReservation(reservations, transitJourney))} /> )} {/* A booking clicked on the desktop plan: its detail first, the editor one Edit away. */} @@ -1001,7 +989,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { editing={expenseEditor.editing} prefill={expenseEditor.prefill} onClose={expenseEditor.close} - onSaved={() => { expenseEditor.close(); loadBudgetItems(tripId) }} + onSaved={onExpenseSaved} /> diff --git a/client/src/pages/tripPlanner/transportEditorOpeners.test.ts b/client/src/pages/tripPlanner/transportEditorOpeners.test.ts new file mode 100644 index 000000000..0a01a70b7 --- /dev/null +++ b/client/src/pages/tripPlanner/transportEditorOpeners.test.ts @@ -0,0 +1,96 @@ +// FE-TP-TROPEN-001 to FE-TP-TROPEN-006 +// +// The two ways from a saved transit journey into the transport editor. The planner's +// dialogs run them on their own state, the phone trip sheets on the planner's setters. +import { buildReservation } from '../../../tests/helpers/factories'; +import type { Reservation } from '../../types'; +import { + changeTransitRouteWith, + latestReservation, + openTransportEditorWith, + type TransportEditorSetters, +} from './transportEditorOpeners'; + +function buildSetters() { + const calls: string[] = []; + const spy = (name: string) => + vi.fn(() => { + calls.push(name); + }); + const setters = { + setEditingTransport: spy('editing'), + setTransportModalDayId: spy('day'), + setTransportModalAutomated: spy('automated'), + setTransitPrefill: spy('prefill'), + setTransitJourney: spy('journey'), + setShowTransportModal: spy('show'), + } satisfies TransportEditorSetters; + return { setters, calls }; +} + +const journey = { + ...buildReservation({ id: 55, day_id: 4, type: 'transit' }), + endpoints: [ + { role: 'from', name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 }, + { role: 'to', name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 }, + ], +} as unknown as Reservation; + +describe('openTransportEditorWith', () => { + it('FE-TP-TROPEN-001: opens the manual editor on the entry and drops the journey view', () => { + const { setters, calls } = buildSetters(); + openTransportEditorWith(setters, journey); + expect(setters.setEditingTransport).toHaveBeenCalledWith(journey); + expect(setters.setTransportModalDayId).toHaveBeenCalledWith(4); + expect(setters.setTransportModalAutomated).toHaveBeenCalledWith(false); + expect(setters.setTransitPrefill).toHaveBeenCalledWith(null); + expect(setters.setTransitJourney).toHaveBeenCalledWith(null); + expect(setters.setShowTransportModal).toHaveBeenCalledWith(true); + expect(calls).toEqual(['editing', 'day', 'automated', 'prefill', 'journey', 'show']); + }); + + it('FE-TP-TROPEN-002: an entry without a day opens the editor without one', () => { + const { setters } = buildSetters(); + openTransportEditorWith(setters, { id: 9, trip_id: 1, type: 'train', title: 'Leg' } as unknown as Reservation); + expect(setters.setTransportModalDayId).toHaveBeenCalledWith(null); + }); +}); + +describe('changeTransitRouteWith', () => { + it('FE-TP-TROPEN-003: reopens the transit search seeded with both endpoints', () => { + const { setters, calls } = buildSetters(); + changeTransitRouteWith(setters, journey); + expect(setters.setTransitPrefill).toHaveBeenCalledWith({ + from: { name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 }, + to: { name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 }, + }); + expect(setters.setEditingTransport).toHaveBeenCalledWith(journey); + expect(setters.setTransportModalDayId).toHaveBeenCalledWith(4); + expect(setters.setTransportModalAutomated).toHaveBeenCalledWith(true); + expect(setters.setTransitJourney).toHaveBeenCalledWith(null); + expect(setters.setShowTransportModal).toHaveBeenCalledWith(true); + expect(calls).toEqual(['prefill', 'editing', 'day', 'automated', 'journey', 'show']); + }); + + it('FE-TP-TROPEN-004: a journey without endpoints seeds empty prefills and no day', () => { + const { setters } = buildSetters(); + const bare = { id: 56, trip_id: 1, type: 'transit', title: 'Unknown leg' } as unknown as Reservation; + changeTransitRouteWith(setters, bare); + expect(setters.setTransitPrefill).toHaveBeenCalledWith({ from: null, to: null }); + expect(setters.setTransportModalDayId).toHaveBeenCalledWith(null); + }); +}); + +describe('latestReservation', () => { + it('FE-TP-TROPEN-005: prefers the store copy of the journey when it is newer', () => { + const fresh = { ...journey, title: 'Tokyo to Kyoto (saved)' } as Reservation; + const other = buildReservation({ id: 57, type: 'train' }); + expect(latestReservation([other, fresh], journey)).toBe(fresh); + }); + + it('FE-TP-TROPEN-006: falls back to the held journey when the store has no copy', () => { + const other = buildReservation({ id: 57, type: 'train' }); + expect(latestReservation([other], journey)).toBe(journey); + expect(latestReservation([], journey)).toBe(journey); + }); +}); diff --git a/client/src/pages/tripPlanner/transportEditorOpeners.ts b/client/src/pages/tripPlanner/transportEditorOpeners.ts new file mode 100644 index 000000000..dd43db5b9 --- /dev/null +++ b/client/src/pages/tripPlanner/transportEditorOpeners.ts @@ -0,0 +1,55 @@ +import type { Reservation } from '../../types'; + +type TransitPoint = { name: string; lat: number; lng: number } | null; + +/** The planner state the transport editor opens from. usePlannerDialogs owns it. */ +export interface TransportEditorSetters { + setEditingTransport: (r: Reservation | null) => void; + setTransportModalDayId: (dayId: number | null) => void; + setTransportModalAutomated: (automated: boolean) => void; + setTransitPrefill: (prefill: { from?: TransitPoint; to?: TransitPoint; time?: string | null } | null) => void; + setTransitJourney: (r: Reservation | null) => void; + setShowTransportModal: (open: boolean) => void; +} + +/** + * The store copy of a journey held in planner state. A save or a socket update may + * have replaced the entry since the journey view opened; without a match the held + * copy is the best there is. + */ +export function latestReservation(reservations: Reservation[], r: Reservation): Reservation { + return reservations.find((x) => x.id === r.id) ?? r; +} + +/** + * The full transport editor on a saved entry. For a transit journey that is where + * travellers, costs, files, code and status live; an unchanged-endpoints save keeps + * the stored itinerary (#2148). + */ +export function openTransportEditorWith(setters: TransportEditorSetters, r: Reservation): void { + setters.setEditingTransport(r); + setters.setTransportModalDayId(r.day_id ?? null); + setters.setTransportModalAutomated(false); + setters.setTransitPrefill(null); + setters.setTransitJourney(null); + setters.setShowTransportModal(true); +} + +/** + * Re-enters the transit search seeded with a journey's route; the journey is + * REPLACED on save (editingTransport drives handleSaveTransport's update path). + */ +export function changeTransitRouteWith(setters: TransportEditorSetters, r: Reservation): void { + const eps = r.endpoints || []; + const from = eps.find((e) => e.role === 'from'); + const to = eps.find((e) => e.role === 'to'); + setters.setTransitPrefill({ + from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null, + to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null, + }); + setters.setEditingTransport(r); + setters.setTransportModalDayId(r.day_id ?? null); + setters.setTransportModalAutomated(true); + setters.setTransitJourney(null); + setters.setShowTransportModal(true); +} diff --git a/client/src/pages/tripPlanner/tripCover.test.ts b/client/src/pages/tripPlanner/tripCover.test.ts new file mode 100644 index 000000000..9dd8dc04b --- /dev/null +++ b/client/src/pages/tripPlanner/tripCover.test.ts @@ -0,0 +1,31 @@ +// FE-TP-COVER-001 to FE-TP-COVER-002 +// +// The cover update the trip form hands back, on the desktop planner and the phone trip +// sheets alike. +import { buildTrip } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useTripStore } from '../../store/tripStore'; +import { applyTripCoverUpdate } from './tripCover'; + +describe('applyTripCoverUpdate', () => { + beforeEach(() => { + resetAllStores(); + }); + + it('FE-TP-COVER-001: patches the new cover into the loaded trip and keeps the rest', () => { + seedStore(useTripStore, { + trip: buildTrip({ id: 1, title: 'Japan 2026', cover_image: '/uploads/covers/old.jpg' }), + }); + applyTripCoverUpdate(1, '/uploads/covers/new.jpg'); + expect(useTripStore.getState().trip).toMatchObject({ title: 'Japan 2026', cover_image: '/uploads/covers/new.jpg' }); + + applyTripCoverUpdate(1, null); + expect(useTripStore.getState().trip?.cover_image).toBeNull(); + }); + + it('FE-TP-COVER-002: leaves the store alone without a loaded trip', () => { + seedStore(useTripStore, { trip: null }); + applyTripCoverUpdate(1, '/uploads/covers/new.jpg'); + expect(useTripStore.getState().trip).toBeNull(); + }); +}); diff --git a/client/src/pages/tripPlanner/tripCover.ts b/client/src/pages/tripPlanner/tripCover.ts new file mode 100644 index 000000000..74d6225db --- /dev/null +++ b/client/src/pages/tripPlanner/tripCover.ts @@ -0,0 +1,11 @@ +import { useTripStore } from '../../store/tripStore'; + +/** + * TripFormModal's onCoverUpdate for the open trip: the new cover goes straight into + * the store's trip, so the planner shows it without reloading the trip. + */ +export function applyTripCoverUpdate(_tripId: number, coverUrl: string | null): void { + useTripStore.setState((state) => ({ + trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip, + })); +} diff --git a/client/src/pages/tripPlanner/useBookingExpenseEditor.test.ts b/client/src/pages/tripPlanner/useBookingExpenseEditor.test.ts new file mode 100644 index 000000000..4a9a396a3 --- /dev/null +++ b/client/src/pages/tripPlanner/useBookingExpenseEditor.test.ts @@ -0,0 +1,140 @@ +// FE-TP-BOOKEXP-001 to FE-TP-BOOKEXP-009 +// +// The expense editor the trip planner opens over itself, driven straight through +// useBookingExpenseEditor. The desktop planner and the phone trip sheets render the +// same editor from it, so every case holds for both. +import { act, renderHook } from '@testing-library/react'; + +import { buildBudgetItem } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import type { ExpensePrefill } from '../../components/Budget/CostsPanel'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useTripStore } from '../../store/tripStore'; +import { useBookingExpenseEditor } from './useBookingExpenseEditor'; + +interface Props { + tripCurrency?: string | null; + receiptExpense?: ExpensePrefill | null; +} + +const clearReceiptExpense = vi.fn(); + +function renderEditor(initial: Props = {}) { + return renderHook( + (props: Props) => + useBookingExpenseEditor({ + tripId: 42, + tripCurrency: props.tripCurrency, + receiptExpense: props.receiptExpense ?? null, + clearReceiptExpense, + }), + { initialProps: initial } + ); +} + +describe('useBookingExpenseEditor', () => { + beforeEach(() => { + resetAllStores(); + clearReceiptExpense.mockClear(); + seedStore(useAuthStore, { user: { id: 7, username: 'maurice', email: 'm@example.com' } }); + }); + + it('FE-TP-BOOKEXP-001: no editor until a booking or a receipt asks for one', () => { + const { result } = renderEditor(); + expect(result.current.expenseEditor).toBeNull(); + }); + + it('FE-TP-BOOKEXP-002: a booking opens its linked expense for editing and closes it again', () => { + const item = buildBudgetItem({ id: 99 }); + const { result } = renderEditor(); + act(() => { + result.current.openBookingExpense({ editItem: item }); + }); + expect(result.current.expenseEditor).toMatchObject({ key: 'booking', editing: item, prefill: undefined }); + act(() => { + result.current.expenseEditor!.close(); + }); + expect(result.current.expenseEditor).toBeNull(); + }); + + it('FE-TP-BOOKEXP-003: a booking without a linked expense opens a new one prefilled', () => { + const { result } = renderEditor(); + act(() => { + result.current.openBookingExpense({ prefill: { reservationId: 5, name: 'Train', amount: 30 } }); + }); + expect(result.current.expenseEditor).toMatchObject({ + key: 'booking', + editing: null, + prefill: { reservationId: 5, name: 'Train', amount: 30 }, + }); + }); + + it('FE-TP-BOOKEXP-004: a request with neither an item nor a prefill opens nothing', () => { + const { result } = renderEditor(); + act(() => { + result.current.openBookingExpense({}); + }); + expect(result.current.expenseEditor).toBeNull(); + }); + + it('FE-TP-BOOKEXP-005: a scanned receipt opens a new expense and its close clears the receipt', () => { + const { result } = renderEditor({ receiptExpense: { name: 'Lunch', amount: 12 } }); + expect(result.current.expenseEditor).toMatchObject({ + key: 'receipt', + editing: null, + prefill: { name: 'Lunch', amount: 12 }, + }); + act(() => { + result.current.expenseEditor!.close(); + }); + expect(clearReceiptExpense).toHaveBeenCalledTimes(1); + }); + + it('FE-TP-BOOKEXP-006: a booking request wins over a waiting receipt', () => { + const item = buildBudgetItem({ id: 3 }); + const { result } = renderEditor({ receiptExpense: { name: 'Lunch' } }); + act(() => { + result.current.openBookingExpense({ editItem: item }); + }); + expect(result.current.expenseEditor?.key).toBe('booking'); + }); + + it('FE-TP-BOOKEXP-007: the base currency prefers the display setting, then the trip, then EUR', () => { + seedStore(useSettingsStore, { settings: { default_currency: 'usd' } }); + const { result, rerender } = renderEditor({ tripCurrency: 'jpy' }); + expect(result.current.costsBase).toBe('USD'); + + act(() => { + seedStore(useSettingsStore, { settings: { default_currency: '' } }); + }); + rerender({ tripCurrency: 'jpy' }); + expect(result.current.costsBase).toBe('JPY'); + + rerender({ tripCurrency: null }); + expect(result.current.costsBase).toBe('EUR'); + }); + + it('FE-TP-BOOKEXP-008: the current user pays, an anonymous session as user -1', () => { + const { result } = renderEditor(); + expect(result.current.meId).toBe(7); + act(() => { + seedStore(useAuthStore, { user: null }); + }); + expect(result.current.meId).toBe(-1); + }); + + it('FE-TP-BOOKEXP-009: a save closes the editor and reloads the trip budget', () => { + const loadBudgetItems = vi.fn(async () => undefined); + seedStore(useTripStore, { loadBudgetItems }); + const { result } = renderEditor(); + act(() => { + result.current.openBookingExpense({ prefill: { reservationId: 5 } }); + }); + act(() => { + result.current.onExpenseSaved(); + }); + expect(result.current.expenseEditor).toBeNull(); + expect(loadBudgetItems).toHaveBeenCalledWith(42); + }); +}); diff --git a/client/src/pages/tripPlanner/useBookingExpenseEditor.ts b/client/src/pages/tripPlanner/useBookingExpenseEditor.ts new file mode 100644 index 000000000..a7401f312 --- /dev/null +++ b/client/src/pages/tripPlanner/useBookingExpenseEditor.ts @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +import type { ExpensePrefill } from '../../components/Budget/CostsPanel'; +import { expenseEditorFor } from '../../components/Budget/CostsPanel.helpers'; +import type { BookingExpenseRequest } from '../../components/Planner/BookingCostsSection.types'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useTripStore } from '../../store/tripStore'; +import type { BudgetItem } from '../../types'; + +interface BookingExpenseEditorOptions { + tripId: number; + /** The trip's own currency, the fallback when the user has no display currency set. */ + tripCurrency: string | null | undefined; + /** A scanned receipt sent to the trip from the background tasks widget. */ + receiptExpense: ExpensePrefill | null; + clearReceiptExpense: () => void; +} + +/** + * The expense editor the trip planner opens over itself: from a booking's Costs block + * (save then open) and for a scanned receipt. The desktop shows it as ExpenseModal, the + * phone as MCostSheet; both read the same editor, base currency and current user here. + */ +export function useBookingExpenseEditor(options: BookingExpenseEditorOptions) { + const { tripId, tripCurrency, receiptExpense, clearReceiptExpense } = options; + const meId = useAuthStore((s) => s.user?.id ?? -1); + const displayCurrency = useSettingsStore((s) => s.settings.default_currency); + const loadBudgetItems = useTripStore((s) => s.loadBudgetItems); + const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>( + null + ); + const openBookingExpense = (req: BookingExpenseRequest) => { + if (req.editItem) setBookingExpense({ editing: req.editItem }); + else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill }); + }; + const costsBase = (displayCurrency || tripCurrency || 'EUR').toUpperCase(); + // One expense editor for both openers: a booking's Costs block, and a scanned + // receipt sent here from the background tasks widget. + const expenseEditor = expenseEditorFor( + bookingExpense, + () => setBookingExpense(null), + receiptExpense, + clearReceiptExpense + ); + const onExpenseSaved = () => { + expenseEditor?.close(); + loadBudgetItems(tripId); + }; + return { meId, costsBase, openBookingExpense, expenseEditor, onExpenseSaved }; +} diff --git a/client/src/pages/tripPlanner/usePlannerDialogs.ts b/client/src/pages/tripPlanner/usePlannerDialogs.ts index 11f504df1..811c15101 100644 --- a/client/src/pages/tripPlanner/usePlannerDialogs.ts +++ b/client/src/pages/tripPlanner/usePlannerDialogs.ts @@ -5,6 +5,7 @@ import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPo import { useAirtrailConnection } from '../../hooks/useAirtrailConnection' import type { Place, Reservation } from '../../types' import type { PlannerBase, PlannerSearchParams } from './plannerTypes' +import { changeTransitRouteWith, openTransportEditorWith } from './transportEditorOpeners' interface PlannerDialogsOptions extends Pick { searchParams: PlannerSearchParams[0] @@ -136,32 +137,14 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) { // whose editor also asks for day_edit. const [bookingDetailOpen, setBookingDetailOpen] = useState<{ id: number; fromDayList: boolean } | null>(null) - // The full transport editor on a saved entry. For a transit journey that is where - // travellers, costs, files, code and status live; an unchanged-endpoints save keeps - // the stored itinerary (#2148). + // The full transport editor on a saved entry, and the transit search re-entered + // seeded with a journey's route. The phone trip sheets open them with the same + // helpers on the planner's setters. const openTransportEditor = useCallback((r: Reservation) => { - setEditingTransport(r) - setTransportModalDayId(r.day_id ?? null) - setTransportModalAutomated(false) - setTransitPrefill(null) - setTransitJourney(null) - setShowTransportModal(true) + openTransportEditorWith({ setEditingTransport, setTransportModalDayId, setTransportModalAutomated, setTransitPrefill, setTransitJourney, setShowTransportModal }, r) }, []) - // Re-enters the transit search seeded with a journey's route; the journey is - // REPLACED on save (editingTransport drives handleSaveTransport's update path). const changeTransitRoute = useCallback((r: Reservation) => { - const eps = r.endpoints || [] - const from = eps.find(e => e.role === 'from') - const to = eps.find(e => e.role === 'to') - setTransitPrefill({ - from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null, - to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null, - }) - setEditingTransport(r) - setTransportModalDayId(r.day_id ?? null) - setTransportModalAutomated(true) - setTransitJourney(null) - setShowTransportModal(true) + changeTransitRouteWith({ setEditingTransport, setTransportModalDayId, setTransportModalAutomated, setTransitPrefill, setTransitJourney, setShowTransportModal }, r) }, []) // The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts index f373bda9b..985c674c3 100644 --- a/client/tests/helpers/mobileTrip.ts +++ b/client/tests/helpers/mobileTrip.ts @@ -1,5 +1,7 @@ import { vi } from 'vitest'; import type { TripPlanner, MTripShellApi } from '../../src/mobile/screens/trip/MTripShell'; +import type { Reservation } from '../../src/types'; +import { changeTransitRouteWith, openTransportEditorWith } from '../../src/pages/tripPlanner/transportEditorOpeners'; import { buildTrip } from './factories'; /** @@ -299,6 +301,10 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner handleSaveReservation: vi.fn(), handleSaveTransport: vi.fn(), handleDeleteReservation: vi.fn(), + // The planner's two ways into the transport editor, run on the setter spies above + // the way usePlannerDialogs runs them on its state. + openTransportEditor: vi.fn((r: Reservation) => openTransportEditorWith(planner, r)), + changeTransitRoute: vi.fn((r: Reservation) => changeTransitRouteWith(planner, r)), selectedPlace: null, dayOrderMap: new Map(), @@ -309,7 +315,8 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner splashDone: true, }; - return { ...base, ...overrides } as unknown as TripPlanner; + const planner = { ...base, ...overrides } as unknown as TripPlanner; + return planner; } export function buildShell(overrides: Partial = {}): MTripShellApi { diff --git a/client/tests/unit/mobile/trip/MTripSheets.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.test.tsx index 299518b6e..6bb779f50 100644 --- a/client/tests/unit/mobile/trip/MTripSheets.test.tsx +++ b/client/tests/unit/mobile/trip/MTripSheets.test.tsx @@ -13,7 +13,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' -// FE-MOB-SHOST-001 to FE-MOB-SHOST-029, FE-MOB-SHOST-032 and FE-MOB-SHOST-033 (030 and 031 live in MTripSheets.members.test.tsx) +// FE-MOB-SHOST-001 to FE-MOB-SHOST-029 and FE-MOB-SHOST-032 to FE-MOB-SHOST-034 (030 and 031 live in MTripSheets.members.test.tsx) // // Every child sheet is stubbed: this file is about the host — which sheet is // mounted for which shell.sheet id, and how the host's own callbacks wire the @@ -160,18 +160,20 @@ vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({ })) vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({ - default: ({ reservation, canEdit, onClose, onSave, onDelete, onChangeRoute }: { + default: ({ reservation, canEdit, onClose, onSave, onDelete, onChangeRoute, onEditDetails }: { reservation: Reservation canEdit: boolean onClose: () => void onSave: (fields: Record) => Promise onDelete: () => Promise onChangeRoute: () => void + onEditDetails: () => void }) => (
+
), @@ -373,6 +375,16 @@ describe('MTripSheets', () => { expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null) }) + it('FE-MOB-SHOST-034: editing the details opens the transport editor on the newer store copy', () => { + const fresh = { ...JOURNEY, title: 'Tokyo → Kyoto (saved)' } as Reservation + const { planner } = renderHost({ transitJourney: JOURNEY, reservations: [fresh] }) + fireEvent.click(screen.getByText('edit details')) + expect(planner.openTransportEditor).toHaveBeenCalledWith(fresh) + expect(planner.setEditingTransport).toHaveBeenCalledWith(fresh) + expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false) + expect(planner.setShowTransportModal).toHaveBeenCalledWith(true) + }) + it('FE-MOB-SHOST-020: a booking opens the expense editor for its linked item and closes again', () => { renderHost() expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()