fix(bookings): make the reservation import findable on the desktop panel

The import is not missing from the desktop — it sits at the far right of the
bookings toolbar, next to the accent "Manual Booking", above an empty page with
nothing but the mascot on it. On a fresh trip that is exactly where nobody
looks, which is how it reads as absent.

The empty state now offers the ways in that apply: add manually, import from a
file, and AirTrail where it is connected. EmptyState grows an optional action
slot for it, so every other caller is untouched.

Closes #2007
This commit is contained in:
Maurice
2026-08-21 12:47:46 +02:00
committed by Maurice
parent 9094f05604
commit 8b88986ca8
3 changed files with 45 additions and 7 deletions
@@ -66,15 +66,16 @@ describe('ReservationsPanel', () => {
it('FE-COMP-RES-005: shows Manual Booking add button', () => {
render(<ReservationsPanel {...defaultProps} />);
// Button text is reservations.addManual = "Manual Booking"
expect(screen.getByText('Manual Booking')).toBeInTheDocument();
// Button text is reservations.addManual = "Manual Booking" — in the toolbar
// and, on an empty list, in the empty state's call to action too (#2007).
expect(screen.getAllByText('Manual Booking').length).toBeGreaterThan(0);
});
it('FE-COMP-RES-006: clicking Manual Booking button calls onAdd', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ReservationsPanel {...defaultProps} onAdd={onAdd} />);
await user.click(screen.getByText('Manual Booking'));
await user.click(screen.getAllByText('Manual Booking')[0]);
expect(onAdd).toHaveBeenCalled();
});
@@ -654,7 +655,7 @@ describe('ReservationsPanel', () => {
it('FE-PLANNER-RESP-058: the add button dims on hover and restores on leave', () => {
render(<ReservationsPanel {...defaultProps} />);
const add = screen.getByText('Manual Booking').closest('button') as HTMLButtonElement;
const add = screen.getAllByText('Manual Booking')[0].closest('button') as HTMLButtonElement;
act(() => { add.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(add.style.opacity).toBe('0.88');
act(() => { add.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, type CSSProperties } from 'react'
import { createPortal } from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
@@ -705,6 +705,14 @@ interface ReservationsPanelProps {
tripMembers?: TripMember[]
}
/** The empty state's call-to-action buttons — same shape as the toolbar's. */
const CTA_STYLE: CSSProperties = {
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10,
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
}
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()
@@ -928,7 +936,32 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{/* Content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px 80px' }} className="max-md:!px-4 max-md:!pt-4">
{total === 0 && reservations.length === 0 ? (
<EmptyState scene={contributionView === 'transports' ? 'transport' : 'bookings'} title={t('reservations.empty')} />
<EmptyState
scene={contributionView === 'transports' ? 'transport' : 'bookings'}
title={t('reservations.empty')}
// On a fresh trip the toolbar controls sit far right above an empty
// page, so the import in particular went unnoticed (#2007).
action={canEdit ? (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
<button onClick={onAdd} className="bg-accent text-accent-text" style={CTA_STYLE}>
<Plus size={14} strokeWidth={2} />
{t(addManualKey)}
</button>
{onImport && bookingImportAvailable && (
<button onClick={onImport} className="bg-surface-card text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
<Download size={14} strokeWidth={2} />
{t('reservations.import.cta')}
</button>
)}
{onAirTrailImport && airTrailAvailable && (
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
<Plane size={14} strokeWidth={2} />
{t('reservations.airtrail.cta')}
</button>
)}
</div>
) : undefined}
/>
) : total === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<p className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('places.noneFound')}</p>
+5 -1
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import MDancingTrek, { type TrekScene, type TrekMood } from '../../mobile/components/MDancingTrek'
/**
@@ -18,6 +18,7 @@ export default function EmptyState({
size = 104,
surface = 'var(--bg-card)',
className = '',
action,
}: {
scene?: TrekScene
mood?: TrekMood
@@ -25,6 +26,8 @@ export default function EmptyState({
size?: number
surface?: string
className?: string
/** Optional call to action under the title, for states that have an obvious next step. */
action?: ReactNode
}) {
return (
<div
@@ -33,6 +36,7 @@ export default function EmptyState({
>
<MDancingTrek scene={scene} mood={mood} size={size} />
<p className="text-[15px] font-semibold text-content-secondary">{title}</p>
{action}
</div>
)
}