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()