feat(reservations): traveler badge and inline member/guest picker on booking cards (desktop)

This commit is contained in:
Maurice
2026-07-24 14:04:28 +02:00
committed by Maurice
parent 21456b983e
commit 781c693434
8 changed files with 169 additions and 5 deletions
+2
View File
@@ -931,6 +931,8 @@ export const reservationsApi = {
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
// Assign trip members / named guests to a booking (#1517).
setTravelers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/reservations/${id}/travelers`, { user_ids: userIds }).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
const fd = new FormData()
@@ -23,6 +23,8 @@ 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 type { TripMember } from '../Budget/BudgetPanelMemberChips'
interface AssignmentLookupEntry {
dayNumber: number
@@ -85,10 +87,12 @@ 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 = [] }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [], tripMembers = [] }: ReservationCardProps) {
const { toggleReservationStatus, setReservationTravelers } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -430,6 +434,20 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)}
</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. */}
@@ -653,9 +671,11 @@ interface ReservationsPanelProps {
/** Which plugin view this panel represents — the transports tab is its own
* contribution view, the bookings tab stays 'reservations'. */
contributionView?: 'reservations' | 'transports'
/** Trip members + guests, forwarded to each card's traveler picker (#1517). */
tripMembers?: TripMember[]
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual', contributionView = 'reservations' }: ReservationsPanelProps) {
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual', contributionView = 'reservations', tripMembers = [] }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
@@ -864,12 +884,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} />)}
{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} />)}
</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} />)}
{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} />)}
</Section>
)}
</>
@@ -0,0 +1,117 @@
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>
)
}
+2
View File
@@ -663,6 +663,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
titleKey="transport.title"
addManualKey="transport.addManual"
contributionView="transports"
tripMembers={tripMembers}
/>
</div>
)}
@@ -681,6 +682,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }}
onDelete={handleDeleteReservation}
onNavigateToFiles={() => handleTabChange('dateien')}
tripMembers={tripMembers}
/>
</div>
)}
@@ -432,6 +432,11 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
return {
reservations: state.reservations.map(r => r.id === (payload.reservation as Reservation).id ? payload.reservation as Reservation : r),
}
case 'reservation:travelers-updated':
return {
reservations: state.reservations.map(r => r.id === (payload.reservationId as number)
? { ...r, travelers: payload.travelers as Reservation['travelers'] } : r),
}
case 'reservation:deleted':
return {
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
@@ -14,6 +14,7 @@ export interface ReservationsSlice {
updateReservation: (tripId: number | string, id: number, data: Partial<Reservation>) => Promise<Reservation>
toggleReservationStatus: (tripId: number | string, id: number) => Promise<void>
deleteReservation: (tripId: number | string, id: number) => Promise<void>
setReservationTravelers: (tripId: number | string, id: number, userIds: number[]) => Promise<void>
}
export const createReservationsSlice = (set: SetState, get: GetState): ReservationsSlice => ({
@@ -74,4 +75,15 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
}
},
setReservationTravelers: async (tripId, id, userIds) => {
try {
const result = await reservationsApi.setTravelers(tripId, id, userIds)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? { ...r, travelers: result.travelers } : r),
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating travelers'))
}
},
})
+3
View File
@@ -173,5 +173,8 @@ const reservations: TranslationStrings = {
'reservations.createExpenseHint': 'Speichert die Buchung und öffnet dann den Kosten-Editor.',
'reservations.linkedExpense': 'Verknüpfte Ausgabe',
'reservations.removeExpense': 'Ausgabe entfernen',
'reservations.travelers.label': 'Reisende',
'reservations.travelers.assign': 'Reisende zuweisen',
'reservations.travelers.none': 'Noch keine Reisenden im Trip.',
};
export default reservations;
+3
View File
@@ -169,5 +169,8 @@ const reservations: TranslationStrings = {
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
'reservations.travelers.label': 'Travelers',
'reservations.travelers.assign': 'Assign travelers',
'reservations.travelers.none': 'No trip members yet.',
};
export default reservations;