feat(reservations): traveler filter + transit-card badge (desktop) and full mobile traveler UI (cards + sheet pickers)

This commit is contained in:
Maurice
2026-07-24 14:32:27 +02:00
committed by Maurice
parent 8d7753c2e1
commit e89dae5528
6 changed files with 236 additions and 10 deletions
@@ -623,6 +623,11 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
</span>
</div>
)}
{r.travelers && r.travelers.length > 0 && (
<div style={{ paddingLeft: 44 }} onClick={e => e.stopPropagation()}>
<TravelerChips travelers={r.travelers} onSetTravelers={() => {}} readOnly compact={false} />
</div>
)}
<PluginCardFooter items={contributions} tripId={tripId} />
{/* 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<Set<number>>(() => {
try {
const saved = sessionStorage.getItem(`${storageKey}-travelers`)
return saved ? new Set<number>(JSON.parse(saved)) : new Set<number>()
} catch { return new Set<number>() }
})
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) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }} title={t('reservations.travelers.label')}>
{tripMembers.map(tm => {
const active = travelerFilters.has(tm.id)
return (
<button key={tm.id} type="button" onClick={() => toggleTravelerFilter(tm.id)} title={tm.username}
style={{
width: 26, height: 26, borderRadius: '50%', cursor: 'pointer', padding: 0, flexShrink: 0,
border: active ? '2px solid var(--accent)' : '2px solid var(--border-primary)',
background: 'var(--bg-tertiary)', overflow: 'hidden',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: 'var(--text-muted)',
opacity: active || travelerFilters.size === 0 ? 1 : 0.45,
transition: 'opacity 0.15s, border-color 0.15s',
}}>
{tm.avatar_url
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
: tm.username?.[0]?.toUpperCase()}
</button>
)
})}
</div>
)}
{canEdit && (
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0 }}>
{onImport && bookingImportAvailable && (
@@ -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<File[]>([])
// Travelers assigned to this booking (#1517) — seeded from the editing
// reservation on open, persisted separately after the save resolves.
const [travelerIds, setTravelerIds] = useState<Set<number>>(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
? <img src={m.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, opacity: dim ? 0.45 : 1 }} />
: (
<span
style={{
width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient,
color: '#fff', display: 'grid', placeItems: 'center', fontSize: 8.8, fontWeight: 700, flexShrink: 0, opacity: dim ? 0.45 : 1,
}}
>
{(m.username || '?').charAt(0).toUpperCase()}
</span>
)
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 */}
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.travelers.label')}</Eyebrow>
{tripMembers.length === 0 ? (
<div className="text-[0.71875rem] text-m-faint">{t('reservations.travelers.none')}</div>
) : (
<div className="flex flex-col gap-[6px]">
{tripMembers.map((m, idx) => {
const on = travelerIds.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => toggleTraveler(m.id)}
className={`${TRAVELER_ROW_CLS} ${on ? '' : 'opacity-60'}`}
>
<TravelerAvatar m={m} idx={idx} dim={!on} />
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-medium text-m-ink">{m.username}</span>
{m.is_guest && <GuestBadge size="xs" />}
{on && <Check size={15} strokeWidth={2.4} className="flex-none text-m-act" />}
</button>
)
})}
</div>
)}
{/* FILES */}
{canUploadFiles && (
<PlFileAttach
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Bike, Bus, Car, CarTaxiFront, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2 } from 'lucide-react'
import { Bike, Bus, Car, CarTaxiFront, Check, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2 } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
@@ -13,7 +13,10 @@ import LocationSelect, { type LocationPoint } from '../../../../components/Plann
import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import PlFileAttach from './PlFileAttach'
import type { Day, Place, Reservation, ReservationEndpoint } from '../../../../types'
import GuestBadge from '../../../../components/shared/GuestBadge'
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
import { useTripStore } from '../../../../store/tripStore'
import type { Day, Place, Reservation, ReservationEndpoint, TripMember } from '../../../../types'
import type { BookingReviewDraft } from '../../../../components/Planner/parsedItemToDraft'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
import type { TripPlanner } from '../MTripShell'
@@ -92,6 +95,9 @@ function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
}
// 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: 'flight' as TransportType,
@@ -115,7 +121,7 @@ const EMPTY = {
*/
export default function MTransportFormSheet({ planner, onOpenExpense }: MTransportFormSheetProps) {
const {
t, toast, tripId, trip, days, places, assignments, tripAccommodations,
t, toast, tripId, trip, days, places, assignments, tripAccommodations, tripMembers,
showTransportModal, setShowTransportModal,
editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
@@ -126,6 +132,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
canUploadFiles, tripActions,
} = planner
const { locale } = useTranslation()
const setReservationTravelers = useTripStore(s => 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<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
// Travelers assigned to this booking (#1517) — seeded from the editing
// reservation on open, persisted separately after the save resolves.
const [travelerIds, setTravelerIds] = useState<Set<number>>(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
? <img src={m.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, opacity: dim ? 0.45 : 1 }} />
: (
<span
style={{
width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient,
color: '#fff', display: 'grid', placeItems: 'center', fontSize: 8.8, fontWeight: 700, flexShrink: 0, opacity: dim ? 0.45 : 1,
}}
>
{(m.username || '?').charAt(0).toUpperCase()}
</span>
)
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 */}
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.travelers.label')}</Eyebrow>
{tripMembers.length === 0 ? (
<div className="text-[0.71875rem] text-m-faint">{t('reservations.travelers.none')}</div>
) : (
<div className="flex flex-col gap-[6px]">
{tripMembers.map((m, idx) => {
const on = travelerIds.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => toggleTraveler(m.id)}
className={`${TRAVELER_ROW_CLS} ${on ? '' : 'opacity-60'}`}
>
<TravelerAvatar m={m} idx={idx} dim={!on} />
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-medium text-m-ink">{m.username}</span>
{m.is_guest && <GuestBadge size="xs" />}
{on && <Check size={15} strokeWidth={2.4} className="flex-none text-m-act" />}
</button>
)
})}
</div>
)}
{/* FILES */}
{canUploadFiles && (
<PlFileAttach
@@ -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 { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -214,6 +214,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
</div>
)}
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
{res.location && (
<div className="mt-2 flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]">
<MapPin size={12} strokeWidth={2} className="flex-none text-m-muted" />
@@ -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 }: {
</div>
)}
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
{files.length > 0 && (
<div className="mt-2">
<div className="mb-[3px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
@@ -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 <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} />
}
/**
* 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 (
<div className="mt-2">
<div className="mb-[3px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
{label}
</div>
<div className="flex flex-wrap items-center gap-[6px]">
{travelers.map(tv => {
const src = tv.avatar_url || avatarSrc(tv.avatar)
return (
<span
key={tv.user_id}
className="flex items-center gap-[6px] rounded-full border border-[color:var(--m-rowbr)] bg-m-card py-[3px] pl-[3px] pr-[10px]"
>
<span className="flex h-[20px] w-[20px] flex-none items-center justify-center overflow-hidden rounded-full bg-m-act text-[0.5625rem] font-extrabold text-m-actfg">
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : tv.username?.[0]?.toUpperCase()}
</span>
<span className="truncate text-[0.71875rem] font-semibold text-m-ink">{tv.username}</span>
{!!tv.is_guest && <GuestBadge size="xs" />}
</span>
)
})}
</div>
</div>
)
}