{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx
index d7e0360e5..f42764967 100644
--- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import {
- Bookmark, Camera, ExternalLink, Loader2, Map as MapIcon, Navigation, Paperclip,
+ Bookmark, Camera, ChevronRight, ExternalLink, Loader2, Map as MapIcon, Navigation, Paperclip,
Pencil, Phone, Plus, Route, Trash2, Upload, X,
} from 'lucide-react'
import MSheet from '../../../components/MSheet'
@@ -85,6 +85,35 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
? ((planner.selectedAssignmentId ? dayAssignments.find(a => a.id === planner.selectedAssignmentId) : null)
?? dayAssignments.find(a => a.place?.id === place?.id))
: null
+ // The booking attached to that assignment. The desktop inspector shows this
+ // strip; the phone sheet never did, so a booking reached from a map marker was
+ // just as unreachable here, only invisibly so (#2012).
+ const linkedRes = assignmentInDay
+ ? planner.reservations.find(r => r.assignment_id === assignmentInDay.id) ?? null
+ : null
+ // A ferry or a flight has its own form — the reservation modal cannot hold one.
+ // Resolved up front so a user without the matching right gets no button at all,
+ // rather than one that does nothing (#2012).
+ const canOpenLinkedRes = !!linkedRes && planner.can(
+ planner.TRANSPORT_TYPES.has(linkedRes.type) ? 'day_edit' : 'reservation_edit',
+ planner.trip,
+ )
+ const openLinkedRes = () => {
+ if (!linkedRes || !canOpenLinkedRes) return
+ if (planner.TRANSPORT_TYPES.has(linkedRes.type)) {
+ planner.setEditingTransport(linkedRes)
+ planner.setTransportModalDayId(linkedRes.day_id ?? null)
+ planner.setTransportModalAutomated(false)
+ planner.setShowTransportModal(true)
+ } else {
+ planner.setEditingReservation(linkedRes)
+ planner.setShowReservationModal(true)
+ }
+ // This sheet hangs off the place selection, not the sheet stack, so its own
+ // close() is what dismisses it — otherwise the editor opens on top of it.
+ close()
+ }
+
const participants = assignmentInDay?.participants || []
const participantIds = participants.map(p => p.user_id)
const allJoined = participants.length === 0
@@ -403,6 +432,30 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
)}
+ {/* ── The booking attached to this stop (#2012) ── */}
+ {linkedRes && (
+ <>
+ {t('reservations.title')}
+
+ >
+ )}
+
{/* ── Participants of the selected day's assignment ── */}
{assignmentInDay && members.length > 1 && (
<>
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index e7fc38464..e0859d5ed 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -279,6 +279,22 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
// coarse — a hybrid laptop loads drag-drop-touch instead.
useTouchDragBridge(isTouch && !isMobile)
+ // The place inspector's booking strip opens the editor the booking belongs to.
+ // Handed over as undefined when the right is missing, so the strip stays a
+ // read-only summary rather than a button that does nothing (#2012).
+ const openLinkedTransport = can('day_edit', trip) ? (reservation: Reservation) => {
+ setEditingTransport(reservation)
+ setTransportModalDayId(reservation.day_id ?? null)
+ setTransportModalAutomated(false)
+ setShowTransportModal(true)
+ setMobileSidebarOpen(null)
+ } : undefined
+ const openLinkedReservation = can('reservation_edit', trip) ? (reservation: Reservation) => {
+ setEditingReservation(reservation)
+ setShowReservationModal(true)
+ setMobileSidebarOpen(null)
+ } : undefined
+
const poi = usePoiExplore()
const [glMap, setGlMap] = useState(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
@@ -594,6 +610,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{selectedPlace && !isMobile && (
setSelectedPlaceId(null)}>
e.stopPropagation()}>
= {}) {
ratePlace: vi.fn().mockResolvedValue(undefined),
},
can: vi.fn(() => true),
+ reservations: [] as unknown[],
+ TRANSPORT_TYPES: new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']),
+ setEditingTransport: vi.fn(),
+ setTransportModalDayId: vi.fn(),
+ setTransportModalAutomated: vi.fn(),
+ setShowTransportModal: vi.fn(),
+ setEditingReservation: vi.fn(),
+ setShowReservationModal: vi.fn(),
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
...overrides,
} as unknown as TripPlanner
@@ -429,4 +437,76 @@ describe('MPlaceSheet', () => {
expect(screen.getAllByRole('button', { name: /Automatic color/ })[0]).toHaveAttribute('aria-expanded', 'false')
})
+
+ // ── The booking attached to this stop (#2012) ──
+
+ const bookingPlanner = (res: Record) => makePlanner({
+ selectedAssignmentId: 11,
+ reservations: [res],
+ })
+
+ it('FE-MOB-PLSH-027: shows the booking attached to the day assignment (#2012)', () => {
+ renderSheet(bookingPlanner({ id: 7, assignment_id: 11, title: 'Ferry to Corfu', status: 'pending', type: 'ferry' }))
+
+ expect(screen.getByText('Ferry to Corfu')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-PLSH-028: a transport booking opens the transport editor, not the reservation modal', () => {
+ const planner = bookingPlanner({ id: 7, assignment_id: 11, title: 'Ferry to Corfu', status: 'pending', type: 'ferry', day_id: 2 })
+ renderSheet(planner)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Reservation' }))
+
+ expect(planner.setEditingTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }))
+ expect(planner.setTransportModalDayId).toHaveBeenCalledWith(2)
+ expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
+ expect(planner.setShowReservationModal).not.toHaveBeenCalled()
+ // and the place sheet gets out of the way
+ expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null)
+ })
+
+ it('FE-MOB-PLSH-029: a non-transport booking opens the reservation modal', () => {
+ const planner = bookingPlanner({ id: 8, assignment_id: 11, title: 'Museum tour', status: 'confirmed', type: 'activity' })
+ renderSheet(planner)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Reservation' }))
+
+ expect(planner.setEditingReservation).toHaveBeenCalledWith(expect.objectContaining({ id: 8 }))
+ expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
+ expect(planner.setShowTransportModal).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-PLSH-030: a viewer without edit rights sees the booking but gets no live button', () => {
+ const planner = makePlanner({
+ selectedAssignmentId: 11,
+ reservations: [{ id: 7, assignment_id: 11, title: 'Ferry to Corfu', status: 'pending', type: 'ferry' }],
+ can: vi.fn(() => false),
+ })
+ renderSheet(planner)
+
+ // Still readable — just not a control that would do nothing when pressed.
+ expect(screen.getByText('Ferry to Corfu')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Edit Reservation' })).not.toBeInTheDocument()
+ const row = screen.getByText('Ferry to Corfu').closest('button') as HTMLButtonElement
+ expect(row.disabled).toBe(true)
+ })
+
+ it('FE-MOB-PLSH-030b: day_edit alone is not enough for a plain booking, and vice versa', () => {
+ // A ferry needs day_edit; a member who only has reservation_edit gets no button.
+ const planner = makePlanner({
+ selectedAssignmentId: 11,
+ reservations: [{ id: 7, assignment_id: 11, title: 'Ferry to Corfu', status: 'pending', type: 'ferry' }],
+ can: vi.fn((perm: string) => perm === 'reservation_edit'),
+ })
+ renderSheet(planner)
+
+ const row = screen.getByText('Ferry to Corfu').closest('button') as HTMLButtonElement
+ expect(row.disabled).toBe(true)
+ })
+
+ it('FE-MOB-PLSH-031: a booking on another assignment is not shown here', () => {
+ renderSheet(bookingPlanner({ id: 9, assignment_id: 999, title: 'Someone elses ferry', status: 'pending', type: 'ferry' }))
+
+ expect(screen.queryByText('Someone elses ferry')).not.toBeInTheDocument()
+ })
})