fix(bookings): the delete question for a booking with a linked expense says the expense is deleted too

Deleting a booking also deletes the expenses linked to it, but the
question only said the booking would be deleted. A booking with a linked
expense now says so in every place that asks: the bookings list and detail,
the transport and transit dialogs, and the phone's booking cards. The MCP
delete_reservation description names the linked expenses and the stay a
hotel booking created as well.

Refs #2604
This commit is contained in:
Maurice
2026-10-12 20:42:55 +02:00
parent 62a80f420c
commit d4f4ab3891
38 changed files with 94 additions and 13 deletions
@@ -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 })}
/>
</Modal>
)
@@ -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 () => {
@@ -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'); }) });
@@ -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', () => {
@@ -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
@@ -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')}
/>
@@ -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);
});
});
@@ -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: () => {
@@ -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(<CardDeleteSheet title="LH 400" card={cardState({ confirmingDelete: true, deleteTakesExpense: true })} t={t} />);
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(
<CardWhenAndCode
@@ -161,7 +161,7 @@ export function CardDeleteSheet({ title, card, t }: { title: string; card: CardS
open={card.confirmingDelete}
onClose={card.cancelDelete}
title={t('reservations.confirm.deleteTitle')}
message={t('reservations.confirm.deleteBody', { name: title })}
message={t(card.deleteTakesExpense ? 'reservations.confirm.deleteBodyWithExpense' : 'reservations.confirm.deleteBody', { name: title })}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger