mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user