From 781c6934347672b1f07ed955df92484476e5bdef Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 24 Jul 2026 14:04:28 +0200 Subject: [PATCH] feat(reservations): traveler badge and inline member/guest picker on booking cards (desktop) --- client/src/api/client.ts | 2 + .../components/Planner/ReservationsPanel.tsx | 30 ++++- .../src/components/Planner/TravelerChips.tsx | 117 ++++++++++++++++++ client/src/pages/TripPlannerPage.tsx | 2 + client/src/store/slices/remoteEventHandler.ts | 5 + client/src/store/slices/reservationsSlice.ts | 12 ++ shared/src/i18n/de/reservations.ts | 3 + shared/src/i18n/en/reservations.ts | 3 + 8 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 client/src/components/Planner/TravelerChips.tsx diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 3f4e5d873..f3d9c833f 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 => { const fd = new FormData() diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 7e2d14227..6b9902969 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -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 )} + {/* 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. */} @@ -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 && (
- {allPending.map(r => )} + {allPending.map(r => )}
)} {allConfirmed.length > 0 && (
- {allConfirmed.map(r => )} + {allConfirmed.map(r => )}
)} diff --git a/client/src/components/Planner/TravelerChips.tsx b/client/src/components/Planner/TravelerChips.tsx new file mode 100644 index 000000000..1fcce31f1 --- /dev/null +++ b/client/src/components/Planner/TravelerChips.tsx @@ -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(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/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index e1d4917ca..96f2bae4a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -663,6 +663,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { titleKey="transport.title" addManualKey="transport.addManual" contributionView="transports" + tripMembers={tripMembers} /> )} @@ -681,6 +682,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }} onDelete={handleDeleteReservation} onNavigateToFiles={() => handleTabChange('dateien')} + tripMembers={tripMembers} /> )} diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index 6b9fa005d..281b33658 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -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), diff --git a/client/src/store/slices/reservationsSlice.ts b/client/src/store/slices/reservationsSlice.ts index 24122ef55..37691954d 100644 --- a/client/src/store/slices/reservationsSlice.ts +++ b/client/src/store/slices/reservationsSlice.ts @@ -14,6 +14,7 @@ export interface ReservationsSlice { updateReservation: (tripId: number | string, id: number, data: Partial) => Promise toggleReservationStatus: (tripId: number | string, id: number) => Promise deleteReservation: (tripId: number | string, id: number) => Promise + setReservationTravelers: (tripId: number | string, id: number, userIds: number[]) => Promise } 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')) + } + }, }) diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts index 74dbe91f5..5f711434a 100644 --- a/shared/src/i18n/de/reservations.ts +++ b/shared/src/i18n/de/reservations.ts @@ -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; diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts index 0b4703c2c..fb92673e5 100644 --- a/shared/src/i18n/en/reservations.ts +++ b/shared/src/i18n/en/reservations.ts @@ -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;