From c45ac648c9cb3d52a3420b3ca12dcd16f5b22668 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 15:47:05 +0200 Subject: [PATCH] refactor(bookings): draw the shared parts of the phone reservation cards once The transports and bookings tabs drew the same title button, edit and delete buttons, date and time row with the booking code, file chips and delete confirmation sheet as two copies. Both cards now render them from MReservationCardParts, and each tab still decides what a tap opens. The rendered markup stays the same for both tabs. --- client/scripts/feature-pairs.json | 2 + .../mobile/screens/trip/tabs/MBookingsTab.tsx | 98 ++--------- .../trip/tabs/MReservationCardParts.test.tsx | 122 +++++++++++++ .../trip/tabs/MReservationCardParts.tsx | 160 ++++++++++++++++++ .../screens/trip/tabs/MTransportsTab.tsx | 99 ++--------- 5 files changed, 313 insertions(+), 168 deletions(-) create mode 100644 client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx create mode 100644 client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 335703b96..9e74bb34c 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -900,6 +900,7 @@ "src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.", "src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.", "src/mobile/screens/trip/roadtrip/useMRtCorridor.ts": "Phone reach and anchor layer over the planner's shared corridor state.", + "src/mobile/screens/trip/sheets/MBookingFields.tsx": "Markup only: the booking code, notes, traveler and route stop fields both phone booking sheets draw.", "src/mobile/screens/trip/sheets/MBookingFilesCosts.tsx": "Phone booking sheet footer that composes PlFileAttach and MLinkedCosts.", "src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.", "src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.", @@ -913,6 +914,7 @@ "src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.", "src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.", "src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.", + "src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.", "src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.", "src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.", "src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).", diff --git a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx index 6b3fc1243..26c5728d7 100644 --- a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx @@ -1,11 +1,11 @@ -import { FileText, MapPin, Pencil, Trash2 } from 'lucide-react' +import { MapPin } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants' import { formatTime, formatPriceText } from '../../../../utils/formatters' import { useTranslation } from '../../../../i18n' import type { Reservation } from '../../../../types' -import MConfirmSheet from '../../settings/MConfirmSheet' -import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' +import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts' +import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { cardWhen, parseTransportMeta } from './transportsModel' import { BOOKING_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel' @@ -136,59 +136,22 @@ function BookingCard({ res, planner, canEdit, compact }: { {/* Header */}
- - {canEdit && ( - - )} - {canEdit && ( - - )} + + {canEdit && }
{/* Body — split around the booking code so the reveal can be its own control instead of a click handler buried inside the card button. */} {!compact && (
- - - {res.confirmation_number && ( - - )} +
)} - {files.length > 0 && ( -
-
- {t('files.title')} -
-
- {/* A span with a button role, not a
-
- )} + )} {!compact && } - + ) } diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx new file mode 100644 index 000000000..18e95e990 --- /dev/null +++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx @@ -0,0 +1,122 @@ +// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-007 +import { fireEvent, render, screen } from '@testing-library/react'; +import { Hotel, Plane } from 'lucide-react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard'; +import type { Reservation } from '../../../../types'; +import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'; + +const t = (key: string, params?: Record) => + params ? `${key}:${Object.values(params).join(',')}` : key; + +function reservation(overrides: Partial = {}): Reservation { + return { id: 1, trip_id: 7, type: 'flight', title: 'LH 400', status: 'confirmed', ...overrides } as Reservation; +} + +function cardState(overrides: Partial> = {}) { + return { + codeBlurred: false, + toggleCode: undefined, + confirmingDelete: false, + askDelete: vi.fn(), + cancelDelete: vi.fn(), + confirmDelete: vi.fn(), + fileChip: () => ({ onClick: vi.fn(), onKeyDown: vi.fn() }), + ...overrides, + } as unknown as ReturnType; +} + +describe('MReservationCardParts', () => { + it('FE-COMP-MRESCARD-001: the title button shows the type pill and title, and opens the card (transport variant)', () => { + const onOpen = vi.fn(); + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveTextContent('reservations.type.flight'); + expect(button).toHaveTextContent('LH 400'); + expect(screen.queryByText('reservations.needsReview')).toBeNull(); + expect(button.querySelector('.lucide-plane')).toHaveStyle({ color: '#123456' }); + fireEvent.click(button); + expect(onOpen).toHaveBeenCalledOnce(); + }); + + it('FE-COMP-MRESCARD-002: the title button flags a booking that needs review (booking variant)', () => { + render( + )} + TypeIcon={Hotel} + typeColor="#654321" + onOpen={vi.fn()} + t={t} + /> + ); + + expect(screen.getByText('reservations.type.hotel')).toBeInTheDocument(); + expect(screen.getByText('reservations.needsReview')).toBeInTheDocument(); + }); + + it('FE-COMP-MRESCARD-003: the actions call edit and delete', () => { + const onEdit = vi.fn(); + const onDelete = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'common.edit' })); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + expect(onEdit).toHaveBeenCalledOnce(); + expect(onDelete).toHaveBeenCalledOnce(); + }); + + it('FE-COMP-MRESCARD-004: date and time open the card, and a missing code draws no code box', () => { + const onOpen = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + expect(onOpen).toHaveBeenCalledOnce(); + expect(screen.getByText('Mon 3')).toBeInTheDocument(); + expect(screen.getByText('09:00')).toBeInTheDocument(); + expect(screen.queryByText('reservations.confirmationCode')).toBeNull(); + }); + + it('FE-COMP-MRESCARD-005: a blurred code is its own reveal control', () => { + const toggleCode = vi.fn(); + render( + + ); + + const reveal = screen.getByRole('button', { name: 'ABC123' }); + expect(reveal).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(reveal); + expect(toggleCode).toHaveBeenCalledOnce(); + }); + + it('FE-COMP-MRESCARD-006: files render as button-role chips, and no files render nothing', () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + + const files = [{ id: 5, original_name: 'ticket.pdf', url: '/uploads/f/5' }] as Parameters< + typeof CardFiles + >[0]['files']; + rerender(); + expect(screen.getByText('files.title')).toBeInTheDocument(); + const chip = screen.getByRole('button', { name: 'ticket.pdf' }); + expect(chip.tagName).toBe('SPAN'); + expect(chip).toHaveAttribute('tabindex', '0'); + }); + + it('FE-COMP-MRESCARD-007: the delete sheet names the reservation and confirms through the card', () => { + const card = cardState({ confirmingDelete: true }); + render(); + + expect(screen.getByText('reservations.confirm.deleteBody:LH 400')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + expect(card.confirmDelete).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx new file mode 100644 index 000000000..75b033c71 --- /dev/null +++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx @@ -0,0 +1,160 @@ +import { FileText, type LucideIcon, Pencil, Trash2 } from 'lucide-react'; + +import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard'; +import type { useTranslation } from '../../../../i18n'; +import type { Reservation } from '../../../../types'; +import type { filesFor } from '../../../../utils/reservationFiles'; +import MConfirmSheet from '../../settings/MConfirmSheet'; +import { ConfirmationCode, Field } from './tabChrome'; + +/** + * Pieces of the phone reservation card that the transports and bookings tabs + * draw the same way. Each tab keeps its own card and decides what a tap does + * (the transports tab opens the detail sheet, the bookings tab the edit modal); + * these only paint the shared markup. + */ + +type T = ReturnType['t']; +type CardState = ReturnType; + +/** Header button: type pill, title and the needs-review badge. */ +export function CardTitleButton({ + res, + TypeIcon, + typeColor, + onOpen, + t, +}: { + res: Reservation; + TypeIcon: LucideIcon; + typeColor: string; + onOpen: () => void; + t: T; +}) { + return ( + + ); +} + +/** Round edit and delete buttons at the end of the header. */ +export function CardActions({ onEdit, onDelete, t }: { onEdit: () => void; onDelete: () => void; t: T }) { + return ( + <> + + + + ); +} + +/** + * Date and time row, followed by the booking code. The code sits outside the + * row button so its reveal can be its own control. + */ +export function CardWhenAndCode({ + dayValue, + timeValue, + code, + card, + onOpen, + t, +}: { + dayValue: string; + timeValue: string; + code: string | null | undefined; + card: CardState; + onOpen: () => void; + t: T; +}) { + return ( + <> + + + {code && ( + + )} + + ); +} + +/** Attached files, listed inside the card body button. */ +export function CardFiles({ files, card, t }: { files: ReturnType; card: CardState; t: T }) { + if (files.length === 0) return null; + return ( +
+
+ {t('files.title')} +
+
+ {/* A span with a button role, not a
+
+ ); +} + +/** Confirmation sheet behind the delete button. */ +export function CardDeleteSheet({ title, card, t }: { title: string; card: CardState; t: T }) { + return ( + + ); +} diff --git a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx index 0a7ebaf46..ad93bde64 100644 --- a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx @@ -1,11 +1,10 @@ -import { FileText, Pencil, Trash2 } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants' import { formatPriceText } from '../../../../utils/formatters' import { useTranslation } from '../../../../i18n' import type { Reservation } from '../../../../types' -import MConfirmSheet from '../../settings/MConfirmSheet' -import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' +import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts' +import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { cardWhen, parseTransportMeta } from './transportsModel' import { TRANSPORT_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel' @@ -149,63 +148,22 @@ function TransportCard({ res, planner, shell, canEdit, compact }: { ) : ( )} - - {canEdit && ( - - )} - {canEdit && ( - - )} + + {canEdit && } {/* Body — split around the booking code so the reveal can be its own control instead of a click handler buried inside the card button. */} {!compact && (
- - - {res.confirmation_number && ( - - )} +
)} {!compact && } - + ) }