refactor(reservations): move desktop traveler assignment into the edit/create modals

The always-on card picker is gone — travelers are assigned in the booking and transport modals now, matching mobile. The card shows assigned travelers read-only as pills in the grey field box, TravelerChips is replaced by a shared TravelerPicker, and the toolbar filter gets gradient avatars with an accent ring and check badge.
This commit is contained in:
Maurice
2026-07-24 16:41:51 +02:00
committed by Maurice
parent 2a5c54c025
commit 8769ed084e
8 changed files with 308 additions and 230 deletions
@@ -15,6 +15,8 @@ import { openFile } from '../../utils/fileDownload'
import { resolveDayId } from '../../utils/formatters'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared'
@@ -71,11 +73,14 @@ interface ReservationModalProps {
// Pre-fill a brand-new booking from a parsed import item (review-before-save).
// Distinct from `reservation`: the form is populated but stays in create mode.
prefill?: BookingReviewDraft | null
/** Trip members + guests, for the traveler picker (#1517). */
tripMembers?: TripMember[]
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null }: ReservationModalProps) {
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null, tripMembers = [] }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles)
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
const toast = useToast()
const { t, locale } = useTranslation()
const fileInputRef = useRef(null)
@@ -99,6 +104,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
// Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
@@ -116,6 +123,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
return loose?.id ?? ''
}
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
const rawEnd = reservation.reservation_end_time || ''
@@ -213,6 +221,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const set = (field, value) => 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 isEndBeforeStart = (() => {
if (!form.end_date || !form.reservation_time) return false
const startDate = form.reservation_time.split('T')[0]
@@ -291,6 +305,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}
}
const saved = await onSave(saveData)
// Persist the traveler assignment once we have the reservation id (create → save
// result, edit → existing reservation), and only when it actually changed (#1517).
const savedId = saved?.id ?? reservation?.id
if (savedId && tripId) {
const original = (reservation?.travelers || []).map(tv => tv.user_id)
const nextIds = [...travelerIds]
const changed = original.length !== nextIds.length || nextIds.some(id => !original.includes(id))
if (changed) {
try { await setReservationTravelers(tripId, savedId, nextIds) } catch { toast.error(t('common.unknownError')) }
}
}
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
@@ -658,6 +683,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
className={inputClass} style={{ resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Travelers — assign trip members & guests to this booking (#1517) */}
<div>
<label className={labelClass}>{t('reservations.travelers.label')}</label>
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
</div>
{/* Files */}
<div>
<label className={labelClass}>{t('files.title')}</label>
@@ -23,7 +23,7 @@ import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'
import PluginFrame from '../Plugins/PluginFrame'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import EmptyState from '../shared/EmptyState'
import TravelerChips from './TravelerChips'
import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
interface AssignmentLookupEntry {
@@ -87,12 +87,10 @@ interface ReservationCardProps {
contributions?: ViewContribution[]
/** Plugins that declared a reservation-detail slot — mounted at the card's foot, scoped to this reservation. */
detailPlugins?: ActivePlugin[]
/** Trip members + guests, for the traveler picker (#1517). */
tripMembers?: TripMember[]
}
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [], tripMembers = [] }: ReservationCardProps) {
const { toggleReservationStatus, setReservationTravelers } = useTripStore()
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [] }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -432,22 +430,17 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
</div>
)}
{/* Travelers assigned to this booking — read-only; assignment lives in the edit/create modal (#1517). */}
{r.travelers && r.travelers.length > 0 && (
<div>
<div className={fieldLabelClass}>{t('reservations.travelers.label')}</div>
<div className={fieldValueClass}>
<TravelerAvatarRow travelers={r.travelers} />
</div>
</div>
)}
</div>
{/* Travelers this booking is for — members/guests (#1517). */}
{(canEdit || (r.travelers && r.travelers.length > 0)) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px 12px' }}>
<span className={fieldLabelClass} style={{ marginBottom: 0 }}>{t('reservations.travelers.label')}</span>
<TravelerChips
travelers={r.travelers}
tripMembers={tripMembers}
onSetTravelers={(ids) => { void setReservationTravelers(tripId, r.id, ids) }}
readOnly={!canEdit}
compact={false}
/>
</div>
)}
<PluginCardFooter items={contributions} tripId={tripId} />
{/* Reservation-detail plugin slots: sandboxed, scoped to this reservation. */}
@@ -625,7 +618,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
)}
{r.travelers && r.travelers.length > 0 && (
<div style={{ paddingLeft: 44 }} onClick={e => e.stopPropagation()}>
<TravelerChips travelers={r.travelers} onSetTravelers={() => {}} readOnly compact={false} />
<TravelerAvatarRow travelers={r.travelers} />
</div>
)}
<PluginCardFooter items={contributions} tripId={tripId} />
@@ -845,27 +838,10 @@ 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>
<>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<TravelerFilterAvatars members={tripMembers} active={travelerFilters} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
</>
)}
{canEdit && (
@@ -934,12 +910,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
)}
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} contributions={contribFor(r.id)} detailPlugins={reservationDetailPlugins} tripMembers={tripMembers} />)}
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} contributions={contribFor(r.id)} detailPlugins={reservationDetailPlugins} />)}
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green" storageKey={`trek:bookings-confirmed-open:${tripId}`}>
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} contributions={contribFor(r.id)} detailPlugins={reservationDetailPlugins} tripMembers={tripMembers} />)}
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} contributions={contribFor(r.id)} detailPlugins={reservationDetailPlugins} />)}
</Section>
)}
</>
@@ -16,6 +16,8 @@ import apiClient from '../../api/client'
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
@@ -160,15 +162,18 @@ interface TransportModalProps {
tripHasDates?: boolean
/** Pre-seed the transit search — used by "change route" on an existing journey. */
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null
/** Trip members + guests, for the traveler picker (#1517). */
tripMembers?: TripMember[]
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
const loadFiles = useTripStore(s => s.loadFiles)
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
const { id: tripId } = useParams<{ id: string }>()
// Set right before submitting when the user clicked "create/edit expense", so
// the post-save handler knows to open the Costs editor for the saved booking.
@@ -187,10 +192,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
// Travelers assigned to this booking (#1517) — seeded on open, persisted after save.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isOpen) return
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
// Either way the init reads the same fields — `reservation` still decides
// edit-vs-create at submit time.
@@ -317,6 +325,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const set = (field: string, value: any) => 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 handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!form.title.trim()) return
@@ -507,6 +521,17 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
}
const saved = await onSave(payload)
// Persist the traveler assignment once we have the reservation id (create → save
// result, edit → existing reservation), and only when it actually changed (#1517).
const savedId = saved?.id ?? reservation?.id
if (savedId && tripId) {
const original = (reservation?.travelers || []).map(tv => tv.user_id)
const nextIds = [...travelerIds]
const changed = original.length !== nextIds.length || nextIds.some(id => !original.includes(id))
if (changed) {
try { await setReservationTravelers(tripId, savedId, nextIds) } catch { toast.error(t('common.unknownError')) }
}
}
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
const fd = new FormData()
@@ -926,6 +951,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
className={inputClass} style={{ resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Travelers — assign trip members & guests to this transport (#1517) */}
<div>
<label className={labelClass}>{t('reservations.travelers.label')}</label>
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
</div>
{/* Files */}
<div>
<label className={labelClass}>{t('files.title')}</label>
@@ -1,66 +0,0 @@
// FE-COMP-TRAVELERS-001 to FE-COMP-TRAVELERS-006
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { ReservationTraveler } from '@trek/shared';
import type { TripMember } from '../Budget/BudgetPanelMemberChips';
import TravelerChips from './TravelerChips';
const tripMembers: TripMember[] = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
];
const travelers: ReservationTraveler[] = [
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
{ user_id: 2, username: 'bob', avatar_url: '/uploads/avatars/bob.jpg' },
];
describe('TravelerChips', () => {
it('FE-COMP-TRAVELERS-001: renders an avatar chip per traveler', () => {
render(<TravelerChips travelers={travelers} tripMembers={tripMembers} onSetTravelers={vi.fn()} />);
// Each assigned traveler carries an avatar_url, so one <img> renders per chip.
expect(document.querySelectorAll('img')).toHaveLength(2);
});
it('FE-COMP-TRAVELERS-002: picker button opens the dropdown listing trip members', async () => {
const user = userEvent.setup();
render(<TravelerChips travelers={[]} tripMembers={tripMembers} onSetTravelers={vi.fn()} />);
// Dropdown starts closed — members are not in the DOM yet.
expect(screen.queryByText('alice')).not.toBeInTheDocument();
// reservations.travelers.assign = "Assign travelers"
await user.click(screen.getByRole('button', { name: 'Assign travelers' }));
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
});
it('FE-COMP-TRAVELERS-003: toggling a member on calls onSetTravelers with the added id', async () => {
const user = userEvent.setup();
const onSetTravelers = vi.fn();
render(<TravelerChips travelers={[]} tripMembers={tripMembers} onSetTravelers={onSetTravelers} />);
await user.click(screen.getByRole('button', { name: 'Assign travelers' }));
await user.click(screen.getByText('alice'));
expect(onSetTravelers).toHaveBeenCalledWith([1]);
});
it('FE-COMP-TRAVELERS-004: toggling an assigned member off calls onSetTravelers without their id', async () => {
const user = userEvent.setup();
const onSetTravelers = vi.fn();
render(<TravelerChips travelers={[travelers[0]]} tripMembers={tripMembers} onSetTravelers={onSetTravelers} />);
await user.click(screen.getByRole('button', { name: 'Assign travelers' }));
await user.click(screen.getByText('alice'));
expect(onSetTravelers).toHaveBeenCalledWith([]);
});
it('FE-COMP-TRAVELERS-005: read-only with no travelers renders nothing', () => {
const { container } = render(
<TravelerChips travelers={[]} tripMembers={tripMembers} onSetTravelers={vi.fn()} readOnly />
);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-TRAVELERS-006: read-only with travelers renders chips but hides the picker button', () => {
render(<TravelerChips travelers={travelers} tripMembers={tripMembers} onSetTravelers={vi.fn()} readOnly />);
expect(document.querySelectorAll('img')).toHaveLength(2);
expect(screen.queryByRole('button', { name: 'Assign travelers' })).not.toBeInTheDocument();
});
});
@@ -1,117 +0,0 @@
import ReactDOM from 'react-dom'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Pencil, Users, Check } from 'lucide-react'
import { ChipWithTooltip, type TripMember } from '../Budget/BudgetPanelMemberChips'
import type { ReservationTraveler } from '@trek/shared'
import { useTranslation } from '../../i18n'
interface TravelerChipsProps {
travelers?: ReservationTraveler[]
tripMembers?: TripMember[]
onSetTravelers: (userIds: number[]) => void
compact?: boolean
readOnly?: boolean
}
/**
* Avatar-chip cluster + member/guest picker for a reservation's travelers (#1517).
* Reuses the budget member ChipWithTooltip for the avatars, but drops the
* cost-split (paid) semantics — a booking traveler is just a person, not a payer.
* The picker list already includes named guests (they ride along on tripMembers).
*/
export default function TravelerChips({ travelers = [], tripMembers = [], onSetTravelers, compact = true, readOnly = false }: TravelerChipsProps) {
const { t } = useTranslation()
const chipSize = compact ? 20 : 26
const btnSize = compact ? 18 : 24
const iconSize = compact ? (travelers.length > 0 ? 8 : 9) : (travelers.length > 0 ? 11 : 13)
const [showDropdown, setShowDropdown] = useState(false)
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
const btnRef = useRef<HTMLButtonElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const openDropdown = useCallback(() => {
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect()
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
}
setShowDropdown(v => !v)
}, [])
useEffect(() => {
if (!showDropdown) return
const close = (e: MouseEvent) => {
if (dropRef.current?.contains(e.target as Node)) return
if (btnRef.current?.contains(e.target as Node)) return
setShowDropdown(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [showDropdown])
const travelerIds = travelers.map(tv => tv.user_id)
const toggle = (userId: number) => {
onSetTravelers(travelerIds.includes(userId) ? travelerIds.filter(id => id !== userId) : [...travelerIds, userId])
}
// Read-only with nobody assigned renders nothing (keeps cards clean).
if (readOnly && travelers.length === 0) return null
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
{travelers.map(tv => (
<ChipWithTooltip key={tv.user_id} label={tv.username} avatarUrl={tv.avatar_url ?? null} size={chipSize} />
))}
{!readOnly && (
<button ref={btnRef} onClick={openDropdown} type="button"
title={t('reservations.travelers.assign')}
aria-label={t('reservations.travelers.assign')}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
}}>
{travelers.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
</button>
)}
{showDropdown && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 168, maxHeight: 280, overflowY: 'auto',
}}>
{tripMembers.length === 0 && (
<div style={{ padding: '6px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
{t('reservations.travelers.none')}
</div>
)}
{tripMembers.map(tm => {
const isActive = travelerIds.includes(tm.id)
return (
<button key={tm.id} type="button" onClick={() => toggle(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
>
<div style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700,
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{tm.avatar_url
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
: tm.username?.[0]?.toUpperCase()}
</div>
<span style={{ flex: 1 }}>{tm.username}</span>
{isActive && <Check size={12} color="var(--text-primary)" />}
</button>
)
})}
</div>,
document.body
)}
</div>
)
}
@@ -0,0 +1,59 @@
// FE-COMP-TRAVELERS-001 to FE-COMP-TRAVELERS-006
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { ReservationTraveler } from '@trek/shared';
import type { TripMember } from '../Budget/BudgetPanelMemberChips';
import { TravelerPicker, TravelerAvatarRow } from './TravelerPicker';
const tripMembers: TripMember[] = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
];
const travelers: ReservationTraveler[] = [
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
{ user_id: 2, username: 'bob', avatar_url: '/uploads/avatars/bob.jpg' },
];
describe('TravelerPicker', () => {
it('FE-COMP-TRAVELERS-001: renders a toggle per trip member', () => {
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set()} onToggle={vi.fn()} />);
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
});
it('FE-COMP-TRAVELERS-002: clicking a member calls onToggle with their id', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set()} onToggle={onToggle} />);
await user.click(screen.getByText('alice'));
expect(onToggle).toHaveBeenCalledWith(1);
});
it('FE-COMP-TRAVELERS-003: a selected member is pressed, an unselected one is not', () => {
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set([1])} onToggle={vi.fn()} />);
expect(screen.getByText('alice').closest('button')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('bob').closest('button')).toHaveAttribute('aria-pressed', 'false');
});
it('FE-COMP-TRAVELERS-004: with no trip members it shows the empty hint, no toggles', () => {
render(<TravelerPicker tripMembers={[]} selectedIds={new Set()} onToggle={vi.fn()} />);
expect(screen.queryAllByRole('button')).toHaveLength(0);
expect(screen.queryByText('alice')).not.toBeInTheDocument();
});
});
describe('TravelerAvatarRow', () => {
it('FE-COMP-TRAVELERS-005: renders nothing when nobody is assigned', () => {
const { container } = render(<TravelerAvatarRow travelers={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-TRAVELERS-006: renders an avatar pill with the name per assigned traveler', () => {
render(<TravelerAvatarRow travelers={travelers} />);
// Each assigned traveler carries an avatar_url, so one <img> renders per pill.
expect(document.querySelectorAll('img')).toHaveLength(2);
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
});
});
@@ -0,0 +1,164 @@
import { Check, Users } from 'lucide-react'
import type { ReservationTraveler } from '@trek/shared'
import { SPLIT_COLORS } from '../Budget/BudgetPanel.constants'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
import { useTranslation } from '../../i18n'
// Deterministic colour per user so the same person keeps their gradient across the
// picker, the card and the cost-split chips (mirrors BudgetPanel.helpers).
function colorFor(userId: number) {
const n = SPLIT_COLORS.length
return SPLIT_COLORS[((((userId | 0) - 1) % n) + n) % n]
}
function Avatar({ userId, username, avatarUrl, size = 22, dim = false }: {
userId: number
username?: string
avatarUrl?: string | null
size?: number
dim?: boolean
}) {
const base = { width: size, height: size, borderRadius: '50%', flexShrink: 0, opacity: dim ? 0.5 : 1 } as const
if (avatarUrl) return <img src={avatarUrl} alt="" style={{ ...base, objectFit: 'cover' }} />
return (
<span style={{
...base, background: colorFor(userId).gradient, color: '#fff',
display: 'grid', placeItems: 'center', fontSize: Math.round(size * 0.4), fontWeight: 700,
}}>
{(username || '?').charAt(0).toUpperCase()}
</span>
)
}
/**
* Toggle picker for the booking/transport edit & create modals — assign trip
* members and named guests to a reservation (#1517). Guests ride along on
* tripMembers, so no separate list is needed.
*/
export function TravelerPicker({ tripMembers, selectedIds, onToggle }: {
tripMembers: TripMember[]
selectedIds: Set<number>
onToggle: (id: number) => void
}) {
const { t } = useTranslation()
if (tripMembers.length === 0) {
return <div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('reservations.travelers.none')}</div>
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tripMembers.map(m => {
const on = selectedIds.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => onToggle(m.id)}
aria-pressed={on}
className={on ? 'bg-surface-hover text-content' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 7,
padding: '4px 11px 4px 4px', borderRadius: 99,
border: `1.5px solid ${on ? 'var(--text-primary)' : 'var(--border-primary)'}`,
background: on ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
opacity: on ? 1 : 0.6, transition: 'opacity 0.12s, border-color 0.12s, background 0.12s',
}}
onMouseEnter={e => { if (!on) e.currentTarget.style.opacity = '0.85' }}
onMouseLeave={e => { if (!on) e.currentTarget.style.opacity = '0.6' }}
>
<Avatar userId={m.id} username={m.username} avatarUrl={m.avatar_url} dim={!on} />
<span style={{ maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.username}</span>
{m.is_guest && <GuestBadge size="xs" />}
{on && <Check size={13} strokeWidth={2.5} style={{ flexShrink: 0 }} />}
</button>
)
})}
</div>
)
}
/**
* Read-only traveler pills for a reservation card. Each person is a rounded badge
* (avatar + name) on bg-surface-card so they stand out inside the card's grey field
* box. Renders nothing when nobody is assigned, so a card without travelers stays
* clean (#1517).
*/
export function TravelerAvatarRow({ travelers }: { travelers?: ReservationTraveler[] }) {
if (!travelers || travelers.length === 0) return null
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{travelers.map(tv => (
<span key={tv.user_id} className="bg-surface-card" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 99,
border: '1px solid var(--border-primary)', padding: '3px 11px 3px 3px',
}}>
<Avatar userId={tv.user_id} username={tv.username} avatarUrl={tv.avatar_url} size={18} />
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{tv.username}</span>
{!!tv.is_guest && <GuestBadge size="xs" />}
</span>
))}
</div>
)
}
/**
* Avatar filter row for the reservations toolbar — toggle to show only bookings
* assigned to the picked members/guests (#1517/#1557). Active avatars get an accent
* ring + check badge; the rest dim once any filter is on. Gradient avatars keep it
* consistent with the traveler pills and the cost-split chips.
*/
export function TravelerFilterAvatars({ members, active, onToggle, label }: {
members: TripMember[]
active: Set<number>
onToggle: (id: number) => void
label?: string
}) {
const hasFilter = active.size > 0
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }} title={label} aria-label={label}>
<Users size={13} className="text-content-faint" style={{ flexShrink: 0, marginRight: 1 }} />
{members.map(m => {
const on = active.has(m.id)
const dim = hasFilter && !on
return (
<button
key={m.id}
type="button"
onClick={() => onToggle(m.id)}
title={m.username}
aria-pressed={on}
style={{
position: 'relative', width: 28, height: 28, borderRadius: '50%', padding: 0, flexShrink: 0,
border: 'none', background: 'none', cursor: 'pointer',
opacity: dim ? 0.4 : 1, transform: on ? 'scale(1.05)' : 'scale(1)',
transition: 'opacity 0.15s ease, transform 0.15s ease',
}}
onMouseEnter={e => { if (!on) e.currentTarget.style.opacity = dim ? '0.7' : '0.82' }}
onMouseLeave={e => { e.currentTarget.style.opacity = dim ? '0.4' : '1' }}
>
<span style={{
display: 'grid', placeItems: 'center', width: '100%', height: '100%', borderRadius: '50%', overflow: 'hidden',
background: m.avatar_url ? 'transparent' : colorFor(m.id).gradient,
color: '#fff', fontSize: 11, fontWeight: 700,
boxShadow: on ? '0 0 0 2px var(--accent)' : 'inset 0 0 0 1.5px var(--border-primary)',
transition: 'box-shadow 0.15s ease',
}}>
{m.avatar_url
? <img src={m.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (m.username || '?').charAt(0).toUpperCase()}
</span>
{on && (
<span style={{
position: 'absolute', right: -2, bottom: -2, width: 13, height: 13, borderRadius: '50%',
background: 'var(--accent)', display: 'grid', placeItems: 'center', boxShadow: '0 0 0 1.5px var(--bg-tertiary)',
}}>
<Check size={8} strokeWidth={3.5} color="#fff" />
</span>
)}
</button>
)
})}
</div>
)
}
+2 -2
View File
@@ -738,8 +738,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onCoverUpdate={(_, coverUrl) => useTripStore.setState(state => ({ trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip }))}
/>
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} onMembersChanged={refreshMembers} />
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} assignments={assignments} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} tripHasDates={tripHasDates} />}
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} tripMembers={tripMembers} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} assignments={assignments} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} tripHasDates={tripHasDates} tripMembers={tripMembers} />}
{/* Journey view for a saved public-transit entry (#1065) */}
{transitJourney && (
<TransitJourneyModal