feat(budget): link existing expenses to bookings and places, several per booking
Discussion #2084: a cost bought later (the seat upgrade for a flight) could not be tied to the booking it belongs to, and a booking held one expense. - The Costs block of the booking, transport and place forms lists every linked expense with edit, unlink (the expense stays in Costs) and delete, and offers the expenses that belong nowhere yet under Link existing expense. The phone sheets get the same block in their own design, and their Files row lists the attached files and links an existing one. - PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC) take reservation_id / place_id; create and update refuse an id from another trip on all three paths. - The booking's mirrored price is the sum of its expenses when they share one currency (none counts as the trip's), else the first one, with that currency, and follows every change to a linked expense. The booking form's price field leaves a booking with several expenses alone. - Deleting a booking deletes all of its linked expenses and broadcasts each one, also from the MCP delete tools. - Cards show the price with its currency. - Booking and transport dialogs: Booking Type and Travelers as dropdowns at the top, the URL beside Files (transports gain the URL field). The row is one shared component; linking and unlinking files go through the store. Help center pictures and texts for the booking, transport and expense guides are redone in all 27 languages, five Costs labels that were still English in 16 languages are translated, and the wiki follows.
@@ -58,6 +58,16 @@ const travellers = (page: Page) => page.locator('[aria-label="Travelers"]')
|
||||
/** The card's delete question is its own portal, with no backdrop class to find it by. */
|
||||
const deleteAsk = (page: Page) => portalDialog(page, page.getByText('Delete booking?', { exact: true }))
|
||||
|
||||
/** The Booking Type field at the head of the form is a CustomSelect; this opens it. */
|
||||
async function openTypeMenu(page: Page): Promise<void> {
|
||||
await block(page, /^Booking Type$/).getByRole('button').first().click()
|
||||
await expect(selectMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
}
|
||||
|
||||
/** The Travelers field opens its members as a list under it, inside the form. */
|
||||
const travelerList = (page: Page) => block(page, /^Travelers$/).getByRole('listbox')
|
||||
|
||||
/** Open the CustomSelect that belongs to a label and take one option out of it. */
|
||||
async function chooseIn(page: Page, field: RegExp, option: RegExp, search?: string): Promise<void> {
|
||||
await block(page, field).getByRole('button').first().click()
|
||||
@@ -149,12 +159,14 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => block(p, /^Booking Type$/),
|
||||
prepare: async p => {
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: selectMenu,
|
||||
act: async p => {
|
||||
const pill = modal(p).getByRole('button', { name: 'Event', exact: true })
|
||||
await pill.click()
|
||||
// The pills carry no aria-pressed; the picked one is the inverted chip.
|
||||
await expect(pill).toHaveClass(/bg-\[var\(--text-primary\)\]/)
|
||||
await selectMenu(p).getByRole('button', { name: /^Event$/ }).first().click()
|
||||
await expect(selectMenu(p)).toHaveCount(0)
|
||||
await expect(block(p, /^Booking Type$/).getByRole('button', { name: /Event/ })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -208,11 +220,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Manual Booking' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Accommodation', exact: true }),
|
||||
target: selectMenu,
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Accommodation', exact: true }).click()
|
||||
await selectMenu(p).getByRole('button', { name: /^Accommodation$/ }).first().click()
|
||||
await expect(selectMenu(p)).toHaveCount(0)
|
||||
await expect(label(p, /^Check-in until$/)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -312,11 +325,19 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => block(p, /^Travelers$/).getByRole('button', { name: /jonas/ }),
|
||||
prepare: async p => {
|
||||
await block(p, /^Travelers$/).getByRole('button', { expanded: false }).click()
|
||||
await expect(travelerList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: travelerList,
|
||||
act: async p => {
|
||||
const pill = block(p, /^Travelers$/).getByRole('button', { name: /jonas/ })
|
||||
await pill.click()
|
||||
await expect(pill).toHaveAttribute('aria-pressed', 'true')
|
||||
const row = travelerList(p).getByRole('button', { name: /jonas/ })
|
||||
await row.click()
|
||||
await expect(row).toHaveAttribute('aria-pressed', 'true')
|
||||
// A click beside the field folds the list away; Escape would close the whole form.
|
||||
await label(p, /^Travelers$/).click()
|
||||
await expect(travelerList(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -438,9 +459,11 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await save.click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
// The block only reads Linked expense once the booking is opened again.
|
||||
// The block only reads Linked expenses once the booking is opened again.
|
||||
await bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(label(p, /^Linked expense$/)).toBeVisible({ timeout: 20_000 })
|
||||
await expect(label(p, /^Linked expenses$/)).toBeVisible({ timeout: 20_000 })
|
||||
// The block sits at the foot of a long form: bring it into the picture.
|
||||
await label(p, /^Linked expenses$/).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -532,7 +532,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await p.getByRole('button', { name: 'Edit' }).first().click()
|
||||
await expect(modal(p).getByText('Linked expense')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(modal(p).getByText('Linked expenses', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
// The block sits at the foot of a long form: bring it into the picture.
|
||||
await modal(p).getByText('Linked expenses', { exact: true }).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,6 +47,8 @@ const transportRow = (page: Page, name: RegExp) => page.getByRole('button', { na
|
||||
const counted = (page: Page, label: string) => page.getByRole('button', { name: new RegExp(`^${label} \\d+$`) })
|
||||
/** A labelled field block inside the open dialog. */
|
||||
const block = (page: Page, label: string) => modal(page).getByText(label, { exact: true }).locator('xpath=..')
|
||||
/** CustomSelect portals its menu to the body: a fixed panel at z-index 99999. */
|
||||
const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last()
|
||||
/** The leg-mode popover, told apart from the connector's tooltip by what stands in it. */
|
||||
const legMenu = (page: Page) =>
|
||||
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Use day default' }) }).last()
|
||||
@@ -221,9 +223,15 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => block(p, 'Booking Type'),
|
||||
prepare: async p => {
|
||||
await block(p, 'Booking Type').getByRole('button').first().click()
|
||||
await expect(selectMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: selectMenu,
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Taxi', exact: true }).click()
|
||||
await selectMenu(p).getByRole('button', { name: /^Taxi$/ }).first().click()
|
||||
await expect(selectMenu(p)).toHaveCount(0)
|
||||
await expect(modal(p).getByText('Start time', { exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,238 @@
|
||||
// FE-PLANNER-COSTSEC-001 to FE-PLANNER-COSTSEC-018
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatMoney } from '../../utils/formatters';
|
||||
import { BookingCostsSection } from './BookingCostsSection';
|
||||
import type { BudgetItem } from '../../types';
|
||||
|
||||
// getByText collapses the no-break space Intl puts between amount and symbol,
|
||||
// so the expected amount is collapsed the same way.
|
||||
const money = (amount: number, currency: string) => formatMoney(amount, currency, 'en').replace(/\s/g, ' ');
|
||||
|
||||
const flight = buildBudgetItem({ id: 11, trip_id: 1, name: 'Flight LH 400', total_price: 420, currency: 'EUR', category: 'flights', reservation_id: 9 });
|
||||
const upgrade = buildBudgetItem({ id: 12, trip_id: 1, name: 'Seat upgrade', total_price: 80, currency: 'USD', category: 'fees', reservation_id: 9 });
|
||||
const elsewhere = buildBudgetItem({ id: 13, trip_id: 1, name: 'Train ride', total_price: 30, reservation_id: 10 });
|
||||
const museum = buildBudgetItem({ id: 14, trip_id: 1, name: 'Museum pass', total_price: 25, category: 'activities', place_id: 4 });
|
||||
const souvenirs = buildBudgetItem({ id: 15, trip_id: 1, name: 'Souvenirs', total_price: 18, currency: 'EUR', category: 'shopping' });
|
||||
const snacks = buildBudgetItem({ id: 16, trip_id: 1, name: 'Snacks', total_price: 7, currency: 'EUR', category: 'food' });
|
||||
|
||||
function renderSection(props: Partial<React.ComponentProps<typeof BookingCostsSection>> = {}) {
|
||||
const handlers = { onCreate: vi.fn(), onEdit: vi.fn(), onRemove: vi.fn() };
|
||||
render(<BookingCostsSection reservationId={9} {...handlers} {...props} />);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
/** A linked expense's row: its name sits two levels below the row itself. */
|
||||
const rowOf = (name: string) => screen.getByText(name).parentElement!.parentElement as HTMLElement;
|
||||
|
||||
/** The trigger of the "link an expense" dropdown, found by what it currently says. */
|
||||
const linkSelect = (label: RegExp = /Link existing expense/) => screen.getByRole('button', { name: label });
|
||||
|
||||
/** Records every expense update and answers with the merged item. */
|
||||
function captureUpdates() {
|
||||
const bodies: { id: number; body: Record<string, unknown> }[] = [];
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/:itemId', async ({ params, request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
bodies.push({ id: Number(params.itemId), body });
|
||||
const current = useTripStore.getState().budgetItems.find(i => i.id === Number(params.itemId));
|
||||
return HttpResponse.json({ item: { ...current, ...body } });
|
||||
}),
|
||||
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
|
||||
);
|
||||
return bodies;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }), budgetItems: [flight, upgrade, elsewhere, museum, souvenirs, snacks] });
|
||||
});
|
||||
|
||||
describe('BookingCostsSection', () => {
|
||||
// ── Nothing linked yet ──────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-COSTSEC-001: with nothing linked it offers create and link side by side, with the hint below', () => {
|
||||
seedStore(useTripStore, { budgetItems: [souvenirs] });
|
||||
renderSection();
|
||||
expect(screen.getByText('Costs')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create expense/ })).toBeInTheDocument();
|
||||
expect(linkSelect()).toBeEnabled();
|
||||
expect(screen.getByText('Saves the booking, then opens the Costs editor.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Unlink, keep the expense' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-002: before the first save there is nothing to link to, so the select is missing', () => {
|
||||
const { onCreate } = renderSection({ reservationId: null });
|
||||
expect(screen.queryByRole('button', { name: /Link existing expense|No unlinked expenses/ })).not.toBeInTheDocument();
|
||||
// Only the create button is left in the row.
|
||||
const create = screen.getByRole('button', { name: /Create expense/ });
|
||||
expect(create.parentElement!.style.gridTemplateColumns).toBe('1fr');
|
||||
create.click();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('Saves the booking, then opens the Costs editor.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-003: once the record exists the create button shares its row with the select', () => {
|
||||
renderSection();
|
||||
expect(screen.getByRole('button', { name: /Create expense/ }).parentElement!.style.gridTemplateColumns).toBe('1fr 1fr');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-004: the select offers only expenses without any link, with their amount', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
await user.click(linkSelect());
|
||||
const souvenirOption = screen.getByRole('button', { name: /Souvenirs/ });
|
||||
expect(souvenirOption).toHaveTextContent(money(18, 'EUR'));
|
||||
expect(screen.getByRole('button', { name: /Snacks/ })).toBeInTheDocument();
|
||||
// Linked to another booking or to a place: not offered, so linking never steals it.
|
||||
expect(screen.queryByRole('button', { name: /Train ride/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Museum pass/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-005: picking an expense in the select links it to the booking', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies = captureUpdates();
|
||||
renderSection();
|
||||
await user.click(linkSelect());
|
||||
await user.click(screen.getByRole('button', { name: /Souvenirs/ }));
|
||||
|
||||
await waitFor(() => expect(bodies).toEqual([{ id: 15, body: { reservation_id: 9 } }]));
|
||||
// It now sits in the linked list with its own row actions.
|
||||
expect(await screen.findByText('Souvenirs')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Unlink, keep the expense' })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-006: the select can be searched by name', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
await user.click(linkSelect());
|
||||
await user.type(screen.getByPlaceholderText('...'), 'snack');
|
||||
expect(screen.getByRole('button', { name: /Snacks/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Souvenirs/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-007: with no unlinked expense left the select says so and stays shut', () => {
|
||||
seedStore(useTripStore, { budgetItems: [flight, elsewhere, museum] });
|
||||
renderSection();
|
||||
expect(linkSelect(/No unlinked expenses/)).toBeDisabled();
|
||||
});
|
||||
|
||||
// ── Linked expenses ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-COSTSEC-008: every linked expense is listed with its category and amount, and the hint goes', () => {
|
||||
renderSection();
|
||||
expect(screen.getByText('Linked expenses')).toBeInTheDocument();
|
||||
expect(screen.getByText('Flight LH 400')).toBeInTheDocument();
|
||||
expect(screen.getByText('Seat upgrade')).toBeInTheDocument();
|
||||
expect(screen.getByText('Flights')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fees & tickets')).toBeInTheDocument();
|
||||
expect(screen.getByText(money(420, 'EUR'))).toBeInTheDocument();
|
||||
// Each amount stays in the expense's own currency.
|
||||
expect(screen.getByText(money(80, 'USD'))).toBeInTheDocument();
|
||||
expect(screen.queryByText('Train ride')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Saves the booking, then opens the Costs editor.')).not.toBeInTheDocument();
|
||||
// A further expense can still be created next to them.
|
||||
expect(screen.getByRole('button', { name: /Create expense/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-009: edit hands the row\'s expense to onEdit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onEdit } = renderSection();
|
||||
const row = rowOf('Seat upgrade');
|
||||
await user.click(within(row).getByRole('button', { name: 'Edit' }));
|
||||
expect(onEdit).toHaveBeenCalledWith(upgrade);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-010: delete hands the row\'s expense to onRemove', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onRemove } = renderSection();
|
||||
const row = rowOf('Flight LH 400');
|
||||
await user.click(within(row).getByRole('button', { name: 'Remove expense' }));
|
||||
expect(onRemove).toHaveBeenCalledWith(flight);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-011: unlink lets go of the link and keeps the expense in Costs', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies = captureUpdates();
|
||||
renderSection();
|
||||
const row = rowOf('Seat upgrade');
|
||||
await user.click(within(row).getByRole('button', { name: 'Unlink, keep the expense' }));
|
||||
|
||||
await waitFor(() => expect(bodies).toEqual([{ id: 12, body: { reservation_id: null } }]));
|
||||
await waitFor(() => expect(screen.queryByText('Seat upgrade')).not.toBeInTheDocument());
|
||||
expect(useTripStore.getState().budgetItems.some((i: BudgetItem) => i.id === 12)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-012: create stays available with expenses linked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onCreate } = renderSection();
|
||||
await user.click(screen.getByRole('button', { name: /Create expense/ }));
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── The place form ─────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-COSTSEC-013: in the place form it lists and links through place_id, with the place hint', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies = captureUpdates();
|
||||
renderSection({ reservationId: null, placeId: 4, hintKey: 'places.createExpenseHint' });
|
||||
expect(screen.getByText('Museum pass')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Flight LH 400')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(linkSelect());
|
||||
await user.click(screen.getByRole('button', { name: /Snacks/ }));
|
||||
await waitFor(() => expect(bodies).toEqual([{ id: 16, body: { place_id: 4 } }]));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-014: the place hint shows while the place has no expense', () => {
|
||||
seedStore(useTripStore, { budgetItems: [souvenirs] });
|
||||
renderSection({ reservationId: null, placeId: 4, hintKey: 'places.createExpenseHint' });
|
||||
expect(screen.getByText('Saves the place, then opens the Costs editor.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Import review ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-COSTSEC-015: an import preview shows the parsed cost instead of the buttons', () => {
|
||||
renderSection({ reservationId: null, pendingExpense: { total_price: 99, currency: 'CHF', category: 'transport' } });
|
||||
expect(screen.getByText('Linked expense')).toBeInTheDocument();
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
expect(screen.getByText(money(99, 'CHF'))).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Create expense/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-016: amounts without a currency use the display currency, then the trip currency', () => {
|
||||
seedStore(useTripStore, { budgetItems: [], trip: buildTrip({ id: 1, currency: 'JPY' }) });
|
||||
seedStore(useSettingsStore, { settings: { default_currency: 'gbp' } });
|
||||
const { unmount } = render(
|
||||
<BookingCostsSection reservationId={null} pendingExpense={{ total_price: 12, category: 'food' }} onCreate={vi.fn()} onEdit={vi.fn()} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText(money(12, 'GBP'))).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
seedStore(useSettingsStore, { settings: { default_currency: '' } });
|
||||
render(
|
||||
<BookingCostsSection reservationId={null} pendingExpense={{ total_price: 12, category: 'food' }} onCreate={vi.fn()} onEdit={vi.fn()} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText(money(12, 'JPY'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-017: a zero-priced import is not previewed', () => {
|
||||
seedStore(useTripStore, { budgetItems: [] });
|
||||
renderSection({ reservationId: null, pendingExpense: { total_price: 0, category: 'food' } });
|
||||
expect(screen.queryByText('Linked expense')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create expense/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-018: a booking that already has an expense lists it instead of the import preview', () => {
|
||||
renderSection({ pendingExpense: { total_price: 99, category: 'transport' } });
|
||||
expect(screen.queryByText('Linked expense')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Linked expenses')).toBeInTheDocument();
|
||||
expect(screen.getByText('Flight LH 400')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, Link2Off } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
import { catMeta } from '../Budget/costsCategories'
|
||||
import { useExpenseLinks } from './useRecordLinks'
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* The Costs block inside a booking modal — and, since #1298, inside the place
|
||||
* form, which links its expense the same way. Replaces the old inline price +
|
||||
* budget category fields: when no expense is linked yet it offers a "create
|
||||
* expense" button (the modal saves its own record first, then opens the full
|
||||
* Costs editor); once linked it shows the expense with edit / remove actions.
|
||||
* form, which links its expenses the same way. Replaces the old inline price +
|
||||
* budget category fields.
|
||||
*
|
||||
* A booking or place can carry several expenses (the flight, then the upgrade
|
||||
* bought for it, #2084). Each is listed with edit, unlink (the expense stays in
|
||||
* Costs) and delete. Under them, "create expense" makes a new one (the modal
|
||||
* saves its own record first, then opens the full Costs editor), and once the
|
||||
* record exists an expense already in Costs can be linked to it from a list.
|
||||
*
|
||||
* Exactly one of reservationId / placeId is set — they are the two sides of the
|
||||
* same link, and the block behaves identically on both.
|
||||
@@ -29,26 +35,26 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
onRemove: (item: BudgetItem) => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const tripCurrency = useTripStore(s => s.trip?.currency)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const linked = reservationId
|
||||
? budgetItems.find(i => i.reservation_id === reservationId)
|
||||
: placeId
|
||||
? budgetItems.find(i => i.place_id === placeId)
|
||||
: null
|
||||
const base = (displayCurrency || tripCurrency || 'EUR').toUpperCase()
|
||||
const { targetId, linked, unlinked, link, unlink } = useExpenseLinks(reservationId, placeId)
|
||||
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
const rowCls = 'bg-surface-secondary border border-edge'
|
||||
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 } as const
|
||||
const iconButton = 'text-content-muted border border-edge bg-surface-card transition-colors hover:bg-surface-tertiary hover:text-content'
|
||||
const deleteButton = 'text-content-muted border border-edge bg-surface-card transition-colors hover:bg-danger-soft hover:text-danger'
|
||||
const iconButtonStyle = { display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' } as const
|
||||
|
||||
// Import review (booking not saved yet): preview the parsed cost that will be linked on save.
|
||||
if (!linked && pendingExpense && pendingExpense.total_price > 0) {
|
||||
if (linked.length === 0 && pendingExpense && pendingExpense.total_price > 0) {
|
||||
const meta = catMeta(pendingExpense.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||
<div className={rowCls} style={rowStyle}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
@@ -60,35 +66,66 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
)
|
||||
}
|
||||
|
||||
if (linked) {
|
||||
const meta = catMeta(linked.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.costsLabel')}</label>
|
||||
<button type="button" onClick={onCreate}
|
||||
className="bg-surface-secondary border border-edge text-content"
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={15} /> {t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t(hintKey)}</div>
|
||||
<label className={labelCls}>{linked.length > 0 ? t('reservations.linkedExpenses') : t('reservations.costsLabel')}</label>
|
||||
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
|
||||
{linked.map(item => {
|
||||
const meta = catMeta(item.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div key={item.id} className={rowCls} style={rowStyle}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(item.total_price, item.currency || base, locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(item)} title={t('common.edit')} aria-label={t('common.edit')} className={iconButton} style={iconButtonStyle}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => void unlink(item)} title={t('reservations.unlinkExpense')} aria-label={t('reservations.unlinkExpense')} className={iconButton} style={iconButtonStyle}><Link2Off size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(item)} title={t('reservations.removeExpense')} aria-label={t('reservations.removeExpense')} className={deleteButton} style={iconButtonStyle}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: targetId ? '1fr 1fr' : '1fr', gap: 8 }}>
|
||||
<button type="button" onClick={onCreate}
|
||||
className="bg-surface-secondary border border-edge text-content transition-colors hover:bg-surface-tertiary"
|
||||
// Sized like the select beside it, so the two sit as one row.
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '8px 13px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={14} /> {t('reservations.createExpense')}
|
||||
</button>
|
||||
{/* Linking needs the record to exist, so it waits for the first save. */}
|
||||
{targetId && (
|
||||
<CustomSelect
|
||||
value=""
|
||||
onChange={value => {
|
||||
const item = unlinked.find(i => i.id === Number(value))
|
||||
if (item) void link(item)
|
||||
}}
|
||||
placeholder={unlinked.length > 0 ? t('reservations.linkExpense') : t('reservations.noUnlinkedExpenses')}
|
||||
options={unlinked.map(i => {
|
||||
const meta = catMeta(i.category)
|
||||
const Icon = meta.Icon
|
||||
return {
|
||||
value: i.id,
|
||||
label: i.name,
|
||||
badge: formatMoney(i.total_price, i.currency || base, locale),
|
||||
icon: <Icon size={13} style={{ color: meta.color }} />,
|
||||
}
|
||||
})}
|
||||
searchable
|
||||
disabled={unlinked.length === 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{linked.length === 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t(hintKey)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// FE-PLANNER-FILEBTNS-001 to FE-PLANNER-FILEBTNS-009
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { buildTripFile } from '../../../tests/helpers/factories';
|
||||
import { BookingFileButtons } from './BookingFileButtons';
|
||||
import type { TripFile } from '../../types';
|
||||
|
||||
const voucher = buildTripFile({ id: 1, original_name: 'voucher.pdf' });
|
||||
const ticket = buildTripFile({ id: 2, original_name: 'ticket.pdf' });
|
||||
|
||||
function renderButtons(props: Partial<React.ComponentProps<typeof BookingFileButtons>> = {}) {
|
||||
const onAttach = vi.fn();
|
||||
const onLink = vi.fn(async (_file: TripFile) => true);
|
||||
const view = render(
|
||||
<BookingFileButtons canAttach uploading={false} onAttach={onAttach} linkable={[voucher, ticket]} onLink={onLink} {...props} />,
|
||||
);
|
||||
return { ...view, onAttach, onLink };
|
||||
}
|
||||
|
||||
const linkButton = () => screen.getByRole('button', { name: /Link existing file/ });
|
||||
|
||||
describe('BookingFileButtons', () => {
|
||||
it('FE-PLANNER-FILEBTNS-001: attach and link sit side by side as two equal buttons', () => {
|
||||
renderButtons();
|
||||
const attach = screen.getByRole('button', { name: /Attach file/ });
|
||||
expect(attach).toBeEnabled();
|
||||
expect(linkButton()).toHaveAttribute('aria-expanded', 'false');
|
||||
expect((attach.parentElement as HTMLElement).style.gridTemplateColumns).toBe('1fr 1fr');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEBTNS-002: attach calls onAttach', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAttach } = renderButtons();
|
||||
await user.click(screen.getByRole('button', { name: /Attach file/ }));
|
||||
expect(onAttach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEBTNS-003: while uploading the attach button says so and is disabled', () => {
|
||||
renderButtons({ uploading: true });
|
||||
const attach = screen.getByRole('button', { name: /Uploading/ });
|
||||
expect(attach).toBeDisabled();
|
||||
expect(screen.queryByRole('button', { name: /Attach file/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEBTNS-004: without upload rights only the link button is left, full width', () => {
|
||||
renderButtons({ canAttach: false });
|
||||
expect(screen.queryByRole('button', { name: /Attach file/ })).not.toBeInTheDocument();
|
||||
const wrapper = linkButton().parentElement!.parentElement as HTMLElement;
|
||||
expect(wrapper.style.gridTemplateColumns).toBe('1fr');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEBTNS-005: with nothing to link only attach is shown', () => {
|
||||
renderButtons({ linkable: [] });
|
||||
expect(screen.getByRole('button', { name: /Attach file/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Link existing file/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEBTNS-006: with neither right nor candidates it renders nothing', () => {
|
||||
const { container } = renderButtons({ canAttach: false, linkable: [] });
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEBTNS-007: the link button opens and closes the list of the trip files', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderButtons();
|
||||
expect(screen.queryByText('voucher.pdf')).not.toBeInTheDocument();
|
||||
await user.click(linkButton());
|
||||
expect(linkButton()).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('voucher.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument();
|
||||
await user.click(linkButton());
|
||||
expect(screen.queryByText('voucher.pdf')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEBTNS-008: picking a file links it and a success closes the list, a failure keeps it open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onLink = vi.fn(async (file: TripFile) => file.id === 2);
|
||||
renderButtons({ onLink });
|
||||
await user.click(linkButton());
|
||||
|
||||
await user.click(screen.getByText('voucher.pdf'));
|
||||
expect(onLink).toHaveBeenCalledWith(voucher);
|
||||
// The link failed (resolved false), so the list stays for another try.
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('ticket.pdf'));
|
||||
expect(onLink).toHaveBeenCalledWith(ticket);
|
||||
await waitFor(() => expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEBTNS-009: a pointer outside the picker closes the list, one inside does not', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<BookingFileButtons canAttach={false} uploading={false} onAttach={vi.fn()} linkable={[voucher]} onLink={vi.fn(async () => false)} />
|
||||
<p>elsewhere</p>
|
||||
</div>,
|
||||
);
|
||||
await user.click(linkButton());
|
||||
fireEvent.pointerDown(screen.getByText('voucher.pdf'));
|
||||
expect(screen.getByText('voucher.pdf')).toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByText('elsewhere'));
|
||||
expect(screen.queryByText('voucher.pdf')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Paperclip, Link2, FileText } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TripFile } from '../../types'
|
||||
|
||||
/**
|
||||
* The file actions of a booking or transport dialog: attach a new file, and
|
||||
* once the record exists link a file the trip already has. Two equal buttons
|
||||
* side by side, the same row the Costs block uses below them.
|
||||
*/
|
||||
export function BookingFileButtons({ canAttach, uploading, onAttach, linkable, onLink }: {
|
||||
canAttach: boolean
|
||||
uploading: boolean
|
||||
onAttach: () => void
|
||||
/** Files of the trip this record could take; empty (or before the first save) hides the link button. */
|
||||
linkable: TripFile[]
|
||||
/** Links one; resolves true when it worked, which closes the list. */
|
||||
onLink: (file: TripFile) => Promise<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!pickerRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [open])
|
||||
|
||||
const canLink = linkable.length > 0
|
||||
if (!canAttach && !canLink) return null
|
||||
|
||||
const buttonClass = 'bg-surface-secondary border border-edge text-content transition-colors hover:bg-surface-tertiary disabled:cursor-default disabled:opacity-60'
|
||||
const buttonStyle = {
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '8px 13px',
|
||||
borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
} as const
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canAttach && canLink ? '1fr 1fr' : '1fr', gap: 8 }}>
|
||||
{canAttach && (
|
||||
<button type="button" onClick={onAttach} disabled={uploading} className={buttonClass} style={buttonStyle}>
|
||||
<Paperclip size={14} />
|
||||
{uploading ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
)}
|
||||
{canLink && (
|
||||
<div ref={pickerRef} style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setOpen(v => !v)} aria-expanded={open} className={buttonClass} style={buttonStyle}>
|
||||
<Link2 size={14} /> {t('reservations.linkExisting')}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="bg-surface-card" style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 6, zIndex: 50,
|
||||
border: '1px solid var(--border-secondary)', borderRadius: 12,
|
||||
boxShadow: '0 12px 32px -8px rgba(0,0,0,0.25), 0 2px 6px rgba(0,0,0,0.06)', padding: 6, maxHeight: 220, overflowY: 'auto',
|
||||
}}>
|
||||
{linkable.map(f => (
|
||||
<button key={f.id} type="button" onClick={async () => { if (await onLink(f)) setOpen(false) }}
|
||||
className="text-content transition-colors hover:bg-surface-tertiary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 10px',
|
||||
border: 'none', cursor: 'pointer', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit',
|
||||
borderRadius: 8, textAlign: 'left',
|
||||
}}>
|
||||
<FileText size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// FE-PLANNER-LINKFILES-001 to FE-PLANNER-LINKFILES-014
|
||||
import { createRef, type ComponentProps } from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildTrip, buildTripFile } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { openFile } from '../../utils/fileDownload';
|
||||
import { BookingLinkAndFiles } from './BookingLinkAndFiles';
|
||||
|
||||
vi.mock('../../utils/fileDownload', () => ({ openFile: vi.fn(async () => undefined) }));
|
||||
|
||||
const onBooking = buildTripFile({ id: 1, original_name: 'voucher.pdf', url: '/uploads/files/voucher.pdf', reservation_id: 9 });
|
||||
const viaLink = buildTripFile({ id: 2, original_name: 'terms.pdf', linked_reservation_ids: [9] });
|
||||
const loose = buildTripFile({ id: 3, original_name: 'map.pdf' });
|
||||
const trashed = buildTripFile({ id: 4, original_name: 'old.pdf', deleted_at: '2025-02-01T00:00:00.000Z' });
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
type Props = ComponentProps<typeof BookingLinkAndFiles>;
|
||||
|
||||
function setup(overrides: Partial<Props> = {}) {
|
||||
const props: Props = {
|
||||
url: '',
|
||||
onUrlChange: vi.fn(),
|
||||
labelClass: 'label',
|
||||
inputClass: 'input',
|
||||
reservationId: 9,
|
||||
tripFiles: [onBooking, viaLink, loose, trashed],
|
||||
attachedFiles: [onBooking, viaLink],
|
||||
pendingFiles: [],
|
||||
onRemovePending: vi.fn(),
|
||||
fileInputRef: createRef<HTMLInputElement>(),
|
||||
onFileChange: vi.fn(),
|
||||
canAttach: true,
|
||||
uploading: false,
|
||||
onLinked: vi.fn(),
|
||||
onDetached: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
const view = render(<BookingLinkAndFiles {...props} />);
|
||||
return { ...view, props };
|
||||
}
|
||||
|
||||
/** The attached file's row, found by its name. */
|
||||
const rowOf = (name: string) => screen.getByText(name).parentElement as HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
vi.mocked(openFile).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('BookingLinkAndFiles', () => {
|
||||
// ── Link ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-LINKFILES-001: the Link field shows the booking URL and reports every edit', () => {
|
||||
const { props } = setup({ url: 'https://hotel.example' });
|
||||
expect(screen.getByText('Link')).toBeInTheDocument();
|
||||
const field = screen.getByPlaceholderText('https://...');
|
||||
expect(field).toHaveValue('https://hotel.example');
|
||||
expect(field).toHaveAttribute('type', 'url');
|
||||
fireEvent.change(field, { target: { value: 'https://hotel.example/booking' } });
|
||||
expect(props.onUrlChange).toHaveBeenCalledWith('https://hotel.example/booking');
|
||||
});
|
||||
|
||||
// ── Attach ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-LINKFILES-002: attach opens the hidden file input, which reports what was picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fileInputRef = createRef<HTMLInputElement>();
|
||||
const { props } = setup({ fileInputRef });
|
||||
const input = fileInputRef.current!;
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
expect(input.accept).toContain('.pkpass');
|
||||
expect(input.style.display).toBe('none');
|
||||
|
||||
const click = vi.spyOn(input, 'click').mockImplementation(() => undefined);
|
||||
await user.click(screen.getByRole('button', { name: /Attach file/ }));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(input, { target: { files: [new File(['x'], 'ticket.pdf')] } });
|
||||
expect(props.onFileChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-003: without upload rights there is no attach button, while uploading it is busy', () => {
|
||||
const { unmount } = setup({ canAttach: false });
|
||||
expect(screen.queryByRole('button', { name: /Attach file/ })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
setup({ uploading: true });
|
||||
expect(screen.getByRole('button', { name: /Uploading/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
// ── Link an existing file ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-LINKFILES-004: the trip files not on the booking and not in the trash can be linked', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByRole('button', { name: /Link existing file/ }));
|
||||
// voucher.pdf and terms.pdf are listed as attached rows, not offered again.
|
||||
expect(screen.getAllByText('map.pdf')).toHaveLength(1);
|
||||
expect(screen.getAllByText('voucher.pdf')).toHaveLength(1);
|
||||
expect(screen.queryByText('old.pdf')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-005: before the first save nothing can be linked', () => {
|
||||
setup({ reservationId: null, attachedFiles: [] });
|
||||
expect(screen.queryByRole('button', { name: /Link existing file/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Attach file/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-006: linking a file posts the link and tells the dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/files/3/link', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||
);
|
||||
const { props } = setup();
|
||||
await user.click(screen.getByRole('button', { name: /Link existing file/ }));
|
||||
await user.click(screen.getByText('map.pdf'));
|
||||
|
||||
await waitFor(() => expect(props.onLinked).toHaveBeenCalledWith(3));
|
||||
expect(body).toEqual({ reservation_id: 9 });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-007: a failed link is reported and the dialog is not told', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/trips/1/files/3/link', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
const { props } = setup();
|
||||
await user.click(screen.getByRole('button', { name: /Link existing file/ }));
|
||||
await user.click(screen.getByText('map.pdf'));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined));
|
||||
expect(props.onLinked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Attached files ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-LINKFILES-008: an attached file opens through the download helper', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(within(rowOf('voucher.pdf')).getByRole('button', { name: 'Open' }));
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/files/voucher.pdf');
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-009: a file that cannot be opened is reported', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(openFile).mockRejectedValueOnce(new Error('gone'));
|
||||
setup();
|
||||
await user.click(within(rowOf('voucher.pdf')).getByRole('button', { name: 'Open' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-010: removing an attached file takes it off the booking and tells the dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const unlinkFileFromReservation = vi.fn(async () => undefined);
|
||||
seedStore(useTripStore, { unlinkFileFromReservation });
|
||||
const { props } = setup();
|
||||
await user.click(within(rowOf('terms.pdf')).getByRole('button', { name: 'Remove link' }));
|
||||
|
||||
await waitFor(() => expect(props.onDetached).toHaveBeenCalledWith(2));
|
||||
expect(unlinkFileFromReservation).toHaveBeenCalledWith(1, viaLink, 9);
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-011: a failed removal is reported, and the dialog still lets the file go', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useTripStore, { unlinkFileFromReservation: vi.fn(async () => { throw new Error('nope'); }) });
|
||||
const { props } = setup();
|
||||
await user.click(within(rowOf('voucher.pdf')).getByRole('button', { name: 'Remove link' }));
|
||||
|
||||
await waitFor(() => expect(props.onDetached).toHaveBeenCalledWith(1));
|
||||
expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-012: without a trip or a saved booking the removal does nothing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const unlinkFileFromReservation = vi.fn(async () => undefined);
|
||||
seedStore(useTripStore, { trip: null, unlinkFileFromReservation });
|
||||
const { props, unmount } = setup();
|
||||
await user.click(within(rowOf('voucher.pdf')).getByRole('button', { name: 'Remove link' }));
|
||||
unmount();
|
||||
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
setup({ reservationId: null, onDetached: props.onDetached });
|
||||
await user.click(within(rowOf('voucher.pdf')).getByRole('button', { name: 'Remove link' }));
|
||||
|
||||
expect(unlinkFileFromReservation).not.toHaveBeenCalled();
|
||||
expect(props.onDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LINKFILES-013: the removal goes all the way through the store to the server', async () => {
|
||||
const user = userEvent.setup();
|
||||
const calls: string[] = [];
|
||||
server.use(
|
||||
http.put('/api/trips/1/files/1', async ({ request }) => {
|
||||
calls.push(`PUT ${JSON.stringify(await request.json())}`);
|
||||
return HttpResponse.json({ file: onBooking });
|
||||
}),
|
||||
http.get('/api/trips/1/files/1/links', () => HttpResponse.json({ links: [{ id: 5, reservation_id: 9 }] })),
|
||||
http.delete('/api/trips/1/files/1/link/5', () => {
|
||||
calls.push('DELETE link 5');
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||
);
|
||||
const { props } = setup();
|
||||
await user.click(within(rowOf('voucher.pdf')).getByRole('button', { name: 'Remove link' }));
|
||||
|
||||
await waitFor(() => expect(props.onDetached).toHaveBeenCalledWith(1));
|
||||
expect(calls).toEqual(['PUT {"reservation_id":null}', 'DELETE link 5']);
|
||||
});
|
||||
|
||||
// ── Files waiting for the first save ──────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-LINKFILES-014: pending files are listed after the attached ones and each X drops its own', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = setup({ pendingFiles: [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')] });
|
||||
const names = screen.getAllByText(/^(voucher|terms|a|b)\.pdf$/).map(n => n.textContent);
|
||||
expect(names).toEqual(['voucher.pdf', 'terms.pdf', 'a.pdf', 'b.pdf']);
|
||||
await user.click(within(rowOf('b.pdf')).getByRole('button', { name: 'Delete' }));
|
||||
expect(props.onRemovePending).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ChangeEvent, RefObject } from 'react'
|
||||
import { ExternalLink, FileText, Link2, X } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { BookingFileButtons } from './BookingFileButtons'
|
||||
import { useFileLinker } from './useRecordLinks'
|
||||
import type { TripFile } from '../../types'
|
||||
|
||||
const FILE_ROW_STYLE = { display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 } as const
|
||||
const FILE_NAME_STYLE = { flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } as const
|
||||
const FILE_ICON_BUTTON_STYLE = { background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 } as const
|
||||
|
||||
/**
|
||||
* The Link and Files row of the booking and transport dialogs, side by side:
|
||||
* the booking's URL on the left, and on the right attach a new file, link one
|
||||
* the trip already has, and the files on the booking (or waiting for its first
|
||||
* save) under the two buttons. One component for both dialogs, which used to
|
||||
* carry the same block twice.
|
||||
*/
|
||||
export function BookingLinkAndFiles({
|
||||
url, onUrlChange, labelClass, inputClass, reservationId, tripFiles, attachedFiles, pendingFiles,
|
||||
onRemovePending, fileInputRef, onFileChange, canAttach, uploading, onLinked, onDetached,
|
||||
}: {
|
||||
url: string
|
||||
onUrlChange: (url: string) => void
|
||||
labelClass: string
|
||||
inputClass: string
|
||||
/** The saved booking; linking waits for it. */
|
||||
reservationId: number | null | undefined
|
||||
tripFiles: TripFile[]
|
||||
attachedFiles: TripFile[]
|
||||
pendingFiles: File[]
|
||||
onRemovePending: (index: number) => void
|
||||
fileInputRef: RefObject<HTMLInputElement | null>
|
||||
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
canAttach: boolean
|
||||
uploading: boolean
|
||||
/** A trip file was linked or let go here, so the dialog's own list follows before the reload lands. */
|
||||
onLinked: (fileId: number) => void
|
||||
onDetached: (fileId: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const tripId = useTripStore(s => s.trip?.id)
|
||||
const unlinkFile = useTripStore(s => s.unlinkFileFromReservation)
|
||||
const linkFile = useFileLinker()
|
||||
|
||||
const linkable = reservationId ? tripFiles.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)) : []
|
||||
const detach = async (file: TripFile) => {
|
||||
if (!tripId || !reservationId) return
|
||||
try { await unlinkFile(tripId, file, reservationId) } catch { toast.error(t('reservations.toast.updateError')) }
|
||||
onDetached(file.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.urlLabel')}</label>
|
||||
<div className="relative">
|
||||
<Link2 size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
|
||||
<input type="url" value={url} onChange={e => onUrlChange(e.target.value)}
|
||||
placeholder={t('reservations.urlPlaceholder')} className={inputClass} style={{ paddingLeft: 34 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('files.title')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={onFileChange} />
|
||||
<BookingFileButtons
|
||||
canAttach={canAttach}
|
||||
uploading={uploading}
|
||||
onAttach={() => fileInputRef.current?.click()}
|
||||
linkable={linkable}
|
||||
onLink={async f => {
|
||||
const ok = await linkFile(f.id, { reservation_id: reservationId! })
|
||||
if (ok) onLinked(f.id)
|
||||
return ok
|
||||
}}
|
||||
/>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} className="bg-surface-secondary" style={FILE_ROW_STYLE}>
|
||||
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-secondary" style={FILE_NAME_STYLE}>{f.original_name}</span>
|
||||
<button type="button" onClick={() => { openFile(f.url).catch(() => toast.error(t('common.unknownError'))) }} aria-label={t('common.open')}
|
||||
className="text-content-faint" style={FILE_ICON_BUTTON_STYLE}><ExternalLink size={11} /></button>
|
||||
<button type="button" onClick={() => { void detach(f) }} aria-label={t('files.unlink')} className="text-content-faint" style={FILE_ICON_BUTTON_STYLE}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={`${f.name}-${i}`} className="bg-surface-secondary" style={FILE_ROW_STYLE}>
|
||||
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-secondary" style={FILE_NAME_STYLE}>{f.name}</span>
|
||||
<button type="button" onClick={() => onRemovePending(i)} aria-label={t('common.delete')} className="text-content-faint" style={FILE_ICON_BUTTON_STYLE}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// FE-PLANNER-TYPESELECT-001 to FE-PLANNER-TYPESELECT-003
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Hotel, Ticket, Utensils } from 'lucide-react';
|
||||
import { BookingTypeSelect, type BookingTypeOption } from './BookingTypeSelect';
|
||||
|
||||
const OPTIONS: readonly BookingTypeOption[] = [
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
||||
];
|
||||
|
||||
describe('BookingTypeSelect', () => {
|
||||
it('FE-PLANNER-TYPESELECT-001: the field shows the current type, translated and with its icon', () => {
|
||||
render(<BookingTypeSelect options={OPTIONS} value="restaurant" onChange={vi.fn()} />);
|
||||
const field = screen.getByRole('button', { name: 'Restaurant' });
|
||||
expect(field.querySelector('svg.lucide-utensils')).not.toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Accommodation' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TYPESELECT-002: every type is offered with its own icon once the field is opened', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BookingTypeSelect options={OPTIONS} value="hotel" onChange={vi.fn()} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Accommodation' }));
|
||||
expect(screen.getByRole('button', { name: 'Restaurant' }).querySelector('svg.lucide-utensils')).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Event' }).querySelector('svg.lucide-ticket')).not.toBeNull();
|
||||
// The trigger and the option of the current type both read "Accommodation".
|
||||
expect(screen.getAllByRole('button', { name: 'Accommodation' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TYPESELECT-003: picking a type hands its value on as a string', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<BookingTypeSelect options={OPTIONS} value="hotel" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Accommodation' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Event' }));
|
||||
expect(onChange).toHaveBeenCalledWith('event');
|
||||
// The menu closes after the pick.
|
||||
expect(screen.queryByRole('button', { name: 'Restaurant' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export interface BookingTypeOption {
|
||||
value: string
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind of a booking or transport as one TREK dropdown, each type with its
|
||||
* icon, so it takes a single field at the head of the dialog instead of a row
|
||||
* of chips.
|
||||
*/
|
||||
export function BookingTypeSelect({ options, value, onChange }: {
|
||||
options: readonly BookingTypeOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={v => onChange(String(v))}
|
||||
options={options.map(({ value: optionValue, labelKey, Icon }) => ({
|
||||
value: optionValue,
|
||||
label: t(labelKey),
|
||||
icon: <Icon size={14} className="text-content-muted" />,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1920,7 +1920,7 @@ describe('PlaceFormModal remaining branches', () => {
|
||||
expect(onOpenExpense).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-071: an already-linked expense is shown with its amount instead of the button', () => {
|
||||
it('FE-PLANNER-PLACEFORM-071: an already-linked expense is listed with its amount, and another can still be created', () => {
|
||||
withBudget();
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
@@ -1929,7 +1929,11 @@ describe('PlaceFormModal remaining branches', () => {
|
||||
render(<PlaceFormModal {...defaultProps} place={{ id: 7, name: 'Louvre' } as never} />);
|
||||
|
||||
expect(screen.getByText('Louvre tickets')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Linked expenses')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Unlink, keep the expense' })).toBeInTheDocument();
|
||||
// A place can carry several expenses (#2084), so the create button stays and the hint goes.
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Saves the place, then opens the Costs editor.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-072: an expense linked to another place is not claimed', () => {
|
||||
|
||||
@@ -77,6 +77,23 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// The booking type is one dropdown now (BookingTypeSelect): open it from the
|
||||
// field under the "Booking Type" label, then pick the option from its menu.
|
||||
function typeField(): HTMLButtonElement {
|
||||
return screen.getByText('Booking Type').parentElement!.querySelector('button') as HTMLButtonElement;
|
||||
}
|
||||
async function pickType(name: RegExp) {
|
||||
await userEvent.click(typeField());
|
||||
// The menu is portaled after the dialog, so its option is the last match.
|
||||
const options = screen.getAllByRole('button', { name });
|
||||
await userEvent.click(options[options.length - 1]);
|
||||
}
|
||||
// The travelers sit in a dropdown field too; the member rows exist once it is open.
|
||||
async function openTravelers() {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Assign travelers|alice|bob/i, expanded: false }));
|
||||
return screen.getByRole('listbox');
|
||||
}
|
||||
|
||||
describe('ReservationModal', () => {
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -105,13 +122,19 @@ describe('ReservationModal', () => {
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-005: all 5 type buttons are visible (transport types removed)', () => {
|
||||
it('FE-PLANNER-RESMODAL-005: the type dropdown offers the booking types (transport types removed)', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument();
|
||||
// A new booking starts as "Other", and the options only exist once the menu is open.
|
||||
expect(typeField()).toHaveTextContent('Other');
|
||||
expect(screen.queryByRole('button', { name: /^Accommodation$/i })).not.toBeInTheDocument();
|
||||
await userEvent.click(typeField());
|
||||
expect(screen.getByRole('button', { name: /^Accommodation$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Restaurant$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Event$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Parking$/i })).toBeInTheDocument();
|
||||
// The field itself and its option both read "Other".
|
||||
expect(screen.getAllByRole('button', { name: /^Other$/i })).toHaveLength(2);
|
||||
expect(screen.queryByRole('button', { name: /^Flight$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Car$/i })).not.toBeInTheDocument();
|
||||
@@ -120,16 +143,16 @@ describe('ReservationModal', () => {
|
||||
|
||||
// ── Type selection ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-006: clicking Event type button activates it', async () => {
|
||||
it('FE-PLANNER-RESMODAL-006: picking Event in the type dropdown shows it in the field and closes the menu', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const eventBtn = screen.getByRole('button', { name: /Event/i });
|
||||
await userEvent.click(eventBtn);
|
||||
expect(eventBtn).toHaveClass('bg-[var(--text-primary)]');
|
||||
await pickType(/^Event$/i);
|
||||
expect(typeField()).toHaveTextContent('Event');
|
||||
expect(screen.queryByRole('button', { name: /^Accommodation$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
await pickType(/Accommodation/i);
|
||||
const checkInLabels = screen.getAllByText(/Check-in/i);
|
||||
expect(checkInLabels.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText(/Check-out/i)).toBeInTheDocument();
|
||||
@@ -137,7 +160,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-009: restaurant type shows location field', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
await pickType(/Restaurant/i);
|
||||
expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -153,7 +176,7 @@ describe('ReservationModal', () => {
|
||||
/>
|
||||
);
|
||||
// Switch to hotel type
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
await pickType(/Accommodation/i);
|
||||
expect(screen.queryByText(/Link to day assignment/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -229,7 +252,7 @@ describe('ReservationModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
await pickType(/Restaurant/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Le Jules Verne');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
@@ -423,7 +446,7 @@ describe('ReservationModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
await pickType(/Accommodation/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
@@ -437,7 +460,7 @@ describe('ReservationModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Event/i }));
|
||||
await pickType(/Event/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Louvre Museum');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
@@ -451,7 +474,7 @@ describe('ReservationModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Parking/i }));
|
||||
await pickType(/Parking/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Airport Parking P1');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
@@ -616,7 +639,7 @@ describe('ReservationModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
await pickType(/Accommodation/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
@@ -632,7 +655,7 @@ describe('ReservationModal', () => {
|
||||
const track = buildPlace({ id: 22, name: 'Rheinsteig', route_geometry: '[[50.1,7.6],[50.2,7.7]]' });
|
||||
render(<ReservationModal {...defaultProps} places={[hotel, track]} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
await pickType(/Accommodation/i);
|
||||
const field = screen.getAllByText('Accommodation').find(el => el.tagName === 'LABEL')!.parentElement!;
|
||||
await userEvent.click(within(field).getByRole('button'));
|
||||
|
||||
@@ -672,7 +695,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||
await pickType(/^Tour$/i);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -745,7 +768,7 @@ describe('ReservationModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await pickType(/^Accommodation$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
@@ -787,7 +810,7 @@ describe('ReservationModal', () => {
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
|
||||
|
||||
// Switch to hotel type
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await pickType(/^Accommodation$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Overlap Hotel');
|
||||
|
||||
// Open start picker (first "Select day" trigger) and select Day 1 (id=17)
|
||||
@@ -815,7 +838,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await pickType(/^Accommodation$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Span Hotel');
|
||||
|
||||
// Set end to Day 16 (id=7) first
|
||||
@@ -900,7 +923,7 @@ describe('ReservationModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await pickType(/^Accommodation$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test');
|
||||
await userEvent.type(screen.getByPlaceholderText(/Address, Airport/i), 'Main Road 3');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
@@ -1173,7 +1196,7 @@ describe('ReservationModal', () => {
|
||||
const place = buildPlace({ id: 15, name: 'Le Jules Verne', address: 'Champ de Mars' });
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} places={[place]} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
await pickType(/Restaurant/i);
|
||||
await userEvent.click(screen.getByText('—'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Le Jules Verne' }));
|
||||
|
||||
@@ -1191,7 +1214,7 @@ describe('ReservationModal', () => {
|
||||
const days = reviewDays();
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} places={[place]} days={days} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await pickType(/^Accommodation$/i);
|
||||
await userEvent.click(screen.getByText('—'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Grand Hotel' }));
|
||||
|
||||
@@ -1240,10 +1263,11 @@ describe('ReservationModal', () => {
|
||||
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Museum tour');
|
||||
await userEvent.click(screen.getByText('alice'));
|
||||
await userEvent.click(screen.getByText('bob'));
|
||||
const travelers = await openTravelers();
|
||||
await userEvent.click(within(travelers).getByText('alice'));
|
||||
await userEvent.click(within(travelers).getByText('bob'));
|
||||
// Toggling bob again removes him, so only alice is sent.
|
||||
await userEvent.click(screen.getByText('bob'));
|
||||
await userEvent.click(within(travelers).getByText('bob'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
@@ -1278,7 +1302,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Boat trip');
|
||||
await userEvent.click(screen.getByText('alice'));
|
||||
await userEvent.click(within(await openTravelers()).getByText('alice'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined));
|
||||
@@ -1443,7 +1467,7 @@ describe('ReservationModal', () => {
|
||||
const days = reviewDays();
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await pickType(/^Accommodation$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
|
||||
// Hotels hide the start/end rows, so these three pickers are check-in / until / check-out.
|
||||
const times = screen.getAllByTestId('time-picker');
|
||||
@@ -1575,9 +1599,7 @@ describe('ReservationModal', () => {
|
||||
// asserting on the message proves nothing here. The save button is the part
|
||||
// that stayed dead with no visible reason.
|
||||
// The hotel type is labelled 'Accommodation' in the picker.
|
||||
const hotelBtn = Array.from(document.querySelectorAll('button'))
|
||||
.find(b => /^\s*Accommodation\s*$/.test(b.textContent || ''))!;
|
||||
fireEvent.click(hotelBtn);
|
||||
await pickType(/^Accommodation$/i);
|
||||
expect(save().disabled).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { useParams } from 'react-router'
|
||||
import apiClient from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Modal from '../shared/Modal'
|
||||
@@ -9,16 +8,17 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import { BookingCodeInput } from '../shared/BookingCode'
|
||||
import { buildAssignmentOptions } from './assignmentOptions'
|
||||
import AddressInput from './AddressInput'
|
||||
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react'
|
||||
import { Hotel, Utensils, Ticket, FileText, Users, Link2, ParkingSquare } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { resolveDayId } from '../../utils/formatters'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
|
||||
import { BookingTypeSelect } from './BookingTypeSelect'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -59,7 +59,6 @@ interface ReservationModalProps {
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null, tripMembers = [] }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -82,24 +81,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
// Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves.
|
||||
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
|
||||
const filePickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFilePicker) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!filePickerRef.current?.contains(event.target as Node)) setShowFilePicker(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [showFilePicker])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setShowFilePicker(false)
|
||||
}, [isOpen])
|
||||
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -413,20 +397,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.bookingType')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
||||
<button key={value} type="button" onClick={() => set('type', value)} className={form.type === value ? 'bg-[var(--text-primary)] text-[var(--bg-primary)]' : 'bg-surface-card text-content-muted'} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 10px', borderRadius: 99, border: '1px solid',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}>
|
||||
<Icon size={11} /> {t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
{/* Type and travelers side by side at the head of the dialog */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.bookingType')}</label>
|
||||
<BookingTypeSelect options={TYPE_OPTIONS} value={form.type} onChange={value => set('type', value)} />
|
||||
</div>
|
||||
{/* Travelers: trip members and guests on this booking (#1517) */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.travelers.label')}</label>
|
||||
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -677,16 +657,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.urlLabel')}</label>
|
||||
<div className="relative">
|
||||
<Link2 size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
|
||||
<input type="url" value={form.url} onChange={e => set('url', e.target.value)}
|
||||
placeholder={t('reservations.urlPlaceholder')} className={inputClass} style={{ paddingLeft: 34 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.notes')}</label>
|
||||
@@ -695,100 +665,24 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
className={inputClass} style={{ resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* Travelers — assign trip members & guests to this booking (#1517) */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.travelers.label')}</label>
|
||||
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('files.title')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
|
||||
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<button type="button" onClick={() => { openFile(f.url).catch(() => {}) }} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}><ExternalLink size={11} /></button>
|
||||
<button type="button" onClick={async () => {
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||
} catch { toast.error(t('reservations.toast.updateError')) }
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={i} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
|
||||
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div ref={filePickerRef} style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||
</button>
|
||||
{showFilePicker && (
|
||||
<div className="bg-surface-card" style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||
<button key={f.id} type="button" onClick={async () => {
|
||||
try {
|
||||
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||
setLinkedFileIds(prev => [...prev, f.id])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}}
|
||||
className="text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||
background: 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit',
|
||||
borderRadius: 7, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<FileText size={12} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Link and files side by side */}
|
||||
<BookingLinkAndFiles
|
||||
url={form.url}
|
||||
onUrlChange={value => set('url', value)}
|
||||
labelClass={labelClass}
|
||||
inputClass={inputClass}
|
||||
reservationId={reservation?.id}
|
||||
tripFiles={files}
|
||||
attachedFiles={attachedFiles}
|
||||
pendingFiles={pendingFiles}
|
||||
onRemovePending={index => setPendingFiles(prev => prev.filter((_, j) => j !== index))}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
canAttach={!!onFileUpload}
|
||||
uploading={uploadingFile}
|
||||
onLinked={fileId => setLinkedFileIds(prev => [...prev, fileId])}
|
||||
onDetached={fileId => setLinkedFileIds(prev => prev.filter(id => id !== fileId))}
|
||||
/>
|
||||
|
||||
{/* Costs — create / view the expense linked to this booking */}
|
||||
{isBudgetEnabled && (
|
||||
|
||||
@@ -11,11 +11,16 @@ import { usePluginStore } from '../../store/pluginStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildReservation, buildDay, buildPlace } from '../../../tests/helpers/factories';
|
||||
import { openFile } from '../../utils/fileDownload';
|
||||
import { formatMoney } from '../../utils/formatters';
|
||||
import ReservationsPanel from './ReservationsPanel';
|
||||
|
||||
vi.mock('../../api/authUrl', () => ({ getAuthUrl: vi.fn().mockResolvedValue('http://test/file') }));
|
||||
vi.mock('../../utils/fileDownload', () => ({ openFile: vi.fn(async () => {}) }));
|
||||
|
||||
// A card shows a price the way formatMoney writes it; getByText collapses the
|
||||
// no-break space Intl puts between amount and symbol, so the expectation does too.
|
||||
const money = (amount: number, currency: string) => formatMoney(amount, currency, 'en').replace(/\s/g, ' ');
|
||||
|
||||
const defaultProps = {
|
||||
tripId: 1,
|
||||
reservations: [],
|
||||
@@ -730,8 +735,23 @@ describe('ReservationsPanel', () => {
|
||||
expect(screen.getByText('Reykjavik')).toBeInTheDocument();
|
||||
// The raw airport codes are dropped in favour of the route line…
|
||||
expect(screen.queryByText('AMS')).not.toBeInTheDocument();
|
||||
// …but the price still gets its own cell.
|
||||
expect(screen.getByText('320 EUR')).toBeInTheDocument();
|
||||
// …but the price still gets its own cell, formatted as money in its own currency.
|
||||
expect(screen.getByText(money(320, 'EUR'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-063b: a price without a currency is shown in the trip currency, a non-numeric one as written', () => {
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'CHF' }) });
|
||||
const priced = buildReservation({
|
||||
id: 1, title: 'Lake cruise', type: 'cruise', status: 'confirmed',
|
||||
metadata: JSON.stringify({ price: '45' }),
|
||||
});
|
||||
const vague = buildReservation({
|
||||
id: 2, title: 'Mountain hut', type: 'other', status: 'confirmed',
|
||||
metadata: JSON.stringify({ price: 'on request', priceCurrency: 'EUR' }),
|
||||
});
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[priced, vague]} />);
|
||||
expect(screen.getByText(money(45, 'CHF'))).toBeInTheDocument();
|
||||
expect(screen.getByText('on request EUR')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-064: an attached file opens through the download helper', async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ViewContribution } from '../../api/client'
|
||||
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
|
||||
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'
|
||||
import PluginFrame from '../Plugins/PluginFrame'
|
||||
import { splitReservationDateTime, formatTime, cleanAmountText } from '../../utils/formatters'
|
||||
import { splitReservationDateTime, formatTime, formatPriceText } from '../../utils/formatters'
|
||||
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'
|
||||
@@ -126,6 +126,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const tripCurrency = useTripStore(s => s.trip?.currency)
|
||||
const [codeRevealed, setCodeRevealed] = useState(false)
|
||||
const typeInfo = getType(r.type)
|
||||
const TypeIcon = typeInfo.Icon
|
||||
@@ -386,7 +387,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
|
||||
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${cleanAmountText(meta.price)}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
|
||||
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, tripCurrency, locale) })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (cells.length === 0) return null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-070
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-076
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -70,6 +70,23 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// The booking type is one dropdown now (BookingTypeSelect): open it from the
|
||||
// field under the "Booking Type" label, then pick the option from its menu.
|
||||
function typeField(): HTMLButtonElement {
|
||||
return screen.getByText('Booking Type').parentElement!.querySelector('button') as HTMLButtonElement;
|
||||
}
|
||||
async function pickType(name: RegExp) {
|
||||
await userEvent.click(typeField());
|
||||
// The menu is portaled after the dialog, so its option is the last match.
|
||||
const options = screen.getAllByRole('button', { name });
|
||||
await userEvent.click(options[options.length - 1]);
|
||||
}
|
||||
// The travelers sit in a dropdown field too; the member rows exist once it is open.
|
||||
async function openTravelers() {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Assign travelers|alice|bob/i, expanded: false }));
|
||||
return screen.getByRole('listbox');
|
||||
}
|
||||
|
||||
describe('TransportModal', () => {
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -102,12 +119,19 @@ describe('TransportModal', () => {
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-005: the type dropdown offers every transport type', async () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument();
|
||||
// A new transport starts as a flight; the options only exist once the menu is open.
|
||||
expect(typeField()).toHaveTextContent('Flight');
|
||||
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
|
||||
await userEvent.click(typeField());
|
||||
for (const name of [/^Train$/i, /^Bus$/i, /^Car$/i, /^Taxi$/i, /^Bicycle$/i, /^Cruise$/i, /^Ferry$/i, /^Other$/i]) {
|
||||
expect(screen.getByRole('button', { name })).toBeInTheDocument();
|
||||
}
|
||||
// The field itself and its option both read "Flight".
|
||||
expect(screen.getAllByRole('button', { name: /^Flight$/i })).toHaveLength(2);
|
||||
// Booking types stay in the booking dialog.
|
||||
expect(screen.queryByRole('button', { name: /^Accommodation$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
|
||||
@@ -141,7 +165,7 @@ describe('TransportModal', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await pickType(/^Train$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
@@ -495,7 +519,7 @@ describe('TransportModal', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-025: a train with an added stop saves from/stop/to endpoints + metadata.legs', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await pickType(/^Train$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Berlin → München');
|
||||
// Insert an intermediate station (2 → 3 stations = 2 legs).
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
@@ -522,7 +546,7 @@ describe('TransportModal', () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' }] as any;
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await pickType(/^Train$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 599');
|
||||
// Fill the train number + a departure time, but never pick a geocoded station.
|
||||
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'ICE 599' } });
|
||||
@@ -540,7 +564,7 @@ describe('TransportModal', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-026: a two-station train saves flat (no metadata.legs)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await pickType(/^Train$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
|
||||
@@ -871,7 +895,7 @@ describe('TransportModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await pickType(/^Train$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'RE 9');
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
|
||||
@@ -913,7 +937,7 @@ describe('TransportModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await pickType(/^Car$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Sixt compact');
|
||||
// The labels switch to rental wording for cars.
|
||||
expect(screen.getByText('Pickup')).toBeInTheDocument();
|
||||
@@ -1001,7 +1025,7 @@ describe('TransportModal', () => {
|
||||
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
||||
await userEvent.click(screen.getByText('bob'));
|
||||
await userEvent.click(within(await openTravelers()).getByText('bob'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
@@ -1018,7 +1042,7 @@ describe('TransportModal', () => {
|
||||
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
||||
await userEvent.click(screen.getByText('alice'));
|
||||
await userEvent.click(within(await openTravelers()).getByText('alice'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined));
|
||||
@@ -1195,9 +1219,11 @@ describe('TransportModal', () => {
|
||||
];
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} tripMembers={tripMembers} />);
|
||||
// Seeded from the reservation, so alice starts selected.
|
||||
expect(screen.getByText('alice').closest('button')).toHaveAttribute('aria-pressed', 'true');
|
||||
await userEvent.click(screen.getByText('alice'));
|
||||
// Seeded from the reservation, so alice starts selected and the closed field names her.
|
||||
expect(screen.getByRole('button', { name: /alice/, expanded: false })).toBeInTheDocument();
|
||||
const travelers = await openTravelers();
|
||||
expect(within(travelers).getByText('alice').closest('button')).toHaveAttribute('aria-pressed', 'true');
|
||||
await userEvent.click(within(travelers).getByText('alice'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
@@ -1208,7 +1234,7 @@ describe('TransportModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await pickType(/^Train$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
||||
@@ -1232,7 +1258,7 @@ describe('TransportModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await pickType(/^Car$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
|
||||
// Pick-up and return are the rental frame; the stops sit between them.
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
@@ -1259,7 +1285,7 @@ describe('TransportModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await pickType(/^Car$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
@@ -1284,7 +1310,7 @@ describe('TransportModal', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await pickType(/^Car$/i);
|
||||
// A single stop has nothing to swap with, so the buttons stay away entirely.
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
|
||||
@@ -1300,7 +1326,7 @@ describe('TransportModal', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await pickType(/^Car$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
||||
@@ -1451,7 +1477,7 @@ describe('TransportModal', () => {
|
||||
buildPlace({ id: 3, name: 'Louvre', lat: 48.86, lng: 2.33 }),
|
||||
];
|
||||
render(<TransportModal {...defaultProps} places={places} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Bus$/i }));
|
||||
await pickType(/^Bus$/i);
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
expect(fields).toHaveLength(2);
|
||||
@@ -1461,11 +1487,56 @@ describe('TransportModal', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-072: every train station field offers the trip places too', async () => {
|
||||
const places = [buildPlace({ id: 1, name: 'Berlin Hbf', lat: 52.52, lng: 13.37 })];
|
||||
render(<TransportModal {...defaultProps} places={places} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await pickType(/^Train$/i);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
expect(stations).toHaveLength(3);
|
||||
for (const station of stations) expect(station).toHaveAttribute('data-picks', 'Berlin Hbf');
|
||||
});
|
||||
|
||||
// ── Link field (#2084): transports keep a booking URL like bookings do ─────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-073: a URL typed into the Link field is saved with the transport', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
||||
await userEvent.type(screen.getByPlaceholderText('https://...'), 'https://lufthansa.example/booking');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ url: 'https://lufthansa.example/booking' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-074: without a URL the transport saves url null', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toHaveProperty('url', null);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-075: editing pre-fills the stored URL, and clearing it saves null', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 64, title: 'ICE 599', type: 'train', url: 'https://bahn.example/ticket' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
||||
const field = screen.getByPlaceholderText('https://...');
|
||||
expect(field).toHaveValue('https://bahn.example/ticket');
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toHaveProperty('url', null);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-076: an edited transport keeps its URL when saved untouched', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 65, title: 'Ferry', type: 'ferry', url: 'https://ferry.example' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ url: 'https://ferry.example' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, X, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { BookingCodeInput } from '../shared/BookingCode'
|
||||
@@ -13,11 +13,11 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
|
||||
import { BookingTypeSelect } from './BookingTypeSelect'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -149,6 +149,7 @@ const defaultForm = {
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
url: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
@@ -193,7 +194,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
// Set right before submitting when the user clicked "create/edit expense", so
|
||||
@@ -225,25 +225,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
// Travelers assigned to this booking (#1517) — seeded on open, persisted after save.
|
||||
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const filePickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFilePicker) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!filePickerRef.current?.contains(event.target as Node)) setShowFilePicker(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [showFilePicker])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setShowFilePicker(false)
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -280,6 +265,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
|
||||
confirmation_number: src.confirmation_number || '',
|
||||
notes: src.notes || '',
|
||||
url: src.url || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
@@ -600,6 +586,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
url: form.url || null,
|
||||
// An empty object, not null: null clears the column outright, and that
|
||||
// took the mirrored booking price with it on every edit of a type that
|
||||
// fills no metadata of its own — restaurant, event, tour, parking, other,
|
||||
@@ -800,20 +787,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.bookingType')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
||||
<button key={value} type="button" onClick={() => set('type', value)} className={form.type === value ? 'bg-[var(--text-primary)] text-[var(--bg-primary)]' : 'bg-surface-card text-content-muted'} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 10px', borderRadius: 99, border: '1px solid',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}>
|
||||
<Icon size={11} /> {t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
{/* Type and travelers side by side at the head of the dialog */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.bookingType')}</label>
|
||||
<BookingTypeSelect options={TYPE_OPTIONS} value={form.type} onChange={value => set('type', value as TransportType)} />
|
||||
</div>
|
||||
{/* Travelers: trip members and guests on this booking (#1517) */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.travelers.label')}</label>
|
||||
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1141,100 +1124,24 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
className={inputClass} style={{ resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* Travelers — assign trip members & guests to this transport (#1517) */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.travelers.label')}</label>
|
||||
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('files.title')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
|
||||
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<button type="button" onClick={() => { openFile(f.url).catch(() => {}) }} aria-label={t('common.open')} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 0, display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></button>
|
||||
<button type="button" onClick={async () => {
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||
} catch { toast.error(t('reservations.toast.updateError')) }
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={i} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
|
||||
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div ref={filePickerRef} style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||
</button>
|
||||
{showFilePicker && (
|
||||
<div className="bg-surface-card" style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||
<button key={f.id} type="button" onClick={async () => {
|
||||
try {
|
||||
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||
setLinkedFileIds(prev => [...prev, f.id])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}}
|
||||
className="text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||
background: 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit',
|
||||
borderRadius: 7, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<FileText size={12} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Link and files side by side */}
|
||||
<BookingLinkAndFiles
|
||||
url={form.url}
|
||||
onUrlChange={value => set('url', value)}
|
||||
labelClass={labelClass}
|
||||
inputClass={inputClass}
|
||||
reservationId={reservation?.id}
|
||||
tripFiles={files}
|
||||
attachedFiles={attachedFiles}
|
||||
pendingFiles={pendingFiles}
|
||||
onRemovePending={index => setPendingFiles(prev => prev.filter((_, j) => j !== index))}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
canAttach={!!onFileUpload}
|
||||
uploading={uploadingFile}
|
||||
onLinked={fileId => setLinkedFileIds(prev => [...prev, fileId])}
|
||||
onDetached={fileId => setLinkedFileIds(prev => prev.filter(id => id !== fileId))}
|
||||
/>
|
||||
|
||||
{/* Costs — create / view the expense linked to this booking */}
|
||||
{isBudgetEnabled && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-COMP-TRAVELERS-001 to FE-COMP-TRAVELERS-017
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
// FE-COMP-TRAVELERS-001 to FE-COMP-TRAVELERS-020
|
||||
import { render, screen, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReservationTraveler } from '@trek/shared';
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips';
|
||||
@@ -16,72 +16,134 @@ const travelers: ReservationTraveler[] = [
|
||||
];
|
||||
|
||||
describe('TravelerPicker', () => {
|
||||
it('FE-COMP-TRAVELERS-001: renders a toggle per trip member', () => {
|
||||
// The picker is a dropdown field now: the member rows only exist once it is open.
|
||||
const openPicker = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole('button', { expanded: false }));
|
||||
return screen.getByRole('listbox');
|
||||
};
|
||||
|
||||
it('FE-COMP-TRAVELERS-001: renders a toggle per trip member once the field is opened', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
const list = await openPicker(user);
|
||||
expect(within(list).getByText('alice')).toBeInTheDocument();
|
||||
expect(within(list).getByText('bob')).toBeInTheDocument();
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-002: clicking a member calls onToggle with their id', async () => {
|
||||
it('FE-COMP-TRAVELERS-002: clicking a member calls onToggle with their id and keeps the list open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set()} onToggle={onToggle} />);
|
||||
await user.click(screen.getByText('alice'));
|
||||
const list = await openPicker(user);
|
||||
await user.click(within(list).getByText('alice'));
|
||||
expect(onToggle).toHaveBeenCalledWith(1);
|
||||
// A pointer inside the list is not an outside click, so several people can be ticked in a row.
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-003: a selected member is pressed, an unselected one is not', () => {
|
||||
it('FE-COMP-TRAVELERS-003: a selected member is pressed, an unselected one is not', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set([1])} onToggle={vi.fn()} />);
|
||||
expect(screen.getByText('alice').closest('button')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('bob').closest('button')).toHaveAttribute('aria-pressed', 'false');
|
||||
const list = await openPicker(user);
|
||||
expect(within(list).getByText('alice').closest('button')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(list).getByText('bob').closest('button')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-004: with no trip members it shows the empty hint, no toggles', () => {
|
||||
render(<TravelerPicker tripMembers={[]} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0);
|
||||
expect(screen.queryByText('alice')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No trip members yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-007: a guest member carries the guest badge, a normal member does not', () => {
|
||||
it('FE-COMP-TRAVELERS-007: a guest member carries the guest badge, a normal member does not', async () => {
|
||||
const user = userEvent.setup();
|
||||
const withGuest: TripMember[] = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 3, username: 'carol', avatar_url: null, is_guest: true },
|
||||
];
|
||||
render(<TravelerPicker tripMembers={withGuest} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
expect(screen.getAllByText('Guest')).toHaveLength(1);
|
||||
expect(screen.getByText('carol').closest('button')).toHaveTextContent('Guest');
|
||||
expect(screen.getByText('alice').closest('button')).not.toHaveTextContent('Guest');
|
||||
const list = await openPicker(user);
|
||||
expect(within(list).getAllByText('Guest')).toHaveLength(1);
|
||||
expect(within(list).getByText('carol').closest('button')).toHaveTextContent('Guest');
|
||||
expect(within(list).getByText('alice').closest('button')).not.toHaveTextContent('Guest');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-008: an avatar_url renders an image instead of the gradient initial', () => {
|
||||
it('FE-COMP-TRAVELERS-008: an avatar_url renders an image instead of the gradient initial', async () => {
|
||||
const user = userEvent.setup();
|
||||
const withAvatar: TripMember[] = [{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/a.jpg' }];
|
||||
render(<TravelerPicker tripMembers={withAvatar} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
const img = document.querySelector('img') as HTMLImageElement;
|
||||
const list = await openPicker(user);
|
||||
const img = list.querySelector('img') as HTMLImageElement;
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.getAttribute('src')).toBe('/uploads/avatars/a.jpg');
|
||||
expect(screen.queryByText('A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-009: a member without a username falls back to "?" in the avatar', () => {
|
||||
it('FE-COMP-TRAVELERS-009: a member without a username falls back to "?" in the avatar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const nameless = [{ id: 4, avatar_url: null }] as unknown as TripMember[];
|
||||
render(<TravelerPicker tripMembers={nameless} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
expect(screen.getByText('?')).toBeInTheDocument();
|
||||
const list = await openPicker(user);
|
||||
expect(within(list).getByText('?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-010: hovering an unselected chip lifts its opacity and leaving restores it', () => {
|
||||
it('FE-COMP-TRAVELERS-010: an unselected row dims its avatar and hides its check, a selected one does not', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set([1])} onToggle={vi.fn()} />);
|
||||
const bob = screen.getByText('bob').closest('button') as HTMLButtonElement;
|
||||
fireEvent.mouseEnter(bob);
|
||||
expect(bob.style.opacity).toBe('0.85');
|
||||
fireEvent.mouseLeave(bob);
|
||||
expect(bob.style.opacity).toBe('0.6');
|
||||
const list = await openPicker(user);
|
||||
const alice = within(list).getByText('alice').closest('button') as HTMLButtonElement;
|
||||
const bob = within(list).getByText('bob').closest('button') as HTMLButtonElement;
|
||||
expect((alice.firstElementChild as HTMLElement).style.opacity).toBe('1');
|
||||
expect((bob.firstElementChild as HTMLElement).style.opacity).toBe('0.5');
|
||||
expect((alice.lastElementChild as SVGElement).style.opacity).toBe('1');
|
||||
expect((bob.lastElementChild as SVGElement).style.opacity).toBe('0');
|
||||
expect(alice).toHaveClass('text-content');
|
||||
expect(bob).toHaveClass('text-content-muted');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-011: hovering a selected chip leaves its opacity untouched', () => {
|
||||
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set([1])} onToggle={vi.fn()} />);
|
||||
const alice = screen.getByText('alice').closest('button') as HTMLButtonElement;
|
||||
fireEvent.mouseEnter(alice);
|
||||
expect(alice.style.opacity).toBe('1');
|
||||
it('FE-COMP-TRAVELERS-011: the closed field says "Assign travelers" while nobody is chosen', () => {
|
||||
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
const field = screen.getByRole('button');
|
||||
expect(field).toHaveTextContent('Assign travelers');
|
||||
expect(field).toHaveAttribute('aria-haspopup', 'listbox');
|
||||
expect(field).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('alice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-018: the closed field shows the chosen people as avatars and names, four avatars at most', () => {
|
||||
const many: TripMember[] = [1, 2, 3, 4, 5].map(id => ({ id, username: `user${id}`, avatar_url: null }));
|
||||
render(<TravelerPicker tripMembers={many} selectedIds={new Set([1, 2, 3, 4, 5])} onToggle={vi.fn()} />);
|
||||
const field = screen.getByRole('button');
|
||||
expect(field).toHaveTextContent('user1, user2, user3, user4, user5');
|
||||
expect(field).not.toHaveTextContent('Assign travelers');
|
||||
// Four initials in the stack, the fifth person only by name.
|
||||
expect(within(field).getAllByText('U')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-019: clicking the field again folds the list away', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
await openPicker(user);
|
||||
const field = screen.getByRole('button', { expanded: true });
|
||||
await user.click(field);
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
expect(field).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-020: a pointer outside the picker closes the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<TravelerPicker tripMembers={tripMembers} selectedIds={new Set()} onToggle={vi.fn()} />
|
||||
<p>elsewhere</p>
|
||||
</div>,
|
||||
);
|
||||
await openPicker(user);
|
||||
fireEvent.pointerDown(screen.getByText('elsewhere'));
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Check, Users } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, Users } from 'lucide-react'
|
||||
import type { ReservationTraveler } from '@trek/shared'
|
||||
import { SPLIT_COLORS } from '../Budget/BudgetPanel.constants'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
@@ -32,9 +33,10 @@ function Avatar({ userId, username, avatarUrl, size = 22, dim = false }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle picker for the booking/transport edit & create modals — assign trip
|
||||
* members and named guests to a reservation (#1517). Guests ride along on
|
||||
* tripMembers, so no separate list is needed.
|
||||
* Who travels on a booking or transport, for its edit and create dialogs
|
||||
* (#1517): a field shaped like the TREK dropdown beside it, showing the chosen
|
||||
* people as avatars and names, that opens a list to tick them on and off.
|
||||
* Guests ride along on tripMembers, so no separate list is needed.
|
||||
*/
|
||||
export function TravelerPicker({ tripMembers, selectedIds, onToggle }: {
|
||||
tripMembers: TripMember[]
|
||||
@@ -42,38 +44,80 @@ export function TravelerPicker({ tripMembers, selectedIds, onToggle }: {
|
||||
onToggle: (id: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [open])
|
||||
|
||||
const field = {
|
||||
width: '100%', minHeight: 38, display: 'flex', alignItems: 'center', gap: 8, padding: '5px 12px 5px 8px',
|
||||
borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-input)',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', textAlign: 'left',
|
||||
} as const
|
||||
|
||||
if (tripMembers.length === 0) {
|
||||
return <div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('reservations.travelers.none')}</div>
|
||||
return <div className="text-content-faint" style={{ ...field, paddingLeft: 12 }}>{t('reservations.travelers.none')}</div>
|
||||
}
|
||||
|
||||
const chosen = tripMembers.filter(m => selectedIds.has(m.id))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{tripMembers.map(m => {
|
||||
const on = selectedIds.has(m.id)
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(m.id)}
|
||||
aria-pressed={on}
|
||||
className={on ? 'bg-surface-hover text-content' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 7,
|
||||
padding: '4px 11px 4px 4px', borderRadius: 99,
|
||||
border: `1.5px solid ${on ? 'var(--text-primary)' : 'var(--border-primary)'}`,
|
||||
background: on ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
opacity: on ? 1 : 0.6, transition: 'opacity 0.12s, border-color 0.12s, background 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!on) e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseLeave={e => { if (!on) e.currentTarget.style.opacity = '0.6' }}
|
||||
>
|
||||
<Avatar userId={m.id} username={m.username} avatarUrl={m.avatar_url} dim={!on} />
|
||||
<span style={{ maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.username}</span>
|
||||
{m.is_guest && <GuestBadge size="xs" />}
|
||||
{on && <Check size={13} strokeWidth={2.5} style={{ flexShrink: 0 }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div ref={rootRef} style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox"
|
||||
className="text-content transition-colors hover:border-content-faint" style={{ ...field, cursor: 'pointer' }}>
|
||||
{chosen.length > 0 ? (
|
||||
<>
|
||||
<span style={{ display: 'inline-flex', flexShrink: 0 }}>
|
||||
{chosen.slice(0, 4).map((m, i) => (
|
||||
<span key={m.id} style={{ marginLeft: i ? -7 : 0, borderRadius: '50%', boxShadow: '0 0 0 2px var(--bg-input)', display: 'inline-flex' }}>
|
||||
<Avatar userId={m.id} username={m.username} avatarUrl={m.avatar_url} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{chosen.map(m => m.username).join(', ')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Users size={15} className="text-content-faint" style={{ flexShrink: 0, marginLeft: 4 }} />
|
||||
<span className="text-content-faint" style={{ flex: 1 }}>{t('reservations.travelers.assign')}</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown size={14} className="text-content-faint" style={{ flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div role="listbox" aria-multiselectable="true" className="bg-surface-card" style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 4, zIndex: 50,
|
||||
border: '1px solid var(--border-secondary)', borderRadius: 12, padding: 6, maxHeight: 260, overflowY: 'auto',
|
||||
boxShadow: '0 12px 32px -8px rgba(0,0,0,0.25), 0 2px 6px rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
{tripMembers.map(m => {
|
||||
const on = selectedIds.has(m.id)
|
||||
return (
|
||||
<button key={m.id} type="button" onClick={() => onToggle(m.id)} aria-pressed={on}
|
||||
className={`transition-colors hover:bg-surface-tertiary ${on ? 'text-content' : 'text-content-muted'}`}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 9, width: '100%', padding: '7px 9px', borderRadius: 8,
|
||||
border: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: on ? 600 : 500,
|
||||
}}>
|
||||
<Avatar userId={m.id} username={m.username} avatarUrl={m.avatar_url} dim={!on} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.username}</span>
|
||||
{m.is_guest && <GuestBadge size="xs" />}
|
||||
<Check size={14} strokeWidth={2.5} style={{ flexShrink: 0, opacity: on ? 1 : 0 }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// FE-PLANNER-RECLINKS-001 to FE-PLANNER-RECLINKS-017
|
||||
import type { ReactNode } from 'react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildBudgetItem, buildTrip, buildTripFile, buildUser } from '../../../tests/helpers/factories';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { useExpenseLinks, useFileLinker, useReservationFiles } from './useRecordLinks';
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
/** Captures the body of every expense update the hook sends. */
|
||||
function captureBudgetPuts() {
|
||||
const bodies: { id: string; body: Record<string, unknown> }[] = [];
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/:itemId', async ({ params, request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
bodies.push({ id: String(params.itemId), body });
|
||||
const current = useTripStore.getState().budgetItems.find(i => i.id === Number(params.itemId));
|
||||
return HttpResponse.json({ item: { ...current, ...body } });
|
||||
}),
|
||||
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
|
||||
);
|
||||
return bodies;
|
||||
}
|
||||
|
||||
describe('useExpenseLinks', () => {
|
||||
const onBooking = buildBudgetItem({ id: 11, trip_id: 1, name: 'Flight', reservation_id: 9 });
|
||||
const upgrade = buildBudgetItem({ id: 12, trip_id: 1, name: 'Seat upgrade', reservation_id: 9 });
|
||||
const onOtherBooking = buildBudgetItem({ id: 13, trip_id: 1, name: 'Train', reservation_id: 10 });
|
||||
const onPlace = buildBudgetItem({ id: 14, trip_id: 1, name: 'Museum', place_id: 4 });
|
||||
const loose = buildBudgetItem({ id: 15, trip_id: 1, name: 'Souvenirs' });
|
||||
|
||||
beforeEach(() => {
|
||||
seedStore(useTripStore, { budgetItems: [onBooking, upgrade, onOtherBooking, onPlace, loose] });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-001: a booking sees every expense linked to it, and only the unlinked ones as candidates', () => {
|
||||
const { result } = renderHook(() => useExpenseLinks(9), { wrapper });
|
||||
expect(result.current.targetId).toBe(9);
|
||||
expect(result.current.linked.map(i => i.id)).toEqual([11, 12]);
|
||||
// Linked to another booking or to a place counts as taken.
|
||||
expect(result.current.unlinked.map(i => i.id)).toEqual([15]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-002: a place sees the expenses on its place_id', () => {
|
||||
const { result } = renderHook(() => useExpenseLinks(null, 4), { wrapper });
|
||||
expect(result.current.targetId).toBe(4);
|
||||
expect(result.current.linked.map(i => i.id)).toEqual([14]);
|
||||
expect(result.current.unlinked.map(i => i.id)).toEqual([15]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-003: an unsaved record has no target, nothing linked and a link that does nothing', async () => {
|
||||
const bodies = captureBudgetPuts();
|
||||
const { result } = renderHook(() => useExpenseLinks(undefined, null), { wrapper });
|
||||
expect(result.current.targetId).toBeNull();
|
||||
expect(result.current.linked).toEqual([]);
|
||||
expect(result.current.unlinked.map(i => i.id)).toEqual([15]);
|
||||
await act(async () => { await result.current.link(loose); });
|
||||
expect(bodies).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-004: link sets the reservation_id of the picked expense', async () => {
|
||||
const bodies = captureBudgetPuts();
|
||||
const { result } = renderHook(() => useExpenseLinks(9), { wrapper });
|
||||
await act(async () => { await result.current.link(loose); });
|
||||
expect(bodies).toEqual([{ id: '15', body: { reservation_id: 9 } }]);
|
||||
await waitFor(() => expect(result.current.linked.map(i => i.id)).toEqual([11, 12, 15]));
|
||||
expect(result.current.unlinked).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-005: on a place, link sets the place_id instead', async () => {
|
||||
const bodies = captureBudgetPuts();
|
||||
const { result } = renderHook(() => useExpenseLinks(null, 4), { wrapper });
|
||||
await act(async () => { await result.current.link(loose); });
|
||||
expect(bodies).toEqual([{ id: '15', body: { place_id: 4 } }]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-006: unlink clears the link and keeps the expense', async () => {
|
||||
const bodies = captureBudgetPuts();
|
||||
const { result } = renderHook(() => useExpenseLinks(9), { wrapper });
|
||||
await act(async () => { await result.current.unlink(upgrade); });
|
||||
expect(bodies).toEqual([{ id: '12', body: { reservation_id: null } }]);
|
||||
await waitFor(() => expect(result.current.linked.map(i => i.id)).toEqual([11]));
|
||||
expect(useTripStore.getState().budgetItems.some(i => i.id === 12)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-007: unlinking from a place clears the place_id', async () => {
|
||||
const bodies = captureBudgetPuts();
|
||||
const { result } = renderHook(() => useExpenseLinks(null, 4), { wrapper });
|
||||
await act(async () => { await result.current.unlink(onPlace); });
|
||||
expect(bodies).toEqual([{ id: '14', body: { place_id: null } }]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-008: a failing update is reported instead of thrown', async () => {
|
||||
server.use(http.put('/api/trips/1/budget/15', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
const { result } = renderHook(() => useExpenseLinks(9), { wrapper });
|
||||
await act(async () => { await result.current.link(loose); });
|
||||
expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined);
|
||||
expect(useTripStore.getState().budgetItems.find(i => i.id === 15)?.reservation_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-009: without a trip loaded nothing is sent', async () => {
|
||||
const bodies = captureBudgetPuts();
|
||||
seedStore(useTripStore, { trip: null });
|
||||
const { result } = renderHook(() => useExpenseLinks(9), { wrapper });
|
||||
await act(async () => { await result.current.unlink(onBooking); });
|
||||
expect(bodies).toEqual([]);
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useFileLinker', () => {
|
||||
it('FE-PLANNER-RECLINKS-010: links the file, reloads the trip files and resolves true', async () => {
|
||||
let body: unknown = null;
|
||||
const linked = buildTripFile({ id: 21, trip_id: 1, linked_reservation_ids: [9] });
|
||||
server.use(
|
||||
http.post('/api/trips/1/files/21/link', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [linked] })),
|
||||
);
|
||||
const { result } = renderHook(() => useFileLinker(), { wrapper });
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => { ok = await result.current(21, { reservation_id: 9 }); });
|
||||
expect(ok).toBe(true);
|
||||
expect(body).toEqual({ reservation_id: 9 });
|
||||
expect(useTripStore.getState().files.map(f => f.id)).toEqual([21]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-011: a failing link is reported and resolves false', async () => {
|
||||
server.use(http.post('/api/trips/1/files/22/link', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
const { result } = renderHook(() => useFileLinker(), { wrapper });
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => { ok = await result.current(22, { place_id: 4 }); });
|
||||
expect(ok).toBe(false);
|
||||
expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-012: without a trip it resolves false and sends nothing', async () => {
|
||||
let called = false;
|
||||
server.use(http.post('/api/trips/:id/files/:fileId/link', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}));
|
||||
seedStore(useTripStore, { trip: null });
|
||||
const { result } = renderHook(() => useFileLinker(), { wrapper });
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => { ok = await result.current(23, { reservation_id: 9 }); });
|
||||
expect(ok).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useReservationFiles', () => {
|
||||
const own = buildTripFile({ id: 31, trip_id: 1, original_name: 'own.pdf', reservation_id: 9 });
|
||||
// The id arrives as text from some paths; it still counts as this booking's.
|
||||
const ownAsText = buildTripFile({ id: 32, trip_id: 1, original_name: 'text-id.pdf', reservation_id: '9' as unknown as number });
|
||||
const viaLink = buildTripFile({ id: 33, trip_id: 1, original_name: 'linked.pdf', linked_reservation_ids: [9] });
|
||||
const other = buildTripFile({ id: 34, trip_id: 1, original_name: 'other.pdf', reservation_id: 10 });
|
||||
const loose = buildTripFile({ id: 35, trip_id: 1, original_name: 'loose.pdf' });
|
||||
const trashed = buildTripFile({ id: 36, trip_id: 1, original_name: 'trashed.pdf', reservation_id: 9, deleted_at: '2025-02-01T00:00:00.000Z' });
|
||||
|
||||
beforeEach(() => {
|
||||
seedStore(useTripStore, { files: [own, ownAsText, viaLink, other, loose, trashed] });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-013: attached are the live files on the booking, directly or through a link row', () => {
|
||||
const { result } = renderHook(() => useReservationFiles(9), { wrapper });
|
||||
expect(result.current.attached.map(f => f.id)).toEqual([31, 32, 33]);
|
||||
// Everything else that is not in the trash can be linked.
|
||||
expect(result.current.linkable.map(f => f.id)).toEqual([34, 35]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-014: nothing is linkable without the file_edit right', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 2 }) });
|
||||
seedStore(usePermissionsStore, { permissions: { file_edit: 'trip_owner' } });
|
||||
const { result } = renderHook(() => useReservationFiles(9), { wrapper });
|
||||
expect(result.current.attached.map(f => f.id)).toEqual([31, 32, 33]);
|
||||
expect(result.current.linkable).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-015: an unsaved booking has no files and links nothing', async () => {
|
||||
let called = false;
|
||||
server.use(http.post('/api/trips/:id/files/:fileId/link', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}));
|
||||
const { result } = renderHook(() => useReservationFiles(null), { wrapper });
|
||||
expect(result.current.attached).toEqual([]);
|
||||
expect(result.current.linkable).toEqual([]);
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => { ok = await result.current.link(loose); });
|
||||
expect(ok).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-016: link attaches a trip file to this booking', async () => {
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/files/35/link', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [{ ...loose, linked_reservation_ids: [9] }] })),
|
||||
);
|
||||
const { result } = renderHook(() => useReservationFiles(9), { wrapper });
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => { ok = await result.current.link(loose); });
|
||||
expect(ok).toBe(true);
|
||||
expect(body).toEqual({ reservation_id: 9 });
|
||||
await waitFor(() => expect(result.current.attached.map(f => f.id)).toEqual([35]));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RECLINKS-017: an empty store is read as no files at all', () => {
|
||||
seedStore(useTripStore, { files: undefined as never });
|
||||
const { result } = renderHook(() => useReservationFiles(9), { wrapper });
|
||||
expect(result.current.attached).toEqual([]);
|
||||
expect(result.current.linkable).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import type { BudgetItem, TripFile } from '../../types'
|
||||
|
||||
/**
|
||||
* The expenses on one booking, transport or place (#2084), for the desktop
|
||||
* Costs block and the phone sheets alike: what is linked here, what belongs
|
||||
* nowhere yet and could be, and linking and letting go of a link (the expense
|
||||
* itself stays in Costs).
|
||||
*
|
||||
* Only expenses without any link are offered, so linking never pulls one away
|
||||
* from another booking or place without anybody noticing. Nothing can be
|
||||
* linked before the record exists, so targetId is null until the first save.
|
||||
*/
|
||||
export function useExpenseLinks(reservationId: number | null | undefined, placeId?: number | null) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const tripId = useTripStore(s => s.trip?.id)
|
||||
const updateBudgetItem = useTripStore(s => s.updateBudgetItem)
|
||||
|
||||
const targetId = reservationId || placeId || null
|
||||
const linkField = reservationId ? 'reservation_id' : 'place_id'
|
||||
const linked = targetId
|
||||
? budgetItems.filter(i => (reservationId ? i.reservation_id === reservationId : i.place_id === placeId))
|
||||
: []
|
||||
const unlinked = budgetItems.filter(i => !i.reservation_id && !i.place_id)
|
||||
|
||||
const setLink = useCallback(async (item: BudgetItem, value: number | null) => {
|
||||
if (!tripId) return
|
||||
try { await updateBudgetItem(tripId, item.id, { [linkField]: value }) }
|
||||
catch { toast.error(t('common.unknownError')) }
|
||||
}, [tripId, updateBudgetItem, linkField, toast, t])
|
||||
|
||||
const link = useCallback((item: BudgetItem) => (targetId ? setLink(item, targetId) : Promise.resolve()), [setLink, targetId])
|
||||
const unlink = useCallback((item: BudgetItem) => setLink(item, null), [setLink])
|
||||
|
||||
return { targetId, linked, unlinked, link, unlink }
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a file the trip already has to a booking or a place (the store reloads
|
||||
* the trip's files, so every list that shows it catches up). Resolves true
|
||||
* when it worked; a failure is reported and resolves false.
|
||||
*/
|
||||
export function useFileLinker() {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const tripId = useTripStore(s => s.trip?.id)
|
||||
const linkFile = useTripStore(s => s.linkFile)
|
||||
|
||||
return useCallback(async (fileId: number, target: { reservation_id?: number; place_id?: number }) => {
|
||||
if (!tripId) return false
|
||||
try {
|
||||
await linkFile(tripId, fileId, target)
|
||||
return true
|
||||
} catch {
|
||||
toast.error(t('reservations.toast.updateError'))
|
||||
return false
|
||||
}
|
||||
}, [tripId, linkFile, toast, t])
|
||||
}
|
||||
|
||||
/**
|
||||
* The files on one booking or transport, and the trip's other files it could
|
||||
* take, for the phone sheets. A file counts as attached through its own
|
||||
* reservation_id or through a link row, the same test the desktop dialog uses.
|
||||
*/
|
||||
export function useReservationFiles(reservationId: number | null | undefined) {
|
||||
const files = useTripStore(s => s.files)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const can = useCanDo()
|
||||
const linkFile = useFileLinker()
|
||||
const live = (files || []).filter(f => !f.deleted_at)
|
||||
const isAttached = (f: TripFile) =>
|
||||
!!reservationId && (String(f.reservation_id) === String(reservationId) || (f.linked_reservation_ids || []).includes(reservationId))
|
||||
const attached = live.filter(isAttached)
|
||||
// Linking is a file edit on the server, so without that right nothing is offered.
|
||||
const linkable = reservationId && can('file_edit', trip) ? live.filter(f => !isAttached(f)) : []
|
||||
const link = useCallback(
|
||||
(file: TripFile) => (reservationId ? linkFile(file.id, { reservation_id: reservationId }) : Promise.resolve(false)),
|
||||
[linkFile, reservationId],
|
||||
)
|
||||
return { attached, linkable, link }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import PlFileAttach from './PlFileAttach'
|
||||
import MLinkedCosts from './MLinkedCosts'
|
||||
import { useReservationFiles } from '../../../../components/Planner/useRecordLinks'
|
||||
import type { BudgetItem } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
|
||||
interface MBookingFilesCostsProps {
|
||||
planner: TripPlanner
|
||||
/** The saved booking or transport; linking waits for it. */
|
||||
reservationId: number | null | undefined
|
||||
pendingFiles: File[]
|
||||
setPendingFiles: Dispatch<SetStateAction<File[]>>
|
||||
canUploadFiles: boolean
|
||||
/** The Costs addon is on. */
|
||||
showCosts: boolean
|
||||
createDisabled: boolean
|
||||
onCreate: () => void
|
||||
onEdit: (item: BudgetItem) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The foot of the phone booking and transport sheets (#2084): the Files row
|
||||
* with the files already on the booking and one of the trip's to link, then
|
||||
* the Costs block with its linked expenses. The attached files are listed
|
||||
* whoever uploaded them, so one from the desktop shows here too (#2217). Both
|
||||
* sheets carry exactly this, so it lives here once.
|
||||
*/
|
||||
export default function MBookingFilesCosts({
|
||||
planner, reservationId, pendingFiles, setPendingFiles, canUploadFiles, showCosts, createDisabled, onCreate, onEdit,
|
||||
}: MBookingFilesCostsProps) {
|
||||
const bookingFiles = useReservationFiles(reservationId)
|
||||
const showFiles = canUploadFiles || bookingFiles.attached.length > 0 || bookingFiles.linkable.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFiles && (
|
||||
<PlFileAttach
|
||||
planner={planner}
|
||||
files={pendingFiles}
|
||||
onAdd={files => setPendingFiles(prev => [...prev, ...files])}
|
||||
onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))}
|
||||
hideHint
|
||||
canAttach={canUploadFiles}
|
||||
attached={bookingFiles.attached}
|
||||
linkable={bookingFiles.linkable}
|
||||
onLink={bookingFiles.link}
|
||||
/>
|
||||
)}
|
||||
{showCosts && (
|
||||
<MLinkedCosts
|
||||
reservationId={reservationId}
|
||||
hintKey="reservations.createExpenseHint"
|
||||
createDisabled={createDisabled}
|
||||
onCreate={onCreate}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight, Link2, Link2Off, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { formatMoney } from '../../../../utils/formatters'
|
||||
import { catMeta } from '../../../../components/Budget/costsCategories'
|
||||
import { useExpenseLinks } from '../../../../components/Planner/useRecordLinks'
|
||||
import type { BudgetItem } from '../../../../types'
|
||||
import { Eyebrow } from './PlSheetChrome'
|
||||
|
||||
const PILL_CLS =
|
||||
'flex w-full items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[11px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40'
|
||||
const ROW_ICON_BTN_CLS = 'flex h-8 w-8 flex-none items-center justify-center rounded-full text-m-faint active:bg-[color:var(--m-ic)]'
|
||||
// Past this many the list gets a search field, a trip can collect a lot of expenses.
|
||||
const SEARCH_FROM = 6
|
||||
|
||||
interface MLinkedCostsProps {
|
||||
reservationId?: number | null
|
||||
placeId?: number | null
|
||||
/** "The booking" or "the place" is saved before the editor opens. */
|
||||
hintKey: string
|
||||
createDisabled: boolean
|
||||
onCreate: () => void
|
||||
onEdit: (item: BudgetItem) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The Costs block of the phone booking, transport and place sheets (#2084):
|
||||
* the expenses linked here (tap to edit, unlink keeps the expense, delete),
|
||||
* then "create expense" and, once the record exists, an inline list of the
|
||||
* trip's unlinked expenses to link one from. Same logic as the desktop block,
|
||||
* through useExpenseLinks.
|
||||
*/
|
||||
export default function MLinkedCosts({ reservationId = null, placeId = null, hintKey, createDisabled, onCreate, onEdit }: MLinkedCostsProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const tripId = useTripStore(s => s.trip?.id)
|
||||
const tripCurrency = useTripStore(s => s.trip?.currency)
|
||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || tripCurrency || 'EUR').toUpperCase()
|
||||
const { targetId, linked, unlinked, link, unlink } = useExpenseLinks(reservationId, placeId)
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const money = (item: BudgetItem) => formatMoney(item.total_price, item.currency || base, locale)
|
||||
const remove = async (item: BudgetItem) => {
|
||||
if (!tripId) return
|
||||
try { await deleteBudgetItem(tripId, item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
const needle = query.trim().toLowerCase()
|
||||
const offered = needle ? unlinked.filter(i => i.name.toLowerCase().includes(needle)) : unlinked
|
||||
|
||||
return (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{linked.length > 0 ? t('reservations.linkedExpenses') : t('reservations.costsLabel')}</Eyebrow>
|
||||
|
||||
{linked.length > 0 && (
|
||||
<div className="mb-2 flex flex-col gap-[6px]">
|
||||
{linked.map(item => {
|
||||
const meta = catMeta(item.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-1 rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[5px] pl-[10px] pr-1">
|
||||
<button type="button" onClick={() => onEdit(item)} aria-label={t('common.edit')} className="flex min-w-0 flex-1 items-center gap-[10px] py-[3px] text-left">
|
||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-[9px]" style={{ background: `${meta.color}22`, color: meta.color }}>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{item.name}</span>
|
||||
<span className="block truncate font-geist text-[0.65625rem] text-m-faint">{t(meta.labelKey)}</span>
|
||||
</span>
|
||||
<span className="flex-none font-geist text-[0.8125rem] font-semibold tabular-nums text-m-ink">{money(item)}</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => void unlink(item)} aria-label={t('reservations.unlinkExpense')} title={t('reservations.unlinkExpense')} className={ROW_ICON_BTN_CLS}>
|
||||
<Link2Off size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button type="button" onClick={() => void remove(item)} aria-label={t('reservations.removeExpense')} title={t('reservations.removeExpense')} className={`${ROW_ICON_BTN_CLS} active:text-[color:var(--m-st-danger)]`}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`grid gap-2 ${targetId ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<button type="button" onClick={onCreate} disabled={createDisabled} className={PILL_CLS}>
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('reservations.createExpense')}
|
||||
</button>
|
||||
{/* Linking needs the record to exist, so it waits for the first save. */}
|
||||
{targetId && (
|
||||
<button type="button" onClick={() => setPicking(v => !v)} aria-expanded={picking} className={`${PILL_CLS} ${picking ? '!bg-m-act !text-m-actfg' : ''}`}>
|
||||
<Link2 size={13} strokeWidth={2.2} />
|
||||
{t('files.link')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{picking && targetId && (
|
||||
<div className="mt-2 rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-1">
|
||||
{unlinked.length >= SEARCH_FROM && (
|
||||
<div className="mx-1 mb-1 mt-1 flex items-center gap-2 rounded-[10px] bg-[color:var(--m-ic)] px-[10px] py-[7px]">
|
||||
<Search size={13} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={t('common.search')}
|
||||
className="min-w-0 flex-1 bg-transparent text-[0.78125rem] text-m-ink outline-none placeholder:text-m-faint" />
|
||||
</div>
|
||||
)}
|
||||
{offered.length === 0 ? (
|
||||
<div className="px-[10px] py-[10px] text-center font-geist text-[0.6875rem] text-m-faint">{t('reservations.noUnlinkedExpenses')}</div>
|
||||
) : (
|
||||
<div className="flex max-h-[232px] flex-col overflow-y-auto">
|
||||
{offered.map(item => {
|
||||
const meta = catMeta(item.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<button key={item.id} type="button"
|
||||
onClick={async () => { await link(item); setPicking(false); setQuery('') }}
|
||||
className="flex w-full items-center gap-[10px] rounded-[10px] px-[10px] py-[8px] text-left active:bg-[color:var(--m-ic)]">
|
||||
<Icon size={14} strokeWidth={2} className="flex-none" style={{ color: meta.color }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-medium text-m-ink">{item.name}</span>
|
||||
<span className="flex-none font-geist text-[0.71875rem] tabular-nums text-m-muted">{money(item)}</span>
|
||||
<ChevronRight size={13} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linked.length === 0 && <div className="mt-[5px] font-geist text-[0.625rem] text-m-faint">{t(hintKey)}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from 'react'
|
||||
import { MapPin, Plus } from 'lucide-react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import {
|
||||
DEFAULT_FORM,
|
||||
@@ -14,6 +14,7 @@ import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch'
|
||||
import PlCategoryPicker from './PlCategoryPicker'
|
||||
import PlTimeFields from './PlTimeFields'
|
||||
import PlFileAttach from './PlFileAttach'
|
||||
import MLinkedCosts from './MLinkedCosts'
|
||||
import PlaceDetailsColumn, { type PlaceDetailsSelection } from '../../../../components/Planner/PlaceDetailsColumn'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
@@ -477,19 +478,13 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
|
||||
{/* COSTS — same block, same flow as the booking sheet (#1298) */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.costsLabel')}</Eyebrow>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { expenseIntentRef.current = true; handleSubmit() }}
|
||||
disabled={!form.name.trim() || isSaving}
|
||||
className="flex w-full items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[11px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="mt-[5px] font-geist text-[0.625rem] text-m-faint">{t('places.createExpenseHint')}</div>
|
||||
</>
|
||||
<MLinkedCosts
|
||||
placeId={sheetPlace?.id}
|
||||
hintKey="places.createExpenseHint"
|
||||
createDisabled={!form.name.trim() || isSaving}
|
||||
onCreate={() => { expenseIntentRef.current = true; handleSubmit() }}
|
||||
onEdit={item => onOpenExpense({ editItem: item })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ExternalLink, FileText, Hotel, Link2, ParkingSquare, Plus, Ticket, Users, Utensils } from 'lucide-react'
|
||||
import { Check, FileText, Hotel, Link2, ParkingSquare, Ticket, Users, Utensils } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
@@ -11,9 +11,8 @@ import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
import { BookingCodeInput } from '../../../../components/shared/BookingCode'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import PlFileAttach from './PlFileAttach'
|
||||
import MBookingFilesCosts from './MBookingFilesCosts'
|
||||
import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
||||
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
@@ -61,7 +60,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
showReservationModal, setShowReservationModal,
|
||||
editingReservation, setEditingReservation, reservationPrefill,
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
assignments, files,
|
||||
assignments,
|
||||
importReviewActive, advanceImportReview,
|
||||
handleSaveReservation, canUploadFiles, tripActions,
|
||||
} = planner
|
||||
@@ -162,16 +161,6 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
}, [showReservationModal])
|
||||
|
||||
const res = snap.res
|
||||
// Files already on this booking. The sheet used to list only the ones picked
|
||||
// in this session, so an upload from the desktop was invisible here (#2217).
|
||||
const attachedFiles = res?.id
|
||||
? (files || []).filter(f =>
|
||||
!f.deleted_at && (
|
||||
String(f.reservation_id) === String(res.id) ||
|
||||
(f.linked_reservation_ids || []).includes(res.id)
|
||||
),
|
||||
)
|
||||
: []
|
||||
const isHotel = form.type === 'hotel'
|
||||
const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
@@ -611,51 +600,18 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* FILES */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('files.title')}</Eyebrow>
|
||||
<div className="flex flex-col gap-1">
|
||||
{attachedFiles.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => openFile(f.url, f.original_name)}
|
||||
className="flex w-full items-center gap-2 rounded-[10px] bg-[color:var(--m-ic)] px-[10px] py-[7px] text-left"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.75rem] font-medium">{f.original_name}</span>
|
||||
<ExternalLink size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{canUploadFiles && (
|
||||
<PlFileAttach
|
||||
planner={planner}
|
||||
files={pendingFiles}
|
||||
onAdd={files => setPendingFiles(prev => [...prev, ...files])}
|
||||
onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))}
|
||||
hideHint
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* COSTS */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.costsLabel')}</Eyebrow>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { expenseIntentRef.current = true; handleSubmit() }}
|
||||
disabled={!form.title.trim() || isSaving}
|
||||
className="flex w-full items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[11px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="mt-[5px] font-geist text-[0.625rem] text-m-faint">{t('reservations.createExpenseHint')}</div>
|
||||
</>
|
||||
)}
|
||||
{/* FILES + COSTS */}
|
||||
<MBookingFilesCosts
|
||||
planner={planner}
|
||||
reservationId={res?.id}
|
||||
pendingFiles={pendingFiles}
|
||||
setPendingFiles={setPendingFiles}
|
||||
canUploadFiles={canUploadFiles}
|
||||
showCosts={isBudgetEnabled}
|
||||
createDisabled={!form.title.trim() || isSaving}
|
||||
onCreate={() => { expenseIntentRef.current = true; handleSubmit() }}
|
||||
onEdit={item => onOpenExpense({ editItem: item })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormSheetFooter
|
||||
|
||||
@@ -14,7 +14,7 @@ import LocationSelect, { type LocationPoint } from '../../../../components/Plann
|
||||
import { toLocationPicks } from '../../../../components/Planner/locationPicks'
|
||||
import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import PlFileAttach from './PlFileAttach'
|
||||
import MBookingFilesCosts from './MBookingFilesCosts'
|
||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
||||
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
@@ -1086,33 +1086,18 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FILES */}
|
||||
{canUploadFiles && (
|
||||
<PlFileAttach
|
||||
planner={planner}
|
||||
files={pendingFiles}
|
||||
onAdd={files => setPendingFiles(prev => [...prev, ...files])}
|
||||
onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))}
|
||||
hideHint
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* COSTS */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.costsLabel')}</Eyebrow>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { expenseIntentRef.current = true; handleSubmit() }}
|
||||
disabled={!form.title.trim() || isSaving}
|
||||
className="flex w-full items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[11px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="mt-[5px] font-geist text-[0.625rem] text-m-faint">{t('reservations.createExpenseHint')}</div>
|
||||
</>
|
||||
)}
|
||||
{/* FILES + COSTS */}
|
||||
<MBookingFilesCosts
|
||||
planner={planner}
|
||||
reservationId={res?.id}
|
||||
pendingFiles={pendingFiles}
|
||||
setPendingFiles={setPendingFiles}
|
||||
canUploadFiles={canUploadFiles}
|
||||
showCosts={isBudgetEnabled}
|
||||
createDisabled={!form.title.trim() || isSaving}
|
||||
onCreate={() => { expenseIntentRef.current = true; handleSubmit() }}
|
||||
onEdit={item => onOpenExpense({ editItem: item })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useRef } from 'react'
|
||||
import { Paperclip, X } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { ExternalLink, FileText, Link2, Paperclip, X } from 'lucide-react'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import type { TripFile } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
|
||||
interface PlFileAttachProps {
|
||||
@@ -9,16 +11,30 @@ interface PlFileAttachProps {
|
||||
onRemove: (index: number) => void
|
||||
/** Hide the "you can also paste…" subline (booking/transport sheets keep it terse). */
|
||||
hideHint?: boolean
|
||||
/** False hides the picker pill (no upload permission); the row still lists and links files. */
|
||||
canAttach?: boolean
|
||||
/** Files already on the record, listed above the pending ones and opened on tap. */
|
||||
attached?: TripFile[]
|
||||
/** The trip's other files the record could take; empty hides the link pill. */
|
||||
linkable?: TripFile[]
|
||||
/** Links one; resolves true when it worked, which folds the list away. */
|
||||
onLink?: (file: TripFile) => Promise<boolean>
|
||||
}
|
||||
|
||||
const ITEM_CLS = 'flex w-full items-center gap-2 rounded-[10px] bg-[color:var(--m-ic)] px-2 py-[6px] text-left'
|
||||
|
||||
/**
|
||||
* Files row of the place form: picker pill plus the pending attachments the
|
||||
* sheet uploads after save. Clipboard paste is handled by the sheet's onPaste
|
||||
* so it works from any focused field.
|
||||
* Files row of the place and booking forms: picker pill plus the pending
|
||||
* attachments the sheet uploads after save. On a saved booking it also lists
|
||||
* the files already attached and links one the trip already has (#2084).
|
||||
* Clipboard paste is handled by the sheet's onPaste so it works from any
|
||||
* focused field.
|
||||
*/
|
||||
export default function PlFileAttach({ planner, files, onAdd, onRemove, hideHint = false }: PlFileAttachProps) {
|
||||
export default function PlFileAttach({ planner, files, onAdd, onRemove, hideHint = false, canAttach = true, attached = [], linkable = [], onLink }: PlFileAttachProps) {
|
||||
const { t } = planner
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [picking, setPicking] = useState(false)
|
||||
const canLink = !!onLink && linkable.length > 0
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[10px]">
|
||||
@@ -27,14 +43,27 @@ export default function PlFileAttach({ planner, files, onAdd, onRemove, hideHint
|
||||
<div className="text-[0.78125rem] font-semibold text-m-ink">{t('files.title')}</div>
|
||||
{!hideHint && <div className="truncate font-geist text-[0.65625rem] text-m-faint">{t('files.pasteHint')}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex flex-none items-center gap-[5px] rounded-full bg-m-act px-3 py-[6px] text-[0.6875rem] font-semibold text-m-actfg"
|
||||
>
|
||||
<Paperclip size={12} strokeWidth={2.2} />
|
||||
{t('files.attach')}
|
||||
</button>
|
||||
{canLink && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicking(v => !v)}
|
||||
aria-expanded={picking}
|
||||
className={`flex flex-none items-center gap-[5px] rounded-full border px-3 py-[5px] text-[0.6875rem] font-semibold ${picking ? 'border-transparent bg-m-act text-m-actfg' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'}`}
|
||||
>
|
||||
<Link2 size={12} strokeWidth={2.2} />
|
||||
{t('files.link')}
|
||||
</button>
|
||||
)}
|
||||
{canAttach && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex flex-none items-center gap-[5px] rounded-full bg-m-act px-3 py-[6px] text-[0.6875rem] font-semibold text-m-actfg"
|
||||
>
|
||||
<Paperclip size={12} strokeWidth={2.2} />
|
||||
{t('files.attach')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -46,13 +75,33 @@ export default function PlFileAttach({ planner, files, onAdd, onRemove, hideHint
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{files.map((file, idx) => (
|
||||
<div
|
||||
key={`${file.name}-${idx}`}
|
||||
className="flex items-center gap-2 rounded-[10px] bg-[color:var(--m-ic)] px-2 py-[6px]"
|
||||
{picking && canLink && (
|
||||
<div className="mt-2 flex max-h-[200px] flex-col gap-1 overflow-y-auto">
|
||||
{linkable.map(file => (
|
||||
<button
|
||||
key={file.id}
|
||||
type="button"
|
||||
onClick={async () => { if (await onLink!(file)) setPicking(false) }}
|
||||
className={ITEM_CLS}
|
||||
>
|
||||
<FileText size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-ink">{file.original_name}</span>
|
||||
<Link2 size={12} strokeWidth={2.2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(attached.length > 0 || files.length > 0) && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{attached.map(file => (
|
||||
<button key={file.id} type="button" onClick={() => { void openFile(file.url, file.original_name) }} className={ITEM_CLS}>
|
||||
<FileText size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] font-medium text-m-ink">{file.original_name}</span>
|
||||
<ExternalLink size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
))}
|
||||
{files.map((file, idx) => (
|
||||
<div key={`${file.name}-${idx}`} className={ITEM_CLS}>
|
||||
<Paperclip size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-muted">{file.name}</span>
|
||||
<button
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { FileText, MapPin, Pencil, Trash2 } from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
|
||||
import { splitReservationDateTime, formatTime, cleanAmountText } from '../../../../utils/formatters'
|
||||
import { splitReservationDateTime, formatTime, formatPriceText } from '../../../../utils/formatters'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
@@ -122,7 +122,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
||||
if (meta.platform) metaCells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) metaCells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
|
||||
if (meta.price != null && meta.price !== '') {
|
||||
metaCells.push({ label: t('reservations.price'), value: `${cleanAmountText(meta.price)}${meta.priceCurrency ? ` ${meta.priceCurrency}` : ''}` })
|
||||
metaCells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, planner.trip?.currency, locale) })
|
||||
}
|
||||
if (meta.check_in_time) {
|
||||
metaCells.push({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { FileText, Pencil, Trash2 } from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
|
||||
import { splitReservationDateTime, formatTime, cleanAmountText } from '../../../../utils/formatters'
|
||||
import { splitReservationDateTime, formatTime, formatPriceText } from '../../../../utils/formatters'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
@@ -128,7 +128,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
if (meta.platform) metaCells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) metaCells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
|
||||
if (meta.price != null && meta.price !== '') {
|
||||
metaCells.push({ label: t('reservations.price'), value: `${cleanAmountText(meta.price)}${meta.priceCurrency ? ` ${meta.priceCurrency}` : ''}` })
|
||||
metaCells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, planner.trip?.currency, locale) })
|
||||
}
|
||||
|
||||
const files = (planner.files || []).filter(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-016
|
||||
// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-023
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
@@ -283,4 +283,82 @@ describe('budgetSlice', () => {
|
||||
|
||||
expect(addToast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// ── Which writes refresh the bookings (#2084) ───────────────────────────────
|
||||
// A booking mirrors the total of its linked expenses, so the reservations are
|
||||
// reloaded after any write to an expense on a booking and whenever a link
|
||||
// moves, and only then.
|
||||
|
||||
function spyReload() {
|
||||
const loadReservations = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useTripStore, { loadReservations });
|
||||
return loadReservations;
|
||||
}
|
||||
|
||||
it('FE-STORE-BUDGET-018: linking an expense to a booking reloads the reservations without a new total', async () => {
|
||||
const existing = buildBudgetItem({ id: 50, trip_id: 1 });
|
||||
seedStore(useTripStore, { budgetItems: [existing] });
|
||||
const loadReservations = spyReload();
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/50', () => HttpResponse.json({ item: { ...existing, reservation_id: 9 } }))
|
||||
);
|
||||
await useTripStore.getState().updateBudgetItem(1, 50, { reservation_id: 9 });
|
||||
expect(loadReservations).toHaveBeenCalledWith(1);
|
||||
expect(useTripStore.getState().budgetItems[0].reservation_id).toBe(9);
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-019: unlinking reloads the reservations although the saved item has no booking any more', async () => {
|
||||
const existing = buildBudgetItem({ id: 51, trip_id: 1, reservation_id: 9 });
|
||||
seedStore(useTripStore, { budgetItems: [existing] });
|
||||
const loadReservations = spyReload();
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/51', () => HttpResponse.json({ item: { ...existing, reservation_id: null } }))
|
||||
);
|
||||
await useTripStore.getState().updateBudgetItem(1, 51, { reservation_id: null });
|
||||
expect(loadReservations).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-020: a new total on an expense without a booking, or a place link, leaves the reservations alone', async () => {
|
||||
const existing = buildBudgetItem({ id: 52, trip_id: 1 });
|
||||
seedStore(useTripStore, { budgetItems: [existing] });
|
||||
const loadReservations = spyReload();
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/52', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: { ...existing, ...body } });
|
||||
})
|
||||
);
|
||||
await useTripStore.getState().updateBudgetItem(1, 52, { total_price: 80 });
|
||||
await useTripStore.getState().updateBudgetItem(1, 52, { place_id: 4 });
|
||||
expect(loadReservations).not.toHaveBeenCalled();
|
||||
expect(useTripStore.getState().budgetItems[0].place_id).toBe(4);
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-021: any edit of an expense on a booking reloads the reservations, not only a new total', async () => {
|
||||
const existing = buildBudgetItem({ id: 53, trip_id: 1, reservation_id: 9 });
|
||||
seedStore(useTripStore, { budgetItems: [existing] });
|
||||
const loadReservations = spyReload();
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/53', () => HttpResponse.json({ item: { ...existing, currency: 'USD' } }))
|
||||
);
|
||||
await useTripStore.getState().updateBudgetItem(1, 53, { currency: 'USD' });
|
||||
expect(loadReservations).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-022: an expense created on a booking reloads the reservations', async () => {
|
||||
const loadReservations = spyReload();
|
||||
const created = buildBudgetItem({ id: 54, trip_id: 1, reservation_id: 9 });
|
||||
server.use(http.post('/api/trips/1/budget', () => HttpResponse.json({ item: created })));
|
||||
await useTripStore.getState().addBudgetItem(1, { name: 'Upgrade', reservation_id: 9 });
|
||||
expect(loadReservations).toHaveBeenCalledWith(1);
|
||||
expect(useTripStore.getState().budgetItems).toContainEqual(created);
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-023: an expense created without a booking leaves the reservations alone', async () => {
|
||||
const loadReservations = spyReload();
|
||||
const created = buildBudgetItem({ id: 55, trip_id: 1, place_id: 4 });
|
||||
server.use(http.post('/api/trips/1/budget', () => HttpResponse.json({ item: created })));
|
||||
await useTripStore.getState().addBudgetItem(1, { name: 'Museum', place_id: 4 });
|
||||
expect(loadReservations).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
try {
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||
// The booking mirrors its expenses' total (#2084), so a new one on it changes its card.
|
||||
if (result.item.reservation_id) get().loadReservations(tripId)
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding budget item'))
|
||||
@@ -47,7 +49,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
if (result.item.reservation_id && data.total_price !== undefined) {
|
||||
// The booking mirrors its expenses' total, so any edit of an expense on one
|
||||
// (total, currency, payers) or a link that moved (#2084) changes its card.
|
||||
if (result.item.reservation_id || data.reservation_id !== undefined) {
|
||||
get().loadReservations(tripId)
|
||||
}
|
||||
return result.item
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// FE-STORE-FILES-001 to FE-STORE-FILES-007
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildTripFile } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../tripStore';
|
||||
|
||||
/**
|
||||
* unlinkFileFromReservation (#2084): a file can sit on a booking two ways, through
|
||||
* its own reservation_id (uploaded there) or through a link row (linked later).
|
||||
* Taking it off has to undo whichever applies and keep the file itself.
|
||||
*/
|
||||
describe('filesSlice.unlinkFileFromReservation', () => {
|
||||
let calls: string[];
|
||||
let reloads: number;
|
||||
|
||||
/** The server side of one unlink, recording what the store sent in which order. */
|
||||
function serve(links: { id: number; reservation_id: number | null }[] | undefined, fileId = 7) {
|
||||
server.use(
|
||||
http.put(`/api/trips/1/files/${fileId}`, async ({ request }) => {
|
||||
calls.push(`PUT ${JSON.stringify(await request.json())}`);
|
||||
return HttpResponse.json({ file: buildTripFile({ id: fileId }) });
|
||||
}),
|
||||
http.get(`/api/trips/1/files/${fileId}/links`, () => {
|
||||
calls.push('GET links');
|
||||
return HttpResponse.json(links === undefined ? {} : { links });
|
||||
}),
|
||||
http.delete(`/api/trips/1/files/${fileId}/link/:linkId`, ({ params }) => {
|
||||
calls.push(`DELETE link ${String(params.linkId)}`);
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
calls = [];
|
||||
reloads = 0;
|
||||
const reloaded = buildTripFile({ id: 99, trip_id: 1, original_name: 'reloaded.pdf' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/files', () => {
|
||||
reloads += 1;
|
||||
return HttpResponse.json({ files: [reloaded] });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-STORE-FILES-001: a file uploaded on the booking loses its reservation_id and any link row to it', async () => {
|
||||
serve([{ id: 3, reservation_id: 9 }, { id: 4, reservation_id: 10 }]);
|
||||
const file = buildTripFile({ id: 7, trip_id: 1, reservation_id: 9 });
|
||||
|
||||
await useTripStore.getState().unlinkFileFromReservation(1, file, 9);
|
||||
|
||||
// Only the link row of this booking goes; the other booking keeps its link.
|
||||
expect(calls).toEqual(['PUT {"reservation_id":null}', 'GET links', 'DELETE link 3']);
|
||||
expect(reloads).toBe(1);
|
||||
expect(useTripStore.getState().files.map(f => f.id)).toEqual([99]);
|
||||
});
|
||||
|
||||
it('FE-STORE-FILES-002: a file linked later keeps its own reservation_id and loses only the link row', async () => {
|
||||
serve([{ id: 5, reservation_id: 9 }]);
|
||||
const file = buildTripFile({ id: 7, trip_id: 1, reservation_id: 10, linked_reservation_ids: [9] });
|
||||
|
||||
await useTripStore.getState().unlinkFileFromReservation(1, file, 9);
|
||||
|
||||
expect(calls).toEqual(['GET links', 'DELETE link 5']);
|
||||
expect(reloads).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-FILES-003: without a link row for the booking nothing is deleted, and an answer without links reads as none', async () => {
|
||||
serve(undefined);
|
||||
const file = buildTripFile({ id: 7, trip_id: 1, reservation_id: 9 });
|
||||
|
||||
await useTripStore.getState().unlinkFileFromReservation(1, file, 9);
|
||||
|
||||
expect(calls).toEqual(['PUT {"reservation_id":null}', 'GET links']);
|
||||
expect(reloads).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-FILES-004: a link row of another booking is left alone', async () => {
|
||||
serve([{ id: 6, reservation_id: 11 }]);
|
||||
await useTripStore.getState().unlinkFileFromReservation(1, buildTripFile({ id: 7, trip_id: 1 }), 9);
|
||||
expect(calls).toEqual(['GET links']);
|
||||
});
|
||||
|
||||
it('FE-STORE-FILES-005: a refused write throws the server message and still reloads the files', async () => {
|
||||
serve([]);
|
||||
server.use(http.put('/api/trips/1/files/7', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })));
|
||||
const file = buildTripFile({ id: 7, trip_id: 1, reservation_id: 9 });
|
||||
|
||||
await expect(useTripStore.getState().unlinkFileFromReservation(1, file, 9)).rejects.toThrow('Not allowed');
|
||||
|
||||
// The link rows are not touched after the first write failed.
|
||||
expect(calls).toEqual([]);
|
||||
expect(reloads).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-FILES-006: a failure without a server message falls back to the generic one', async () => {
|
||||
serve([]);
|
||||
server.use(http.get('/api/trips/1/files/7/links', () => HttpResponse.json({}, { status: 500 })));
|
||||
|
||||
await expect(
|
||||
useTripStore.getState().unlinkFileFromReservation(1, buildTripFile({ id: 7, trip_id: 1 }), 9),
|
||||
).rejects.toThrow('Error unlinking file');
|
||||
expect(reloads).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-FILES-007: a failing link delete is rethrown too', async () => {
|
||||
serve([{ id: 8, reservation_id: 9 }]);
|
||||
server.use(http.delete('/api/trips/1/files/7/link/8', () => HttpResponse.json({ error: 'Link locked' }, { status: 409 })));
|
||||
seedStore(useTripStore, { files: [buildTripFile({ id: 7, trip_id: 1 })] });
|
||||
|
||||
await expect(
|
||||
useTripStore.getState().unlinkFileFromReservation(1, buildTripFile({ id: 7, trip_id: 1 }), 9),
|
||||
).rejects.toThrow('Link locked');
|
||||
// The reload in finally replaced the list all the same.
|
||||
expect(useTripStore.getState().files.map(f => f.id)).toEqual([99]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { fileRepo } from '../../repo/fileRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { TripFile } from '../../types'
|
||||
import type { FileLinkRequest } from '@trek/shared'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
@@ -12,6 +13,10 @@ export interface FilesSlice {
|
||||
loadFiles: (tripId: number | string) => Promise<void>
|
||||
addFile: (tripId: number | string, formData: FormData) => Promise<TripFile>
|
||||
deleteFile: (tripId: number | string, id: number) => Promise<void>
|
||||
/** Links a file the trip already has to a booking, place or expense, then reloads the files. */
|
||||
linkFile: (tripId: number | string, id: number, link: FileLinkRequest) => Promise<void>
|
||||
/** Takes a file off a booking, whichever way it was attached, and keeps the file. */
|
||||
unlinkFileFromReservation: (tripId: number | string, file: TripFile, reservationId: number) => Promise<void>
|
||||
}
|
||||
|
||||
export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
|
||||
@@ -42,4 +47,27 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
|
||||
}
|
||||
},
|
||||
|
||||
linkFile: async (tripId, id, link) => {
|
||||
try {
|
||||
await filesApi.addLink(tripId, id, link)
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error linking file'))
|
||||
}
|
||||
await get().loadFiles(tripId)
|
||||
},
|
||||
|
||||
unlinkFileFromReservation: async (tripId, file, reservationId) => {
|
||||
try {
|
||||
// Uploaded on the booking, the file points at it itself; linked later, a link row does.
|
||||
if (file.reservation_id === reservationId) await filesApi.update(tripId, file.id, { reservation_id: null })
|
||||
const { links = [] } = (await filesApi.getLinks(tripId, file.id)) as { links?: { id: number; reservation_id: number | null }[] }
|
||||
const link = links.find(l => l.reservation_id === reservationId)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error unlinking file'))
|
||||
} finally {
|
||||
await get().loadFiles(tripId)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-STORE-RESERVATIONS-001 to FE-STORE-RESERVATIONS-003
|
||||
// FE-STORE-RESERVATIONS-001 to FE-STORE-RESERVATIONS-005
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildReservation } from '../../../tests/helpers/factories';
|
||||
import { buildBudgetItem, buildReservation } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../tripStore';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -53,4 +53,39 @@ describe('reservationsSlice', () => {
|
||||
await useTripStore.getState().setReservationTravelers(1, 7, [2]);
|
||||
expect(useTripStore.getState().reservations.find(r => r.id === 8)?.travelers).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-STORE-RESERVATIONS-004: deleting a booking also drops the expenses linked to it (#2084)', async () => {
|
||||
const booking = buildReservation({ id: 12, trip_id: 1 });
|
||||
const other = buildReservation({ id: 13, trip_id: 1 });
|
||||
seedStore(useTripStore, {
|
||||
reservations: [booking, other],
|
||||
budgetItems: [
|
||||
buildBudgetItem({ id: 1, trip_id: 1, reservation_id: 12 }),
|
||||
buildBudgetItem({ id: 2, trip_id: 1, reservation_id: 12 }),
|
||||
buildBudgetItem({ id: 3, trip_id: 1, reservation_id: 13 }),
|
||||
buildBudgetItem({ id: 4, trip_id: 1 }),
|
||||
],
|
||||
});
|
||||
server.use(http.delete('/api/trips/1/reservations/12', () => HttpResponse.json({ success: true })));
|
||||
|
||||
await useTripStore.getState().deleteReservation(1, 12);
|
||||
|
||||
expect(useTripStore.getState().reservations.map(r => r.id)).toEqual([13]);
|
||||
// The server deletes the booking's expenses with it; the other booking's and the unlinked one stay.
|
||||
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('FE-STORE-RESERVATIONS-005: a failed delete keeps the booking and its expenses', async () => {
|
||||
const booking = buildReservation({ id: 14, trip_id: 1 });
|
||||
seedStore(useTripStore, {
|
||||
reservations: [booking],
|
||||
budgetItems: [buildBudgetItem({ id: 5, trip_id: 1, reservation_id: 14 })],
|
||||
});
|
||||
server.use(http.delete('/api/trips/1/reservations/14', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
|
||||
await expect(useTripStore.getState().deleteReservation(1, 14)).rejects.toThrow();
|
||||
|
||||
expect(useTripStore.getState().reservations).toHaveLength(1);
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,10 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
|
||||
deleteReservation: async (tripId, id) => {
|
||||
try {
|
||||
await reservationsApi.delete(tripId, id)
|
||||
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
||||
set(state => ({
|
||||
reservations: state.reservations.filter(r => r.id !== id),
|
||||
budgetItems: state.budgetItems.filter(i => i.reservation_id !== id),
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, currencyDecimals, localizeAmountInput, amountToInputString } from './formatters'
|
||||
import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, formatPriceText, currencyDecimals, localizeAmountInput, amountToInputString } from './formatters'
|
||||
import { CURRENCIES, SYMBOLS, currenciesWith } from '../components/Budget/BudgetPanel.constants'
|
||||
import type { Day } from '../types'
|
||||
|
||||
@@ -200,6 +200,35 @@ describe('formatMoneySum (#1561)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPriceText (#2084)', () => {
|
||||
it('formats a numeric price as money in its own currency', () => {
|
||||
expect(formatPriceText(320, 'EUR', 'USD', 'en')).toBe(formatMoney(320, 'EUR', 'en'))
|
||||
expect(formatPriceText('12.5', 'chf', 'EUR', 'en')).toBe(formatMoney(12.5, 'CHF', 'en'))
|
||||
})
|
||||
|
||||
it("takes the trip's currency when the price names none", () => {
|
||||
expect(formatPriceText('45', null, 'jpy', 'en')).toBe(formatMoney(45, 'JPY', 'en'))
|
||||
expect(formatPriceText(45, '', 'CHF', 'en')).toBe(formatMoney(45, 'CHF', 'en'))
|
||||
})
|
||||
|
||||
it('shows nothing for a missing or empty price', () => {
|
||||
expect(formatPriceText(null, 'EUR', 'EUR', 'en')).toBe('')
|
||||
expect(formatPriceText(undefined, 'EUR', 'EUR', 'en')).toBe('')
|
||||
expect(formatPriceText('', 'EUR', 'EUR', 'en')).toBe('')
|
||||
})
|
||||
|
||||
it('passes a price that is not a number through as written, with its own currency', () => {
|
||||
expect(formatPriceText('on request', 'EUR', 'USD', 'en')).toBe('on request EUR')
|
||||
expect(formatPriceText('on request', 'eur', 'USD', 'en')).toBe('on request EUR')
|
||||
expect(formatPriceText('included', null, 'USD', 'en')).toBe('included')
|
||||
})
|
||||
|
||||
it('keeps a bare number bare when neither the price nor the trip names a currency', () => {
|
||||
expect(formatPriceText('99', null, null, 'en')).toBe('99')
|
||||
expect(formatPriceText(12.5, undefined, undefined, 'en')).toBe('12.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitReservationDateTime', () => {
|
||||
it('parses full ISO datetime', () => {
|
||||
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||
|
||||
@@ -126,6 +126,24 @@ export function cleanAmountText(value: string | number | null | undefined): stri
|
||||
return Number.isFinite(n) && String(value).trim() !== '' ? String(cleanAmount(n)) : String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking's price for its card: formatted as money when it is a number, in
|
||||
* its own currency or else the trip's (a price with none is in the trip's
|
||||
* currency), and passed through as written otherwise.
|
||||
*/
|
||||
export function formatPriceText(
|
||||
price: string | number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
tripCurrency: string | null | undefined,
|
||||
locale: string,
|
||||
): string {
|
||||
if (price == null || price === '') return ''
|
||||
const n = Number(price)
|
||||
const code = (currency || tripCurrency || '').toUpperCase()
|
||||
if (Number.isFinite(n) && String(price).trim() !== '' && code) return formatMoney(n, code, locale)
|
||||
return `${cleanAmountText(price)}${currency ? ` ${currency.toUpperCase()}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale- and currency-correct money formatting via Intl: the symbol position,
|
||||
* thousands/decimal separators and decimal count all follow the user's locale
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, type ComponentProps } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import MBookingFilesCosts from '../../../../src/mobile/screens/trip/sheets/MBookingFilesCosts'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { buildBudgetItem, buildTrip, buildTripFile, buildUser } from '../../../helpers/factories'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-BKFOOT-001 to FE-MOB-BKFOOT-010
|
||||
// PlFileAttach speaks through planner.t (keys echoed), MLinkedCosts through the
|
||||
// real translations, so file labels are asserted as keys and cost labels in English.
|
||||
|
||||
const planner = buildPlanner()
|
||||
|
||||
type Props = ComponentProps<typeof MBookingFilesCosts>
|
||||
|
||||
/** Holds the pending files the way the sheets do, so adding and dropping can be followed. */
|
||||
function Harness(props: Partial<Omit<Props, 'pendingFiles' | 'setPendingFiles'>> & { initialPending?: File[] }) {
|
||||
const { initialPending = [], ...rest } = props
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>(initialPending)
|
||||
return (
|
||||
<MBookingFilesCosts
|
||||
planner={planner}
|
||||
reservationId={9}
|
||||
pendingFiles={pendingFiles}
|
||||
setPendingFiles={setPendingFiles}
|
||||
canUploadFiles
|
||||
showCosts
|
||||
createDisabled={false}
|
||||
onCreate={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onBooking = buildTripFile({ id: 1, trip_id: 1, original_name: 'voucher.pdf', reservation_id: 9 })
|
||||
const loose = buildTripFile({ id: 2, trip_id: 1, original_name: 'map.pdf' })
|
||||
|
||||
describe('MBookingFilesCosts', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1, currency: 'EUR' }), files: [], budgetItems: [] })
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-001: with upload rights the file row is there even without any file', () => {
|
||||
render(<Harness />)
|
||||
expect(screen.getByText('files.title')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'files.attach' })).toBeInTheDocument()
|
||||
// The booking sheets keep the row terse.
|
||||
expect(screen.queryByText('files.pasteHint')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-002: without upload rights and without files the row stays away', () => {
|
||||
seedStore(useTripStore, { files: [loose] })
|
||||
// Nobody signed in, so nothing can be linked either.
|
||||
render(<Harness canUploadFiles={false} />)
|
||||
expect(screen.queryByText('files.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-003: without upload rights the files already on the booking are still listed', () => {
|
||||
seedStore(useTripStore, { files: [onBooking, loose] })
|
||||
render(<Harness canUploadFiles={false} />)
|
||||
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'files.attach' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-004: without upload rights a trip file can still be linked, which posts the link', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }) })
|
||||
seedStore(useTripStore, { files: [loose] })
|
||||
let body: unknown = null
|
||||
server.use(
|
||||
http.post('/api/trips/1/files/2/link', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [loose] })),
|
||||
)
|
||||
render(<Harness canUploadFiles={false} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.link' }))
|
||||
fireEvent.click(screen.getByText('map.pdf'))
|
||||
await waitFor(() => expect(body).toEqual({ reservation_id: 9 }))
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-005: an unsaved booking offers no file to link', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }) })
|
||||
seedStore(useTripStore, { files: [onBooking, loose] })
|
||||
render(<Harness reservationId={null} />)
|
||||
expect(screen.queryByRole('button', { name: 'files.link' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('voucher.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-006: picked files join the pending list and each X drops its own', () => {
|
||||
render(<Harness initialPending={[new File(['a'], 'a.pdf')]} />)
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [new File(['b'], 'b.pdf'), new File(['c'], 'c.pdf')] } })
|
||||
expect(screen.getAllByText(/^[abc]\.pdf$/).map(n => n.textContent)).toEqual(['a.pdf', 'b.pdf', 'c.pdf'])
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[1])
|
||||
expect(screen.getAllByText(/^[abc]\.pdf$/).map(n => n.textContent)).toEqual(['a.pdf', 'c.pdf'])
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-007: the costs block follows showCosts', () => {
|
||||
const { unmount } = render(<Harness showCosts={false} />)
|
||||
expect(screen.queryByRole('button', { name: /Create expense/ })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
render(<Harness />)
|
||||
expect(screen.getByRole('button', { name: /Create expense/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('Saves the booking, then opens the Costs editor.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-008: create is handed createDisabled and onCreate', () => {
|
||||
const onCreate = vi.fn()
|
||||
const { unmount } = render(<Harness createDisabled onCreate={onCreate} />)
|
||||
expect(screen.getByRole('button', { name: /Create expense/ })).toBeDisabled()
|
||||
unmount()
|
||||
render(<Harness onCreate={onCreate} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create expense/ }))
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-009: the booking\'s expenses are listed and open through onEdit', () => {
|
||||
const fare = buildBudgetItem({ id: 30, trip_id: 1, name: 'Fare', total_price: 60, reservation_id: 9 })
|
||||
seedStore(useTripStore, { budgetItems: [fare, buildBudgetItem({ id: 31, trip_id: 1, name: 'Elsewhere', reservation_id: 10 })] })
|
||||
const onEdit = vi.fn()
|
||||
render(<Harness onEdit={onEdit} />)
|
||||
expect(screen.queryByText('Elsewhere')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Fare'))
|
||||
expect(onEdit).toHaveBeenCalledWith(fare)
|
||||
})
|
||||
|
||||
it('FE-MOB-BKFOOT-010: the costs block links to the same booking as the files', () => {
|
||||
seedStore(useTripStore, { budgetItems: [buildBudgetItem({ id: 32, trip_id: 1, name: 'Loose cost' })] })
|
||||
const { unmount } = render(<Harness reservationId={null} />)
|
||||
// Before the first save there is nothing to link a cost to.
|
||||
expect(screen.queryByRole('button', { name: 'Link' })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
render(<Harness />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Link' }))
|
||||
expect(screen.getByText('Loose cost')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import { formatMoney } from '../../../../src/utils/formatters'
|
||||
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore'
|
||||
import { buildSettings } from '../../../helpers/factories'
|
||||
@@ -11,6 +12,10 @@ import { seedStore } from '../../../helpers/store'
|
||||
|
||||
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-023
|
||||
|
||||
// A card shows a price the way formatMoney writes it; getByText collapses the
|
||||
// no-break space Intl puts between amount and symbol, so the expectation does too.
|
||||
const money = (amount: number, currency: string) => formatMoney(amount, currency, 'en').replace(/\s/g, ' ')
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
||||
openFile: vi.fn(),
|
||||
@@ -191,7 +196,8 @@ describe('MBookingsTab', () => {
|
||||
const card = cardOf('Bamboo walk')
|
||||
expect(within(card).queryByText('reservations.meta.from')).not.toBeInTheDocument()
|
||||
expect(within(card).getByText('reservations.price')).toBeInTheDocument()
|
||||
expect(within(card).getByText('30 JPY')).toBeInTheDocument()
|
||||
// Formatted as money in the booking's own currency.
|
||||
expect(within(card).getByText(money(30, 'JPY'))).toBeInTheDocument()
|
||||
expect(within(card).getAllByText('—')).toHaveLength(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import MLinkedCosts from '../../../../src/mobile/screens/trip/sheets/MLinkedCosts'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { formatMoney } from '../../../../src/utils/formatters'
|
||||
import type { BudgetItem } from '../../../../src/types'
|
||||
import { buildBudgetItem, buildTrip } from '../../../helpers/factories'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-LINKCOST-001 to FE-MOB-LINKCOST-014
|
||||
// MLinkedCosts reads the real translations (it is shared by three sheets), so
|
||||
// the assertions use the English copy rather than echoed keys.
|
||||
|
||||
// getByText collapses the no-break space Intl puts between amount and symbol.
|
||||
const money = (amount: number, currency: string) => formatMoney(amount, currency, 'en').replace(/\s/g, ' ')
|
||||
|
||||
const flight = buildBudgetItem({ id: 11, trip_id: 1, name: 'Flight', total_price: 420, currency: 'EUR', category: 'flights', reservation_id: 9 })
|
||||
const upgrade = buildBudgetItem({ id: 12, trip_id: 1, name: 'Seat upgrade', total_price: 80, currency: 'USD', category: 'fees', reservation_id: 9 })
|
||||
const museum = buildBudgetItem({ id: 13, trip_id: 1, name: 'Museum pass', total_price: 25, category: 'activities', place_id: 4 })
|
||||
const souvenirs = buildBudgetItem({ id: 14, trip_id: 1, name: 'Souvenirs', total_price: 18, currency: 'EUR', category: 'shopping' })
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>
|
||||
|
||||
function setup(props: Partial<React.ComponentProps<typeof MLinkedCosts>> = {}) {
|
||||
const onCreate = vi.fn()
|
||||
const onEdit = vi.fn()
|
||||
const view = render(
|
||||
<MLinkedCosts reservationId={9} hintKey="reservations.createExpenseHint" createDisabled={false} onCreate={onCreate} onEdit={onEdit} {...props} />,
|
||||
)
|
||||
return { ...view, onCreate, onEdit }
|
||||
}
|
||||
|
||||
/** Records every expense update and answers with the merged item. */
|
||||
function captureUpdates() {
|
||||
const bodies: { id: number; body: Record<string, unknown> }[] = []
|
||||
server.use(
|
||||
http.put('/api/trips/1/budget/:itemId', async ({ params, request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
bodies.push({ id: Number(params.itemId), body })
|
||||
const current = useTripStore.getState().budgetItems.find(i => i.id === Number(params.itemId))
|
||||
return HttpResponse.json({ item: { ...current, ...body } })
|
||||
}),
|
||||
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
|
||||
)
|
||||
return bodies
|
||||
}
|
||||
|
||||
const linkPill = () => screen.getByRole('button', { name: 'Link' })
|
||||
const rowOf = (name: string) => screen.getByText(name).closest('button')!.parentElement as HTMLElement
|
||||
|
||||
describe('MLinkedCosts', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }), budgetItems: [flight, upgrade, museum, souvenirs] })
|
||||
addToast = vi.fn()
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-001: an unsaved record offers only "create expense", with the hint', () => {
|
||||
seedStore(useTripStore, { budgetItems: [souvenirs] })
|
||||
setup({ reservationId: null })
|
||||
expect(screen.getByText('Costs')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Create expense/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Link' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Saves the booking, then opens the Costs editor.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-002: create follows createDisabled and calls onCreate', () => {
|
||||
const { onCreate, rerender } = setup({ createDisabled: true })
|
||||
const create = screen.getByRole('button', { name: /Create expense/ })
|
||||
expect(create).toBeDisabled()
|
||||
rerender(<MLinkedCosts reservationId={9} hintKey="reservations.createExpenseHint" createDisabled={false} onCreate={onCreate} onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create expense/ }))
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-003: the linked expenses are listed with category and amount, and the hint goes', () => {
|
||||
setup()
|
||||
expect(screen.getByText('Linked expenses')).toBeInTheDocument()
|
||||
expect(within(rowOf('Flight')).getByText('Flights')).toBeInTheDocument()
|
||||
expect(within(rowOf('Flight')).getByText(money(420, 'EUR'))).toBeInTheDocument()
|
||||
expect(within(rowOf('Seat upgrade')).getByText(money(80, 'USD'))).toBeInTheDocument()
|
||||
expect(screen.queryByText('Museum pass')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Saves the booking, then opens the Costs editor.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-004: tapping a linked row opens it for editing', () => {
|
||||
const { onEdit } = setup()
|
||||
fireEvent.click(screen.getByText('Seat upgrade'))
|
||||
expect(onEdit).toHaveBeenCalledWith(upgrade)
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-005: unlink clears the link and keeps the expense', async () => {
|
||||
const bodies = captureUpdates()
|
||||
setup()
|
||||
fireEvent.click(within(rowOf('Seat upgrade')).getByRole('button', { name: 'Unlink, keep the expense' }))
|
||||
await waitFor(() => expect(bodies).toEqual([{ id: 12, body: { reservation_id: null } }]))
|
||||
await waitFor(() => expect(screen.queryByText('Seat upgrade')).not.toBeInTheDocument())
|
||||
expect(useTripStore.getState().budgetItems.some((i: BudgetItem) => i.id === 12)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-006: delete removes the expense through the store', async () => {
|
||||
let deleted: string | null = null
|
||||
server.use(http.delete('/api/trips/1/budget/:itemId', ({ params }) => {
|
||||
deleted = String(params.itemId)
|
||||
return HttpResponse.json({ success: true })
|
||||
}))
|
||||
setup()
|
||||
fireEvent.click(within(rowOf('Flight')).getByRole('button', { name: 'Remove expense' }))
|
||||
await waitFor(() => expect(deleted).toBe('11'))
|
||||
expect(screen.queryByText('Flight')).not.toBeInTheDocument()
|
||||
expect(useTripStore.getState().budgetItems.some((i: BudgetItem) => i.id === 11)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-007: a failed delete is reported and the expense comes back', async () => {
|
||||
server.use(http.delete('/api/trips/1/budget/11', () => HttpResponse.json({ error: 'nope' }, { status: 500 })))
|
||||
setup()
|
||||
fireEvent.click(within(rowOf('Flight')).getByRole('button', { name: 'Remove expense' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
expect(screen.getByText('Flight')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-008: without a trip, delete does nothing', () => {
|
||||
seedStore(useTripStore, { trip: null })
|
||||
setup()
|
||||
fireEvent.click(within(rowOf('Flight')).getByRole('button', { name: 'Remove expense' }))
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-009: the link pill folds a list of the unlinked expenses in and out', () => {
|
||||
setup()
|
||||
expect(linkPill()).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(linkPill())
|
||||
expect(linkPill()).toHaveAttribute('aria-expanded', 'true')
|
||||
const option = screen.getByText('Souvenirs').closest('button') as HTMLElement
|
||||
expect(option).toHaveTextContent(money(18, 'EUR'))
|
||||
// Only expenses without any link are offered; a short list has no search field.
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Museum pass')).not.toBeInTheDocument()
|
||||
fireEvent.click(linkPill())
|
||||
expect(screen.queryByText('Souvenirs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-010: tapping an offered expense links it and folds the list away', async () => {
|
||||
const bodies = captureUpdates()
|
||||
setup()
|
||||
fireEvent.click(linkPill())
|
||||
fireEvent.click(screen.getByText('Souvenirs'))
|
||||
await waitFor(() => expect(bodies).toEqual([{ id: 14, body: { reservation_id: 9 } }]))
|
||||
await waitFor(() => expect(linkPill()).toHaveAttribute('aria-expanded', 'false'))
|
||||
// Now it is one of the linked rows.
|
||||
expect(within(rowOf('Souvenirs')).getByRole('button', { name: 'Unlink, keep the expense' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-011: with nothing left to link the list says so', () => {
|
||||
seedStore(useTripStore, { budgetItems: [flight, museum] })
|
||||
setup()
|
||||
fireEvent.click(linkPill())
|
||||
expect(screen.getByText('No unlinked expenses')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-012: from six candidates on the list can be searched', () => {
|
||||
const many = ['Snacks', 'Taxi', 'Tips', 'Water', 'Museum shop', 'Postcards'].map((name, i) =>
|
||||
buildBudgetItem({ id: 40 + i, trip_id: 1, name, total_price: 5 }))
|
||||
seedStore(useTripStore, { budgetItems: many })
|
||||
setup()
|
||||
fireEvent.click(linkPill())
|
||||
const search = screen.getByPlaceholderText('Search')
|
||||
fireEvent.change(search, { target: { value: ' TA ' } })
|
||||
expect(screen.getByText('Taxi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Postcards')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Snacks')).not.toBeInTheDocument()
|
||||
fireEvent.change(search, { target: { value: 'zeppelin' } })
|
||||
expect(screen.getByText('No unlinked expenses')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-013: a linked pick clears the search for the next time', async () => {
|
||||
const many = ['Snacks', 'Taxi', 'Tips', 'Water', 'Museum shop', 'Postcards'].map((name, i) =>
|
||||
buildBudgetItem({ id: 50 + i, trip_id: 1, name, total_price: 5 }))
|
||||
seedStore(useTripStore, { budgetItems: many })
|
||||
captureUpdates()
|
||||
setup({ reservationId: null, placeId: 4, hintKey: 'places.createExpenseHint' })
|
||||
fireEvent.click(linkPill())
|
||||
fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'tax' } })
|
||||
fireEvent.click(screen.getByText('Taxi'))
|
||||
await waitFor(() => expect(linkPill()).toHaveAttribute('aria-expanded', 'false'))
|
||||
fireEvent.click(linkPill())
|
||||
// Five candidates left, so the search field is gone and every one is listed again.
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Snacks')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-LINKCOST-014: on a place it shows and links through place_id, with the place hint', async () => {
|
||||
const bodies = captureUpdates()
|
||||
setup({ reservationId: null, placeId: 4, hintKey: 'places.createExpenseHint' })
|
||||
expect(screen.getByText('Museum pass')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Flight')).not.toBeInTheDocument()
|
||||
fireEvent.click(linkPill())
|
||||
fireEvent.click(screen.getByText('Souvenirs'))
|
||||
await waitFor(() => expect(bodies).toEqual([{ id: 14, body: { place_id: 4 } }]))
|
||||
|
||||
seedStore(useTripStore, { budgetItems: [souvenirs] })
|
||||
expect(await screen.findByText('Saves the place, then opens the Costs editor.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import { server } from '../../../helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-043, plus the 009b, 025b and 029b variants
|
||||
// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-045, plus the 009b, 025b and 029b variants
|
||||
// planner.t echoes the key, so every label/placeholder is asserted as its key.
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -435,12 +435,12 @@ describe('MPlaceEditSheet', () => {
|
||||
|
||||
it('FE-MOB-PLEDIT-033: the button only appears while the Budget addon is on', () => {
|
||||
const { unmount } = setup()
|
||||
expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Create expense' })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
withBudget()
|
||||
setup()
|
||||
expect(screen.getByRole('button', { name: 'reservations.createExpense' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Create expense' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-034: creating an expense saves the place first, then opens the editor', async () => {
|
||||
@@ -449,7 +449,7 @@ describe('MPlaceEditSheet', () => {
|
||||
const { onOpenExpense } = setup({ handleSavePlace })
|
||||
|
||||
fireEvent.change(nameField(), { target: { value: 'Louvre' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create expense' }))
|
||||
|
||||
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled())
|
||||
expect(handleSavePlace.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0])
|
||||
@@ -464,11 +464,30 @@ describe('MPlaceEditSheet', () => {
|
||||
const { onOpenExpense } = setup({ handleSavePlace })
|
||||
|
||||
fireEvent.change(nameField(), { target: { value: 'Louvre' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create expense' }))
|
||||
|
||||
await waitFor(() => expect(handleSavePlace).toHaveBeenCalled())
|
||||
expect(onOpenExpense).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-044: an edited place lists its expenses and opens one for editing (#2084)', () => {
|
||||
withBudget()
|
||||
const tickets = { id: 70, trip_id: 1, name: 'Temple tickets', total_price: 5, category: 'activities', place_id: 42 }
|
||||
seedStore(useTripStore, { trip: { id: 1, currency: 'JPY' }, budgetItems: [tickets] })
|
||||
const { onOpenExpense } = setup({ editingPlace: EDITED })
|
||||
expect(screen.getByText('Linked expenses')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Temple tickets'))
|
||||
expect(onOpenExpense).toHaveBeenCalledWith({ editItem: tickets })
|
||||
// The place hint only shows while nothing is linked.
|
||||
expect(screen.queryByText('Saves the place, then opens the Costs editor.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-045: a new place shows the place hint and nothing to link to yet', () => {
|
||||
withBudget()
|
||||
setup()
|
||||
expect(screen.getByText('Saves the place, then opens the Costs editor.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Link' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── A visit the day lists hide (the road trip stop sheet opens the editor on them) ──
|
||||
|
||||
@@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MReservationSheet from '../../../../src/mobile/screens/trip/sheets/MReservationSheet'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { buildBudgetItem, buildUser } from '../../../helpers/factories'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import { isBlurred } from '../../../helpers/bookingCodeBlur'
|
||||
import type { Accommodation, Assignment, Day, Place, Reservation, TripMember } from '../../../../src/types'
|
||||
@@ -9,7 +13,7 @@ import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-RESSH-001 to FE-MOB-RESSH-063
|
||||
// FE-MOB-RESSH-001 to FE-MOB-RESSH-068
|
||||
|
||||
// Date/time/select pickers have their own suites — here they only have to be
|
||||
// addressable, so they render as plain controls.
|
||||
@@ -264,13 +268,74 @@ describe('MReservationSheet', () => {
|
||||
{ id: 5, trip_id: 5, reservation_id: 99, original_name: 'other.pdf', url: '/uploads/other.pdf', deleted_at: null },
|
||||
{ id: 6, trip_id: 5, linked_reservation_ids: [71], original_name: 'linked.pdf', url: '/uploads/linked.pdf', deleted_at: null },
|
||||
]
|
||||
setup(makePlanner({ editingReservation: linked, files }))
|
||||
// The sheet reads the trip's files from the store (useReservationFiles), like the desktop dialog.
|
||||
useTripStore.setState({ files: files as never })
|
||||
setup(makePlanner({ editingReservation: linked }))
|
||||
|
||||
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('linked.pdf')).toBeInTheDocument()
|
||||
expect(screen.queryByText('other.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-064: a saved booking can take a file the trip already has (#2084)', async () => {
|
||||
const linked = { ...DINNER, id: 72 } as unknown as Reservation
|
||||
const files = [
|
||||
{ id: 8, trip_id: 5, reservation_id: 72, original_name: 'menu.pdf', url: '/uploads/menu.pdf', deleted_at: null },
|
||||
{ id: 9, trip_id: 5, original_name: 'city-map.pdf', url: '/uploads/city-map.pdf', deleted_at: null },
|
||||
]
|
||||
useAuthStore.setState({ user: buildUser({ id: 1 }) })
|
||||
useTripStore.setState({ trip: { id: 5, user_id: 1 } as never, files: files as never })
|
||||
let body: unknown = null
|
||||
server.use(
|
||||
http.post('/api/trips/5/files/9/link', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
http.get('/api/trips/5/files', () => HttpResponse.json({ files })),
|
||||
)
|
||||
setup(makePlanner({ editingReservation: linked }))
|
||||
|
||||
// Only the other file is offered; the menu is already on the booking.
|
||||
expect(screen.queryByText('city-map.pdf')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.link' }))
|
||||
fireEvent.click(screen.getByText('city-map.pdf'))
|
||||
await waitFor(() => expect(body).toEqual({ reservation_id: 72 }))
|
||||
// A successful link folds the list away.
|
||||
await waitFor(() => expect(screen.queryByText('city-map.pdf')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-065: without upload permission a booking still lists its files, just without the picker', () => {
|
||||
const linked = { ...DINNER, id: 73 } as unknown as Reservation
|
||||
useTripStore.setState({
|
||||
files: [{ id: 10, trip_id: 5, reservation_id: 73, original_name: 'pass.pdf', url: '/uploads/pass.pdf', deleted_at: null }] as never,
|
||||
})
|
||||
setup(makePlanner({ editingReservation: linked, canUploadFiles: false }))
|
||||
expect(screen.getByText('pass.pdf')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'files.attach' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-066: without upload permission and without files the file block stays away', () => {
|
||||
setup(makePlanner({ editingReservation: DINNER, canUploadFiles: false }))
|
||||
expect(screen.queryByText('files.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-067: the booking lists its linked expenses and opens one for editing', () => {
|
||||
const deposit = buildBudgetItem({ id: 80, trip_id: 5, name: 'Deposit', total_price: 50, reservation_id: 55 })
|
||||
useTripStore.setState({ trip: { id: 5, currency: 'EUR' } as never, budgetItems: [deposit] })
|
||||
const { onOpenExpense } = setup(makePlanner({ editingReservation: DINNER }))
|
||||
expect(screen.getByText('Linked expenses')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Deposit'))
|
||||
expect(onOpenExpense).toHaveBeenCalledWith({ editItem: deposit })
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-068: a saved booking offers the unlinked expenses of the trip', () => {
|
||||
const loose = buildBudgetItem({ id: 81, trip_id: 5, name: 'Tram tickets', total_price: 9 })
|
||||
useTripStore.setState({ trip: { id: 5, currency: 'EUR' } as never, budgetItems: [loose] })
|
||||
setup(makePlanner({ editingReservation: DINNER }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Link' }))
|
||||
expect(screen.getByText('Tram tickets')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-007: the type chips switch the form between event and hotel layout', () => {
|
||||
setup()
|
||||
expect(screen.getByLabelText('reservations.meta.pickPlace')).toBeInTheDocument()
|
||||
@@ -398,7 +463,7 @@ describe('MReservationSheet', () => {
|
||||
type(dates()[0], '2026-05-02')
|
||||
type(times()[0], '19:30')
|
||||
type(dates()[1], '2026-05-01')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create expense' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('reservations.validation.endBeforeStart'))
|
||||
expect(planner.handleSaveReservation).not.toHaveBeenCalled()
|
||||
@@ -677,7 +742,7 @@ describe('MReservationSheet', () => {
|
||||
const { planner, onOpenExpense } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.restaurant' }))
|
||||
type(titleField(), 'Dinner')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create expense' }))
|
||||
|
||||
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled())
|
||||
expect(planner.handleSaveReservation).toHaveBeenCalledTimes(1)
|
||||
@@ -697,7 +762,7 @@ describe('MReservationSheet', () => {
|
||||
it('FE-MOB-RESSH-041: without the budget addon there is no costs section', () => {
|
||||
useAddonStore.setState({ addons: [], loaded: true })
|
||||
setup()
|
||||
expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Create expense' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-042: a failing save surfaces the error message and unlocks the form', async () => {
|
||||
|
||||
@@ -3,14 +3,18 @@ import MTransportFormSheet from '../../../../src/mobile/screens/trip/sheets/MTra
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { buildBudgetItem, buildUser } from '../../../helpers/factories'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import { isBlurred } from '../../../helpers/bookingCodeBlur'
|
||||
import type { Day, Reservation, TripMember } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-052
|
||||
// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-057
|
||||
//
|
||||
// The sheet's own pickers (airport/location search, day select, time picker) and
|
||||
// the embedded transit panel are replaced by minimal controlled stand-ins so the
|
||||
@@ -94,12 +98,24 @@ vi.mock('../../../../src/components/Planner/TransitSearchPanel', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/PlFileAttach', () => ({
|
||||
default: ({ files, onAdd, onRemove }: { files: File[]; onAdd: (f: File[]) => void; onRemove: (i: number) => void }) => (
|
||||
default: ({ files, onAdd, onRemove, canAttach = true, attached = [], linkable = [], onLink }: {
|
||||
files: File[]
|
||||
onAdd: (f: File[]) => void
|
||||
onRemove: (i: number) => void
|
||||
canAttach?: boolean
|
||||
attached?: { id: number; original_name: string }[]
|
||||
linkable?: { id: number; original_name: string }[]
|
||||
onLink?: (f: { id: number; original_name: string }) => Promise<boolean>
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onAdd([new File(['x'], 'extra.pdf')])}>attach-file</button>
|
||||
{canAttach && <button type="button" onClick={() => onAdd([new File(['x'], 'extra.pdf')])}>attach-file</button>}
|
||||
{files.map((f, i) => (
|
||||
<button key={`${f.name}-${i}`} type="button" onClick={() => onRemove(i)}>{`drop-${f.name}`}</button>
|
||||
))}
|
||||
{attached.map(f => <span key={f.id}>{`attached-${f.original_name}`}</span>)}
|
||||
{linkable.map(f => (
|
||||
<button key={f.id} type="button" onClick={() => { void onLink?.(f) }}>{`link-${f.original_name}`}</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@@ -834,7 +850,7 @@ describe('MTransportFormSheet', () => {
|
||||
handleSaveTransport,
|
||||
})
|
||||
renderSheet(planner)
|
||||
expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Create expense' })).not.toBeInTheDocument()
|
||||
await submit()
|
||||
expect(handleSaveTransport.mock.calls[0][0]).not.toHaveProperty('create_budget_entry')
|
||||
})
|
||||
@@ -898,7 +914,7 @@ describe('MTransportFormSheet', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.ferry' }))
|
||||
typeTitle('Ferry ticket')
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create expense' }))
|
||||
})
|
||||
expect(handleSaveTransport).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenExpense).toHaveBeenCalledWith({
|
||||
@@ -1037,4 +1053,56 @@ describe('MTransportFormSheet', () => {
|
||||
expect(fields).toHaveLength(2)
|
||||
for (const field of fields) expect(field).toHaveAttribute('data-picks', 'Fushimi Inari')
|
||||
})
|
||||
|
||||
// ── Files and costs of a saved transport (#2084) ───────────────────────────
|
||||
|
||||
const SHINKANSEN = {
|
||||
id: 61, trip_id: 1, type: 'train', title: 'Shinkansen', status: 'confirmed', day_id: 12, metadata: {}, endpoints: [],
|
||||
} as unknown as Reservation
|
||||
const TRIP_FILES = [
|
||||
{ id: 7, trip_id: 1, reservation_id: 61, original_name: 'eticket.pdf', url: '/uploads/eticket.pdf', deleted_at: null },
|
||||
{ id: 8, trip_id: 1, original_name: 'railpass.pdf', url: '/uploads/railpass.pdf', deleted_at: null },
|
||||
]
|
||||
|
||||
it('FE-MOB-TRFRM-054: an edited transport lists its files and links another trip file to itself', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }) })
|
||||
seedStore(useTripStore, { trip: { id: 1, user_id: 1, currency: 'EUR' } as never, files: TRIP_FILES as never })
|
||||
let body: unknown = null
|
||||
server.use(
|
||||
http.post('/api/trips/1/files/8/link', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: TRIP_FILES })),
|
||||
)
|
||||
renderSheet(makePlanner({ editingTransport: SHINKANSEN }))
|
||||
expect(screen.getByText('attached-eticket.pdf')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'link-railpass.pdf' }))
|
||||
await waitFor(() => expect(body).toEqual({ reservation_id: 61 }))
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-055: without upload permission the files of a transport are still shown, without the picker', () => {
|
||||
seedStore(useTripStore, { files: TRIP_FILES as never })
|
||||
renderSheet(makePlanner({ editingTransport: SHINKANSEN, canUploadFiles: false }))
|
||||
expect(screen.getByText('attached-eticket.pdf')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'attach-file' })).not.toBeInTheDocument()
|
||||
// Nobody is signed in here, so linking is not offered either.
|
||||
expect(screen.queryByRole('button', { name: 'link-railpass.pdf' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-056: a linked expense of the transport opens in the cost sheet for editing', () => {
|
||||
const fare = buildBudgetItem({ id: 90, trip_id: 1, name: 'Train fare', total_price: 140, reservation_id: 61 })
|
||||
seedStore(useTripStore, { trip: { id: 1, currency: 'EUR' } as never, budgetItems: [fare] })
|
||||
const onOpenExpense = vi.fn()
|
||||
renderSheet(makePlanner({ editingTransport: SHINKANSEN }), onOpenExpense)
|
||||
fireEvent.click(screen.getByText('Train fare'))
|
||||
expect(onOpenExpense).toHaveBeenCalledWith({ editItem: fare })
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-057: a new transport has nothing to link its costs to yet', () => {
|
||||
seedStore(useTripStore, { trip: { id: 1, currency: 'EUR' } as never, budgetItems: [buildBudgetItem({ id: 91, trip_id: 1, name: 'Loose' })] })
|
||||
renderSheet(makePlanner())
|
||||
expect(screen.getByRole('button', { name: 'Create expense' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Link' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransportsTab'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import { formatMoney } from '../../../../src/utils/formatters'
|
||||
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore'
|
||||
import { buildSettings } from '../../../helpers/factories'
|
||||
@@ -11,6 +12,10 @@ import { seedStore } from '../../../helpers/store'
|
||||
|
||||
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-028
|
||||
|
||||
// A card shows a price the way formatMoney writes it; getByText collapses the
|
||||
// no-break space Intl puts between amount and symbol, so the expectation does too.
|
||||
const money = (amount: number, currency: string) => formatMoney(amount, currency, 'en').replace(/\s/g, ' ')
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
||||
openFile: vi.fn(),
|
||||
@@ -192,11 +197,11 @@ describe('MTransportsTab', () => {
|
||||
expect(within(card).getByText('reservations.needsReview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-011: appends the currency to the price cell', () => {
|
||||
it('FE-MOB-TRTAB-011: formats the price cell as money in its own currency', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Airport shuttle')
|
||||
expect(within(card).getByText('reservations.price')).toBeInTheDocument()
|
||||
expect(within(card).getByText('25 CHF')).toBeInTheDocument()
|
||||
expect(within(card).getByText(money(25, 'CHF'))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-012: uses the airport meta cells when no endpoints are stored', () => {
|
||||
@@ -327,9 +332,9 @@ describe('MTransportsTab', () => {
|
||||
}))
|
||||
const card = cardOf('Heli transfer')
|
||||
expect(within(card).getByText('reservations.type.helicopter')).toBeInTheDocument()
|
||||
// seat without a class and a price without a currency stay bare
|
||||
// a seat without a class stays bare; a price without a currency is in the trip's (EUR)
|
||||
expect(within(card).getByText('3B')).toBeInTheDocument()
|
||||
expect(within(card).getByText('99')).toBeInTheDocument()
|
||||
expect(within(card).getByText(money(99, 'EUR'))).toBeInTheDocument()
|
||||
// nobody is assigned, so the traveler filter row stays away
|
||||
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||