feat(reservations): traveler filter on the mobile bookings and transports tabs

This commit is contained in:
Maurice
2026-07-24 14:41:08 +02:00
committed by Maurice
parent bc06125d6e
commit 2a5c54c025
3 changed files with 55 additions and 24 deletions
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars } from './tabChrome'
import { CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -22,9 +22,13 @@ import { BOOKING_TYPE_COLOR } from './bookingsModel'
*/
export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
const { t, reservations, days } = planner
const bookings = reservations.filter(r => !planner.TRANSPORT_TYPES.has(r.type))
const [travelerFilter, setTravelerFilter] = useState<Set<number>>(new Set())
const allBookings = reservations.filter(r => !planner.TRANSPORT_TYPES.has(r.type))
const bookings = travelerFilter.size === 0 ? allBookings : allBookings.filter(r => (r.travelers || []).some(tv => travelerFilter.has(tv.user_id)))
const groups = groupTransports(bookings, days)
const canEdit = planner.can('reservation_edit', planner.trip)
const showTravelerFilter = planner.tripMembers.length > 1 && allBookings.some(r => (r.travelers || []).length > 0)
const toggleTravelerFilter = (id: number) => setTravelerFilter(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n })
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const toggle = (id: string) => setCollapsed(c => ({ ...c, [id]: !c[id] }))
@@ -34,20 +38,17 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
{ id: 'pending', label: t('reservations.pending'), rows: groups.pending },
].filter(s => s.rows.length > 0)
if (sections.length === 0) {
return (
<TabScroller>
return (
<TabScroller>
{showTravelerFilter && (
<TravelerFilterRow members={planner.tripMembers} active={travelerFilter} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
)}
{sections.length === 0 ? (
<div className="flex min-h-full flex-1 flex-col items-center justify-center px-8 py-10 text-center">
<MDancingTrek scene="bookings" className="mb-2" />
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('mobileTrip.bookingsEmpty')}</p>
</div>
</TabScroller>
)
}
return (
<TabScroller>
{sections.map(section => (
) : sections.map(section => (
<div key={section.id}>
<SectionHeader
label={section.label}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars } from './tabChrome'
import { CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import {
TRANSPORT_TYPE_COLOR,
@@ -26,9 +26,13 @@ import {
*/
export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
const { t, reservations, days } = planner
const transports = reservations.filter(r => planner.TRANSPORT_TYPES.has(r.type))
const [travelerFilter, setTravelerFilter] = useState<Set<number>>(new Set())
const allTransports = reservations.filter(r => planner.TRANSPORT_TYPES.has(r.type))
const transports = travelerFilter.size === 0 ? allTransports : allTransports.filter(r => (r.travelers || []).some(tv => travelerFilter.has(tv.user_id)))
const groups = groupTransports(transports, days)
const canEdit = planner.can('day_edit', planner.trip)
const showTravelerFilter = planner.tripMembers.length > 1 && allTransports.some(r => (r.travelers || []).length > 0)
const toggleTravelerFilter = (id: number) => setTravelerFilter(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n })
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const toggle = (id: string) => setCollapsed(c => ({ ...c, [id]: !c[id] }))
@@ -39,20 +43,17 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
{ id: 'transit', label: t('reservations.type.transit'), rows: groups.transit },
].filter(s => s.rows.length > 0)
if (sections.length === 0) {
return (
<TabScroller>
return (
<TabScroller>
{showTravelerFilter && (
<TravelerFilterRow members={planner.tripMembers} active={travelerFilter} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
)}
{sections.length === 0 ? (
<div className="flex min-h-full flex-col items-center justify-center px-8 py-10 text-center">
<MDancingTrek scene="transport" className="mb-2" />
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('mobileTrip.transportsEmpty')}</p>
</div>
</TabScroller>
)
}
return (
<TabScroller>
{sections.map(section => (
) : sections.map(section => (
<div key={section.id}>
<SectionHeader
label={section.label}
@@ -130,3 +130,32 @@ export function TravelerAvatars({ travelers, label }: {
</div>
)
}
/** Avatar toggle row to filter the list by assigned traveler (#1517/#1557). */
export function TravelerFilterRow({ members, active, onToggle, label }: {
members: { id: number; username: string; avatar_url?: string | null }[]
active: Set<number>
onToggle: (id: number) => void
label: string
}) {
return (
<div className="flex flex-wrap items-center gap-1.5 px-[18px] pb-2 pt-1" aria-label={label}>
{members.map(m => {
const on = active.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => onToggle(m.id)}
title={m.username}
className={`flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-[color:var(--m-ic)] text-[0.625rem] font-bold text-m-muted transition-opacity ${on ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'} ${on || active.size === 0 ? 'opacity-100' : 'opacity-40'}`}
>
{m.avatar_url
? <img src={m.avatar_url} className="h-full w-full object-cover" alt="" />
: m.username?.[0]?.toUpperCase()}
</button>
)
})}
</div>
)
}