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 && } - + ) }