diff --git a/client/src/components/Planner/TransitJourneyModal.tsx b/client/src/components/Planner/TransitJourneyModal.tsx index 1fb655dbe..e39bac3fd 100644 --- a/client/src/components/Planner/TransitJourneyModal.tsx +++ b/client/src/components/Planner/TransitJourneyModal.tsx @@ -10,8 +10,10 @@ import { Segmented, TEXTAREA } from '../shared/dialogParts' import { Tooltip } from '../shared/Tooltip' import { BookingDialogHeader, TypePill } from './bookings/BookingDialogShell' import { TransitLegs } from './bookings/transitParts' +import { deleteBodyKey } from './bookings/bookingsModel' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' +import { useTripStore } from '../../store/tripStore' import { splitReservationDateTime, formatTime } from '../../utils/formatters' import { TransitTitle, TransitMetaBadges, TransitWalkDivider, fmtTransitDuration } from './transitDisplay' import type { Reservation } from '../../types' @@ -55,6 +57,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe const { t, locale } = useTranslation() const isMobile = useIsPhone() const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h' + const budgetItems = useTripStore(st => st.budgetItems) const res = reservation const meta = typeof res.metadata === 'string' ? (() => { try { return JSON.parse(res.metadata || '{}') } catch { return {} } })() : (res.metadata || {}) const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null @@ -252,7 +255,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe onClose={() => setConfirmDelete(false)} onConfirm={async () => { setConfirmDelete(false); await onDelete(); onClose() }} title={t('reservations.confirm.deleteTitle')} - message={t('reservations.confirm.deleteBody', { name: res.title })} + message={t(deleteBodyKey(res.id, budgetItems), { name: res.title })} /> ) @@ -530,7 +533,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe onClose={() => setConfirmDelete(false)} onConfirm={async () => { setConfirmDelete(false); await onDelete(); onClose() }} title={t('reservations.confirm.deleteTitle')} - message={t('reservations.confirm.deleteBody', { name: res.title })} + message={t(deleteBodyKey(res.id, budgetItems), { name: res.title })} /> ) diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 16c12efc9..9771b8366 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -24,7 +24,7 @@ import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell' import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell' import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts' import { fs, Eyebrow, type StatusTone } from './bookings/bookingParts' -import { typeInfo } from './bookings/bookingsModel' +import { deleteBodyKey, typeInfo } from './bookings/bookingsModel' import { EMPTY_TRANSPORT_FIELDS, TRANSPORT_TYPE_OPTIONS as TYPE_OPTIONS, emptyCarStop, emptyStationWaypoint, emptyWaypoint, transportDayOptions, type StationWaypointForm, type WaypointForm, @@ -565,7 +565,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel isOpen={showDeleteConfirm} onClose={() => setShowDeleteConfirm(false)} title={t('reservations.confirm.deleteTitle')} - message={t('reservations.confirm.deleteBody', { name: reservation?.title ?? '' })} + message={t(deleteBodyKey(reservation?.id, budgetItems), { name: reservation?.title ?? '' })} confirmLabel={t('common.delete')} cancelLabel={t('common.cancel')} onConfirm={async () => { diff --git a/client/src/components/Planner/bookings/BookingDetailHost.test.tsx b/client/src/components/Planner/bookings/BookingDetailHost.test.tsx index 61d365c9b..9f47ad8a5 100644 --- a/client/src/components/Planner/bookings/BookingDetailHost.test.tsx +++ b/client/src/components/Planner/bookings/BookingDetailHost.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-BOOKINGHOST-001 to FE-PLANNER-BOOKINGHOST-017 +// FE-PLANNER-BOOKINGHOST-001 to FE-PLANNER-BOOKINGHOST-018 import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -111,6 +111,15 @@ describe('BookingDetailHost', () => { expect(calls).toEqual(['close', 'delete']); }); + it('FE-PLANNER-BOOKINGHOST-018: the delete question says a linked expense goes with the booking (#2604)', async () => { + const r = buildReservation({ title: 'Dinner' }); + useTripStore.setState({ budgetItems: [buildBudgetItem({ name: 'Dinner bill', reservation_id: r.id } as never)] }); + renderHost(r, { onDelete: vi.fn() }); + + await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(screen.getByText('"Dinner" will be permanently deleted, along with its linked costs.')).toBeInTheDocument(); + }); + it('FE-PLANNER-BOOKINGHOST-007: a failed delete says so', async () => { renderHost(buildReservation({ title: 'Dinner' }), { onDelete: vi.fn(async () => { throw new Error('nope'); }) }); diff --git a/client/src/components/Planner/bookings/bookingsModel.test.ts b/client/src/components/Planner/bookings/bookingsModel.test.ts index 6f1fe220e..7687e5f5e 100644 --- a/client/src/components/Planner/bookings/bookingsModel.test.ts +++ b/client/src/components/Planner/bookings/bookingsModel.test.ts @@ -1,4 +1,4 @@ -// FE-PLANNER-BKMODEL-001 to FE-PLANNER-BKMODEL-043 +// FE-PLANNER-BKMODEL-001 to FE-PLANNER-BKMODEL-044 import { describe, it, expect } from 'vitest'; import { Plane, TramFront, FileText, Hotel } from 'lucide-react'; import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../../tests/helpers/factories'; @@ -10,6 +10,7 @@ import { applyFilters, buildAssignmentLookup, costsFor, + deleteBodyKey, daySpan, displayTitle, groupReservations, @@ -337,6 +338,13 @@ describe('costsFor', () => { expect(costsFor(3, [{ reservation_id: 3, total_price: 4 }], '')).toEqual([{ currency: 'EUR', amount: 4 }]); expect(costsFor(4, [{ reservation_id: 3, total_price: 4 }], 'EUR')).toEqual([]); }); + + it('FE-PLANNER-BKMODEL-044: the delete question names the linked expense only when there is one (#2604)', () => { + const items = [{ reservation_id: 3 }, { reservation_id: null }]; + expect(deleteBodyKey(3, items)).toBe('reservations.confirm.deleteBodyWithExpense'); + expect(deleteBodyKey(4, items)).toBe('reservations.confirm.deleteBody'); + expect(deleteBodyKey(undefined, items)).toBe('reservations.confirm.deleteBody'); + }); }); describe('displayTitle', () => { diff --git a/client/src/components/Planner/bookings/bookingsModel.ts b/client/src/components/Planner/bookings/bookingsModel.ts index 659b319fc..17d8bf709 100644 --- a/client/src/components/Planner/bookings/bookingsModel.ts +++ b/client/src/components/Planner/bookings/bookingsModel.ts @@ -317,6 +317,16 @@ export function costsFor(reservationId: number, items: { reservation_id?: number return [...sums.entries()].map(([currency, amount]) => ({ currency, amount })) } +/** + * The delete question's text. The server deletes a booking's linked expenses with it, + * so a booking that has one says so instead of only naming the booking (#2604). + */ +export function deleteBodyKey(reservationId: number | undefined, items: { reservation_id?: number | null }[]): string { + return reservationId != null && items.some(it => it.reservation_id === reservationId) + ? 'reservations.confirm.deleteBodyWithExpense' + : 'reservations.confirm.deleteBody' +} + /** A title for an entry saved without one: its route, else its place, else nothing. */ export function displayTitle(r: Reservation): string { if (r.title?.trim()) return r.title diff --git a/client/src/components/Planner/bookings/useBookingActions.tsx b/client/src/components/Planner/bookings/useBookingActions.tsx index 2e8a0c88d..ee3a232d2 100644 --- a/client/src/components/Planner/bookings/useBookingActions.tsx +++ b/client/src/components/Planner/bookings/useBookingActions.tsx @@ -4,6 +4,7 @@ import { useTripStore } from '../../../store/tripStore' import { useToast } from '../../shared/Toast' import ConfirmDialog from '../../shared/ConfirmDialog' import { useReservationDelete } from './useReservationDelete' +import { deleteBodyKey } from './bookingsModel' /** * The two things a booking can have done to it without its editor: switching @@ -17,6 +18,7 @@ export function useBookingActions(tripId: number, onDelete: (id: number) => unkn const { t } = useTranslation() const toast = useToast() const toggleReservationStatus = useTripStore(s => s.toggleReservationStatus) + const budgetItems = useTripStore(s => s.budgetItems) const { pendingDelete, requestDelete, cancelDelete, confirmDelete } = useReservationDelete(onDelete, () => toast.error(t('reservations.toast.deleteError')), beforeDelete) @@ -30,7 +32,7 @@ export function useBookingActions(tripId: number, onDelete: (id: number) => unkn onClose={cancelDelete} onConfirm={confirmDelete} title={t('reservations.confirm.deleteTitle')} - message={t('reservations.confirm.deleteBody', { name: pendingDelete?.title ?? '' })} + message={t(deleteBodyKey(pendingDelete?.id, budgetItems), { name: pendingDelete?.title ?? '' })} confirmLabel={t('common.delete')} cancelLabel={t('common.cancel')} /> diff --git a/client/src/components/Planner/bookings/useReservationCard.test.ts b/client/src/components/Planner/bookings/useReservationCard.test.ts index b679e813e..6f3029bc1 100644 --- a/client/src/components/Planner/bookings/useReservationCard.test.ts +++ b/client/src/components/Planner/bookings/useReservationCard.test.ts @@ -1,10 +1,12 @@ -// FE-PLANNER-RESLISTFILTER-005 to -008: what one card of the phone's bookings +// FE-PLANNER-RESLISTFILTER-005 to -009: what one card of the phone's bookings // and transports tabs does. import { act, renderHook } from '@testing-library/react'; import type { KeyboardEvent, MouseEvent } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { buildReservation } from '../../../../tests/helpers/factories'; +import { buildBudgetItem, buildReservation } from '../../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { useTripStore } from '../../../store/tripStore'; import type { Reservation } from '../../../types'; import { openFile } from '../../../utils/fileDownload'; import { useReservationCard } from './useReservationCard'; @@ -88,4 +90,11 @@ describe('useReservationCard', () => { expect(openFile).toHaveBeenCalledTimes(3); expect(openFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf'); }); + + it('FE-PLANNER-RESLISTFILTER-009: the card knows whether deleting it takes a linked expense along (#2604)', () => { + resetAllStores(); + seedStore(useTripStore, { budgetItems: [buildBudgetItem({ id: 1, reservation_id: 10 } as never)] }); + expect(renderHook(() => useReservationCard(ann, host())).result.current.deleteTakesExpense).toBe(true); + expect(renderHook(() => useReservationCard(bob, host())).result.current.deleteTakesExpense).toBe(false); + }); }); diff --git a/client/src/components/Planner/bookings/useReservationCard.ts b/client/src/components/Planner/bookings/useReservationCard.ts index d9cd7ea6d..f432a2133 100644 --- a/client/src/components/Planner/bookings/useReservationCard.ts +++ b/client/src/components/Planner/bookings/useReservationCard.ts @@ -1,6 +1,8 @@ import { useState, type KeyboardEvent, type MouseEvent } from 'react'; +import { useTripStore } from '../../../store/tripStore'; import type { Reservation } from '../../../types'; +import { deleteBodyKey } from './bookingsModel'; import { openAttachment } from './openAttachment'; import { useReservationDelete } from './useReservationDelete'; @@ -19,7 +21,8 @@ export interface ReservationCardHost { * behind a question with a toast when it fails, and its attached files, which * open from a chip inside the card's own button (so a click or Enter/Space on * the chip stops there). A file that cannot be opened stays silent here; the - * desktop panel toasts it. + * desktop panel toasts it. The delete question names a linked expense, which + * goes with the booking. */ export function useReservationCard(res: Reservation, host: ReservationCardHost) { const blurCodes = host.settings.blur_booking_codes; @@ -28,11 +31,15 @@ export function useReservationCard(res: Reservation, host: ReservationCardHost) () => host.toast.error(host.t('reservations.toast.deleteError')) ); const [codeRevealed, setCodeRevealed] = useState(false); + const deleteTakesExpense = useTripStore( + (s) => deleteBodyKey(res.id, s.budgetItems) === 'reservations.confirm.deleteBodyWithExpense' + ); return { codeBlurred: !!blurCodes && !codeRevealed, toggleCode: blurCodes ? () => setCodeRevealed((v) => !v) : undefined, confirmingDelete: pendingDelete != null, + deleteTakesExpense, askDelete: () => requestDelete(res), cancelDelete, confirmDelete: () => { diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx index 69c2fca21..e3ff774c3 100644 --- a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx +++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-008 +// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-009 import { fireEvent, render, screen } from '@testing-library/react'; import { Hotel, Plane } from 'lucide-react'; import { describe, expect, it, vi } from 'vitest'; @@ -120,6 +120,12 @@ describe('MReservationCardParts', () => { expect(card.confirmDelete).toHaveBeenCalledOnce(); }); + it('FE-COMP-MRESCARD-009: a booking with a linked expense says the expense is deleted with it (#2604)', () => { + render(); + + expect(screen.getByText('reservations.confirm.deleteBodyWithExpense:LH 400')).toBeInTheDocument(); + }); + it('FE-COMP-MRESCARD-008: the date sits beside the day title and never shrinks, a range stacks the time under it (#2590)', () => { const { container } = render(