mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(reservations): traveler badge and inline member/guest picker on booking cards (desktop)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user