From 8769ed084e5d29bf4473c6202a41aac4bb8e0b93 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 24 Jul 2026 16:41:51 +0200 Subject: [PATCH] refactor(reservations): move desktop traveler assignment into the edit/create modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/Planner/ReservationModal.tsx | 33 +++- .../components/Planner/ReservationsPanel.tsx | 62 ++----- .../src/components/Planner/TransportModal.tsx | 33 +++- .../components/Planner/TravelerChips.test.tsx | 66 ------- .../src/components/Planner/TravelerChips.tsx | 117 ------------- .../Planner/TravelerPicker.test.tsx | 59 +++++++ .../src/components/Planner/TravelerPicker.tsx | 164 ++++++++++++++++++ client/src/pages/TripPlannerPage.tsx | 4 +- 8 files changed, 308 insertions(+), 230 deletions(-) delete mode 100644 client/src/components/Planner/TravelerChips.test.tsx delete mode 100644 client/src/components/Planner/TravelerChips.tsx create mode 100644 client/src/components/Planner/TravelerPicker.test.tsx create mode 100644 client/src/components/Planner/TravelerPicker.tsx diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index f61eecd10..7b343ace9 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -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([]) const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) + // Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves. + const [travelerIds, setTravelerIds] = useState>(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 }} /> + {/* Travelers — assign trip members & guests to this booking (#1517) */} +
+ + +
+ {/* Files */}
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index f61d3c7f6..340201735 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -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
)} + {/* Travelers assigned to this booking — read-only; assignment lives in the edit/create modal (#1517). */} + {r.travelers && r.travelers.length > 0 && ( +
+
{t('reservations.travelers.label')}
+
+ +
+
+ )} - {/* Travelers this booking is for — members/guests (#1517). */} - {(canEdit || (r.travelers && r.travelers.length > 0)) && ( -
- {t('reservations.travelers.label')} - { void setReservationTravelers(tripId, r.id, ids) }} - readOnly={!canEdit} - compact={false} - /> -
- )} - {/* 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 && (
e.stopPropagation()}> - {}} readOnly compact={false} /> +
)} @@ -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) && ( -
- {tripMembers.map(tm => { - const active = travelerFilters.has(tm.id) - return ( - - ) - })} -
+ <> +
+ + )} {canEdit && ( @@ -934,12 +910,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme )} {allPending.length > 0 && (
- {allPending.map(r => )} + {allPending.map(r => )}
)} {allConfirmed.length > 0 && (
- {allConfirmed.map(r => )} + {allConfirmed.map(r => )}
)} diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 05d9b2a03..719f5108a 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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([]) const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) + // Travelers assigned to this booking (#1517) — seeded on open, persisted after save. + const [travelerIds, setTravelerIds] = useState>(new Set()) const fileInputRef = useRef(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 }} />
+ {/* Travelers — assign trip members & guests to this transport (#1517) */} +
+ + +
+ {/* Files */}
diff --git a/client/src/components/Planner/TravelerChips.test.tsx b/client/src/components/Planner/TravelerChips.test.tsx deleted file mode 100644 index d983ad005..000000000 --- a/client/src/components/Planner/TravelerChips.test.tsx +++ /dev/null @@ -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(); - // Each assigned traveler carries an avatar_url, so one 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(); - // 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(); - 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(); - 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( - - ); - expect(container).toBeEmptyDOMElement(); - }); - - it('FE-COMP-TRAVELERS-006: read-only with travelers renders chips but hides the picker button', () => { - render(); - expect(document.querySelectorAll('img')).toHaveLength(2); - expect(screen.queryByRole('button', { name: 'Assign travelers' })).not.toBeInTheDocument(); - }); -}); diff --git a/client/src/components/Planner/TravelerChips.tsx b/client/src/components/Planner/TravelerChips.tsx deleted file mode 100644 index 1fcce31f1..000000000 --- a/client/src/components/Planner/TravelerChips.tsx +++ /dev/null @@ -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(null) - const dropRef = useRef(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 ( -
- {travelers.map(tv => ( - - ))} - {!readOnly && ( - - )} - {showDropdown && ReactDOM.createPortal( -
- {tripMembers.length === 0 && ( -
- {t('reservations.travelers.none')} -
- )} - {tripMembers.map(tm => { - const isActive = travelerIds.includes(tm.id) - return ( - - ) - })} -
, - document.body - )} -
- ) -} diff --git a/client/src/components/Planner/TravelerPicker.test.tsx b/client/src/components/Planner/TravelerPicker.test.tsx new file mode 100644 index 000000000..d0ea277db --- /dev/null +++ b/client/src/components/Planner/TravelerPicker.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-COMP-TRAVELERS-006: renders an avatar pill with the name per assigned traveler', () => { + render(); + // Each assigned traveler carries an avatar_url, so one renders per pill. + expect(document.querySelectorAll('img')).toHaveLength(2); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('bob')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Planner/TravelerPicker.tsx b/client/src/components/Planner/TravelerPicker.tsx new file mode 100644 index 000000000..68995c967 --- /dev/null +++ b/client/src/components/Planner/TravelerPicker.tsx @@ -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 + return ( + + {(username || '?').charAt(0).toUpperCase()} + + ) +} + +/** + * 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 + onToggle: (id: number) => void +}) { + const { t } = useTranslation() + if (tripMembers.length === 0) { + return
{t('reservations.travelers.none')}
+ } + return ( +
+ {tripMembers.map(m => { + const on = selectedIds.has(m.id) + return ( + + ) + })} +
+ ) +} + +/** + * 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 ( +
+ {travelers.map(tv => ( + + + {tv.username} + {!!tv.is_guest && } + + ))} +
+ ) +} + +/** + * 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 + onToggle: (id: number) => void + label?: string +}) { + const hasFilter = active.size > 0 + return ( +
+ + {members.map(m => { + const on = active.has(m.id) + const dim = hasFilter && !on + return ( + + ) + })} +
+ ) +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 96f2bae4a..120b568da 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -738,8 +738,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onCoverUpdate={(_, coverUrl) => useTripStore.setState(state => ({ trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip }))} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} onMembersChanged={refreshMembers} /> - { 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 && { 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} />} + { 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 && { 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 && (