diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 6b9902969..f61d3c7f6 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -623,6 +623,11 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri )} + {r.travelers && r.travelers.length > 0 && ( +
e.stopPropagation()}> + {}} readOnly compact={false} /> +
+ )} {/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The card itself is clickable, so keep frame interactions from opening it. */} @@ -705,11 +710,31 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme }) } + // Filter by assigned traveler (#1517, #1557), persisted alongside the type filter. + const [travelerFilters, setTravelerFilters] = useState>(() => { + try { + const saved = sessionStorage.getItem(`${storageKey}-travelers`) + return saved ? new Set(JSON.parse(saved)) : new Set() + } catch { return new Set() } + }) + const toggleTravelerFilter = (userId: number) => { + setTravelerFilters(prev => { + const next = new Set(prev) + if (next.has(userId)) next.delete(userId); else next.add(userId) + sessionStorage.setItem(`${storageKey}-travelers`, JSON.stringify([...next])) + return next + }) + } + const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments]) - const filtered = useMemo(() => - typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)), - [reservations, typeFilters]) + const filtered = useMemo(() => { + let list = typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)) + if (travelerFilters.size > 0) { + list = list.filter(r => (r.travelers || []).some(tv => travelerFilters.has(tv.user_id))) + } + return list + }, [reservations, typeFilters, travelerFilters]) // Chronological order (#1507): day-linked transports often carry no date in // reservation_time, so resolve each entry to an effective departure datetime — @@ -818,6 +843,31 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme )} + {/* Filter by traveler — members/guests assigned to bookings (#1517/#1557). */} + {tripMembers.length > 1 && reservations.some(r => (r.travelers || []).length > 0) && ( +
+ {tripMembers.map(tm => { + const active = travelerFilters.has(tm.id) + return ( + + ) + })} +
+ )} + {canEdit && (
{onImport && bookingImportAvailable && ( diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index 90536f4f5..6d7f5406a 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { FileText, Hotel, Link2, ParkingSquare, Plus, Ticket, Users, Utensils } from 'lucide-react' +import { Check, FileText, Hotel, Link2, ParkingSquare, Plus, Ticket, Users, Utensils } from 'lucide-react' import MSheet from '../../../components/MSheet' import { useAddonStore } from '../../../../store/addonStore' import { useTranslation } from '../../../../i18n' @@ -10,6 +10,10 @@ import CustomTimePicker from '../../../../components/shared/CustomTimePicker' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import PlFileAttach from './PlFileAttach' +import GuestBadge from '../../../../components/shared/GuestBadge' +import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants' +import { useTripStore } from '../../../../store/tripStore' +import type { TripMember } from '../../../../types' import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types' import type { TripPlanner } from '../MTripShell' @@ -27,6 +31,9 @@ const TYPE_OPTIONS = [ { value: 'other', labelKey: 'reservations.type.other', Icon: FileText }, ] +// Traveler picker row — same surface as the cost-split rows (bg on --m-ic). +const TRAVELER_ROW_CLS = 'flex w-full items-center gap-[9px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[9px] text-left' + const EMPTY = { title: '', type: 'other', status: 'pending', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', @@ -45,7 +52,7 @@ const EMPTY = { */ export default function MReservationSheet({ planner, onOpenExpense }: MReservationSheetProps) { const { - t, toast, tripId, days, places, tripAccommodations, selectedDayId, + t, toast, tripId, days, places, tripAccommodations, tripMembers, selectedDayId, showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, reservationPrefill, bookingForAssignmentId, setBookingForAssignmentId, @@ -53,11 +60,15 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati handleSaveReservation, canUploadFiles, tripActions, } = planner const { locale } = useTranslation() + const setReservationTravelers = useTripStore(s => s.setReservationTravelers) const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const [form, setForm] = useState(EMPTY) const [pendingFiles, setPendingFiles] = useState([]) + // Travelers assigned to this booking (#1517) — seeded from the editing + // reservation on open, persisted separately after the save resolves. + const [travelerIds, setTravelerIds] = useState>(new Set()) const [isSaving, setIsSaving] = useState(false) // Ref (not state) so handleSubmit reads the intent set by the same click. const expenseIntentRef = useRef(false) @@ -71,6 +82,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati setSnap({ res: editingReservation, assignmentId: bookingForAssignmentId ?? null }) expenseIntentRef.current = false setPendingFiles([]) + setTravelerIds(new Set((editingReservation?.travelers || []).map(tv => tv.user_id))) const res = editingReservation if (res) { @@ -122,6 +134,26 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati const isHotel = form.type === 'hotel' const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value })) + const toggleTraveler = (id: number) => setTravelerIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + + const TravelerAvatar = ({ m, idx, dim }: { m: TripMember; idx: number; dim: boolean }) => + m.avatar_url + ? + : ( + + {(m.username || '?').charAt(0).toUpperCase()} + + ) + const isEndBeforeStart = (() => { if (isHotel || !form.end_date || !form.reservation_time) return false const sDate = form.reservation_time.split('T')[0] @@ -194,6 +226,15 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati } } const saved = await handleSaveReservation(saveData as never) + // Persist the traveler assignment once we have the reservation id (from the + // save result on create, or the edited reservation) — only when it changed. + const savedId = saved?.id ?? res?.id + if (savedId) { + const original = (res?.travelers || []).map(tv => tv.user_id) + const next = [...travelerIds] + const changed = original.length !== next.length || next.some(id => !original.includes(id)) + if (changed) await setReservationTravelers(tripId, savedId, next) + } if (!res?.id && saved?.id && pendingFiles.length > 0 && canUploadFiles) { for (const file of pendingFiles) { const fd = new FormData() @@ -461,6 +502,31 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati className={FIELD_AREA_CLS} /> + {/* TRAVELERS */} + {t('reservations.travelers.label')} + {tripMembers.length === 0 ? ( +
{t('reservations.travelers.none')}
+ ) : ( +
+ {tripMembers.map((m, idx) => { + const on = travelerIds.has(m.id) + return ( + + ) + })} +
+ )} + {/* FILES */} {canUploadFiles && ( s.setReservationTravelers) const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const tripHasDates = Boolean(trip?.start_date && trip?.end_date) @@ -137,6 +144,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo const [waypoints, setWaypoints] = useState([emptyWaypoint(), emptyWaypoint()]) const [trainWaypoints, setTrainWaypoints] = useState([emptyStationWaypoint(), emptyStationWaypoint()]) const [pendingFiles, setPendingFiles] = useState([]) + // Travelers assigned to this booking (#1517) — seeded from the editing + // reservation on open, persisted separately after the save resolves. + const [travelerIds, setTravelerIds] = useState>(new Set()) const [isSaving, setIsSaving] = useState(false) // Ref (not state) so handleSubmit reads the intent set by the same click — a // state value would be stale in that render's closure and never open the editor. @@ -153,6 +163,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo setDeleteArmed(false) // On a review-import, seed the booking's Files with the parsed source document. setPendingFiles(!editingTransport && transportPrefill?._sourceFiles ? transportPrefill._sourceFiles : []) + setTravelerIds(new Set((editingTransport?.travelers || []).map(tv => tv.user_id))) // Edit uses the saved `editingTransport`; a review-import populates from the // prefill. Either way the init reads the same fields; the reservation still @@ -272,6 +283,26 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo const prefill = snap.prefill const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value })) + const toggleTraveler = (id: number) => setTravelerIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + + const TravelerAvatar = ({ m, idx, dim }: { m: TripMember; idx: number; dim: boolean }) => + m.avatar_url + ? + : ( + + {(m.username || '?').charAt(0).toUpperCase()} + + ) + const showModeToggle = !res && tripHasDates const dayOptions = [ @@ -469,6 +500,15 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo } } const saved = await saveTransport(payload) + // Persist the traveler assignment once we have the reservation id (from the + // save result on create, or the edited reservation) — only when it changed. + const savedId = saved?.id ?? res?.id + if (savedId) { + const original = (res?.travelers || []).map(tv => tv.user_id) + const next = [...travelerIds] + const changed = original.length !== next.length || next.some(id => !original.includes(id)) + if (changed) await setReservationTravelers(tripId, savedId, next) + } if (!res?.id && saved?.id && pendingFiles.length > 0 && canUploadFiles) { for (const file of pendingFiles) { const fd = new FormData() @@ -853,6 +893,31 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo className={FIELD_AREA_CLS} /> + {/* TRAVELERS */} + {t('reservations.travelers.label')} + {tripMembers.length === 0 ? ( +
{t('reservations.travelers.none')}
+ ) : ( +
+ {tripMembers.map((m, idx) => { + const on = travelerIds.has(m.id) + return ( + + ) + })} +
+ )} + {/* FILES */} {canUploadFiles && ( )} + + {res.location && (
diff --git a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx index 954ff7c19..e3ff0097c 100644 --- a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx @@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload' import { useTranslation } from '../../../../i18n' import type { Reservation } from '../../../../types' import MConfirmSheet from '../../settings/MConfirmSheet' -import { CountPill, Field, SectionHeader, StatusDot, TabScroller } from './tabChrome' +import { CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars } from './tabChrome' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { TRANSPORT_TYPE_COLOR, @@ -247,6 +247,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
)} + + {files.length > 0 && (
diff --git a/client/src/mobile/screens/trip/tabs/tabChrome.tsx b/client/src/mobile/screens/trip/tabs/tabChrome.tsx index 893d77a8d..d8aaa50a1 100644 --- a/client/src/mobile/screens/trip/tabs/tabChrome.tsx +++ b/client/src/mobile/screens/trip/tabs/tabChrome.tsx @@ -1,5 +1,8 @@ import type { ReactNode } from 'react' import { ChevronDown, ChevronUp } from 'lucide-react' +import type { ReservationTraveler } from '@trek/shared' +import { avatarSrc } from '../../../../utils/avatarSrc' +import GuestBadge from '../../../../components/shared/GuestBadge' /** * Scroll body shared by the list-style trip tabs (transports, bookings, costs, @@ -89,3 +92,41 @@ export function Field({ label, children, className = '', tabular = false }: { export function StatusDot({ color }: { color: string }) { return } + +/** + * Read-only avatar-chip cluster for a reservation's assigned travelers (#1517). + * The mobile transport / booking cards only display them — assignment editing + * lives in the reservation sheet, so there is no picker here. Reuses the shared + * avatarSrc + GuestBadge like the cost member chips; renders nothing when nobody + * is assigned. `label` comes from 'reservations.travelers.label'. + */ +export function TravelerAvatars({ travelers, label }: { + travelers: ReservationTraveler[] + label: string +}) { + if (travelers.length === 0) return null + return ( +
+
+ {label} +
+
+ {travelers.map(tv => { + const src = tv.avatar_url || avatarSrc(tv.avatar) + return ( + + + {src ? : tv.username?.[0]?.toUpperCase()} + + {tv.username} + {!!tv.is_guest && } + + ) + })} +
+
+ ) +}