diff --git a/client/e2e/help/trip-bookings.guide.ts b/client/e2e/help/trip-bookings.guide.ts index c034c4ed6..f82053b4b 100644 --- a/client/e2e/help/trip-bookings.guide.ts +++ b/client/e2e/help/trip-bookings.guide.ts @@ -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 { + 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 { await block(page, field).getByRole('button').first().click() @@ -149,12 +159,14 @@ const SCRIPTS: Record = { }, }, { - 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 = { 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 = { 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 = { 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) }, }, diff --git a/client/e2e/help/trip-costs.guide.ts b/client/e2e/help/trip-costs.guide.ts index ed22a466a..7e523b5bd 100644 --- a/client/e2e/help/trip-costs.guide.ts +++ b/client/e2e/help/trip-costs.guide.ts @@ -532,7 +532,9 @@ const SCRIPTS: Record = { 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) }, }, diff --git a/client/e2e/help/trip-transports.guide.ts b/client/e2e/help/trip-transports.guide.ts index 8c3994a97..7bb13275c 100644 --- a/client/e2e/help/trip-transports.guide.ts +++ b/client/e2e/help/trip-transports.guide.ts @@ -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 = { }, }, { - 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) }, diff --git a/client/public/help-media/add-transport/result.webp b/client/public/help-media/add-transport/result.webp index 86a3ef206..6779fa3f8 100644 Binary files a/client/public/help-media/add-transport/result.webp and b/client/public/help-media/add-transport/result.webp differ diff --git a/client/public/help-media/add-transport/step-1.webp b/client/public/help-media/add-transport/step-1.webp index a55bf856e..758a1be54 100644 Binary files a/client/public/help-media/add-transport/step-1.webp and b/client/public/help-media/add-transport/step-1.webp differ diff --git a/client/public/help-media/add-transport/step-2.webp b/client/public/help-media/add-transport/step-2.webp index 208b83a6c..377c4baef 100644 Binary files a/client/public/help-media/add-transport/step-2.webp and b/client/public/help-media/add-transport/step-2.webp differ diff --git a/client/public/help-media/add-transport/step-3.webp b/client/public/help-media/add-transport/step-3.webp index e8ddb31c0..278c1f1fb 100644 Binary files a/client/public/help-media/add-transport/step-3.webp and b/client/public/help-media/add-transport/step-3.webp differ diff --git a/client/public/help-media/add-transport/step-4.webp b/client/public/help-media/add-transport/step-4.webp index bffa78beb..25a9b860c 100644 Binary files a/client/public/help-media/add-transport/step-4.webp and b/client/public/help-media/add-transport/step-4.webp differ diff --git a/client/public/help-media/add-transport/step-5.webp b/client/public/help-media/add-transport/step-5.webp index 225998f5f..d0a36580c 100644 Binary files a/client/public/help-media/add-transport/step-5.webp and b/client/public/help-media/add-transport/step-5.webp differ diff --git a/client/public/help-media/add-transport/step-6.webp b/client/public/help-media/add-transport/step-6.webp index eaa14f6b3..99c73e0db 100644 Binary files a/client/public/help-media/add-transport/step-6.webp and b/client/public/help-media/add-transport/step-6.webp differ diff --git a/client/public/help-media/booking-cost/result.webp b/client/public/help-media/booking-cost/result.webp index 882e7ea2b..5455b201c 100644 Binary files a/client/public/help-media/booking-cost/result.webp and b/client/public/help-media/booking-cost/result.webp differ diff --git a/client/public/help-media/booking-cost/step-1.webp b/client/public/help-media/booking-cost/step-1.webp index 02ee0275a..2dac00fae 100644 Binary files a/client/public/help-media/booking-cost/step-1.webp and b/client/public/help-media/booking-cost/step-1.webp differ diff --git a/client/public/help-media/booking-cost/step-2.webp b/client/public/help-media/booking-cost/step-2.webp index 97b5965a1..f7abcaafb 100644 Binary files a/client/public/help-media/booking-cost/step-2.webp and b/client/public/help-media/booking-cost/step-2.webp differ diff --git a/client/public/help-media/booking-files/step-1.webp b/client/public/help-media/booking-files/step-1.webp index f9d4ba08d..5316aaf24 100644 Binary files a/client/public/help-media/booking-files/step-1.webp and b/client/public/help-media/booking-files/step-1.webp differ diff --git a/client/public/help-media/booking-files/step-2.webp b/client/public/help-media/booking-files/step-2.webp index 92e80f387..cc64e4f81 100644 Binary files a/client/public/help-media/booking-files/step-2.webp and b/client/public/help-media/booking-files/step-2.webp differ diff --git a/client/public/help-media/booking-files/step-3.webp b/client/public/help-media/booking-files/step-3.webp index f3006f5c2..1b77864a7 100644 Binary files a/client/public/help-media/booking-files/step-3.webp and b/client/public/help-media/booking-files/step-3.webp differ diff --git a/client/public/help-media/booking-files/step-4.webp b/client/public/help-media/booking-files/step-4.webp index d03a58b5d..f145cbda7 100644 Binary files a/client/public/help-media/booking-files/step-4.webp and b/client/public/help-media/booking-files/step-4.webp differ diff --git a/client/public/help-media/booking-hotel/step-1.webp b/client/public/help-media/booking-hotel/step-1.webp index bf2d32707..38813087e 100644 Binary files a/client/public/help-media/booking-hotel/step-1.webp and b/client/public/help-media/booking-hotel/step-1.webp differ diff --git a/client/public/help-media/booking-hotel/step-2.webp b/client/public/help-media/booking-hotel/step-2.webp index d037c065a..1633a928d 100644 Binary files a/client/public/help-media/booking-hotel/step-2.webp and b/client/public/help-media/booking-hotel/step-2.webp differ diff --git a/client/public/help-media/booking-hotel/step-3.webp b/client/public/help-media/booking-hotel/step-3.webp index 2308a8a61..c42358cb8 100644 Binary files a/client/public/help-media/booking-hotel/step-3.webp and b/client/public/help-media/booking-hotel/step-3.webp differ diff --git a/client/public/help-media/booking-hotel/step-4.webp b/client/public/help-media/booking-hotel/step-4.webp index 6808cf03b..5bac55919 100644 Binary files a/client/public/help-media/booking-hotel/step-4.webp and b/client/public/help-media/booking-hotel/step-4.webp differ diff --git a/client/public/help-media/booking-hotel/step-5.webp b/client/public/help-media/booking-hotel/step-5.webp index f7dcb8e47..6364b64f2 100644 Binary files a/client/public/help-media/booking-hotel/step-5.webp and b/client/public/help-media/booking-hotel/step-5.webp differ diff --git a/client/public/help-media/booking-travelers/result.webp b/client/public/help-media/booking-travelers/result.webp index 7589d8071..56983c63d 100644 Binary files a/client/public/help-media/booking-travelers/result.webp and b/client/public/help-media/booking-travelers/result.webp differ diff --git a/client/public/help-media/booking-travelers/step-1.webp b/client/public/help-media/booking-travelers/step-1.webp index afb09b36b..7d0be98f3 100644 Binary files a/client/public/help-media/booking-travelers/step-1.webp and b/client/public/help-media/booking-travelers/step-1.webp differ diff --git a/client/public/help-media/booking-travelers/step-2.webp b/client/public/help-media/booking-travelers/step-2.webp index 914cc43cf..099c4cebf 100644 Binary files a/client/public/help-media/booking-travelers/step-2.webp and b/client/public/help-media/booking-travelers/step-2.webp differ diff --git a/client/public/help-media/booking-travelers/step-3.webp b/client/public/help-media/booking-travelers/step-3.webp index a85821807..467b64fc8 100644 Binary files a/client/public/help-media/booking-travelers/step-3.webp and b/client/public/help-media/booking-travelers/step-3.webp differ diff --git a/client/public/help-media/create-booking/step-2.webp b/client/public/help-media/create-booking/step-2.webp index 43194f430..6579d2bb6 100644 Binary files a/client/public/help-media/create-booking/step-2.webp and b/client/public/help-media/create-booking/step-2.webp differ diff --git a/client/public/help-media/create-booking/step-3.webp b/client/public/help-media/create-booking/step-3.webp index 531b92d7b..05db0d358 100644 Binary files a/client/public/help-media/create-booking/step-3.webp and b/client/public/help-media/create-booking/step-3.webp differ diff --git a/client/public/help-media/create-booking/step-4.webp b/client/public/help-media/create-booking/step-4.webp index 7699b6602..45395f512 100644 Binary files a/client/public/help-media/create-booking/step-4.webp and b/client/public/help-media/create-booking/step-4.webp differ diff --git a/client/public/help-media/create-booking/step-5.webp b/client/public/help-media/create-booking/step-5.webp index 4eb7d4148..d7f9a5346 100644 Binary files a/client/public/help-media/create-booking/step-5.webp and b/client/public/help-media/create-booking/step-5.webp differ diff --git a/client/public/help-media/create-booking/step-6.webp b/client/public/help-media/create-booking/step-6.webp index 228533f80..a562b6a4f 100644 Binary files a/client/public/help-media/create-booking/step-6.webp and b/client/public/help-media/create-booking/step-6.webp differ diff --git a/client/public/help-media/delete-booking/step-2.webp b/client/public/help-media/delete-booking/step-2.webp index 619098710..33ba69fd5 100644 Binary files a/client/public/help-media/delete-booking/step-2.webp and b/client/public/help-media/delete-booking/step-2.webp differ diff --git a/client/public/help-media/edit-booking/step-2.webp b/client/public/help-media/edit-booking/step-2.webp index 407fa55b5..8674439ac 100644 Binary files a/client/public/help-media/edit-booking/step-2.webp and b/client/public/help-media/edit-booking/step-2.webp differ diff --git a/client/public/help-media/edit-booking/step-3.webp b/client/public/help-media/edit-booking/step-3.webp index dbd190388..6559df8d2 100644 Binary files a/client/public/help-media/edit-booking/step-3.webp and b/client/public/help-media/edit-booking/step-3.webp differ diff --git a/client/public/help-media/edit-booking/step-4.webp b/client/public/help-media/edit-booking/step-4.webp index 9ad758783..a107b1fb7 100644 Binary files a/client/public/help-media/edit-booking/step-4.webp and b/client/public/help-media/edit-booking/step-4.webp differ diff --git a/client/public/help-media/edit-transport/step-1.webp b/client/public/help-media/edit-transport/step-1.webp index 1b5606111..e6fdc18d3 100644 Binary files a/client/public/help-media/edit-transport/step-1.webp and b/client/public/help-media/edit-transport/step-1.webp differ diff --git a/client/public/help-media/edit-transport/step-2.webp b/client/public/help-media/edit-transport/step-2.webp index a4dbd34cf..887d61937 100644 Binary files a/client/public/help-media/edit-transport/step-2.webp and b/client/public/help-media/edit-transport/step-2.webp differ diff --git a/client/public/help-media/edit-transport/step-3.webp b/client/public/help-media/edit-transport/step-3.webp index 92b08bbbe..55cf55ede 100644 Binary files a/client/public/help-media/edit-transport/step-3.webp and b/client/public/help-media/edit-transport/step-3.webp differ diff --git a/client/public/help-media/edit-transport/step-4.webp b/client/public/help-media/edit-transport/step-4.webp index 56fe2022e..d901a2ace 100644 Binary files a/client/public/help-media/edit-transport/step-4.webp and b/client/public/help-media/edit-transport/step-4.webp differ diff --git a/client/public/help-media/expense-from-booking/result.webp b/client/public/help-media/expense-from-booking/result.webp index 0648f4c50..47a7d2325 100644 Binary files a/client/public/help-media/expense-from-booking/result.webp and b/client/public/help-media/expense-from-booking/result.webp differ diff --git a/client/public/help-media/expense-from-booking/step-1.webp b/client/public/help-media/expense-from-booking/step-1.webp index a588ae045..679675cc4 100644 Binary files a/client/public/help-media/expense-from-booking/step-1.webp and b/client/public/help-media/expense-from-booking/step-1.webp differ diff --git a/client/public/help-media/expense-from-booking/step-2.webp b/client/public/help-media/expense-from-booking/step-2.webp index cd7299ce9..7e334cb1a 100644 Binary files a/client/public/help-media/expense-from-booking/step-2.webp and b/client/public/help-media/expense-from-booking/step-2.webp differ diff --git a/client/public/help-media/expense-from-booking/step-3.webp b/client/public/help-media/expense-from-booking/step-3.webp index ea4d7a151..1278b0eb9 100644 Binary files a/client/public/help-media/expense-from-booking/step-3.webp and b/client/public/help-media/expense-from-booking/step-3.webp differ diff --git a/client/public/help-media/import-booking-file/step-4.webp b/client/public/help-media/import-booking-file/step-4.webp index c21ce2a79..6ecfa3aca 100644 Binary files a/client/public/help-media/import-booking-file/step-4.webp and b/client/public/help-media/import-booking-file/step-4.webp differ diff --git a/client/public/help-media/import-booking-file/step-5.webp b/client/public/help-media/import-booking-file/step-5.webp index 27ec9fb15..adbf04590 100644 Binary files a/client/public/help-media/import-booking-file/step-5.webp and b/client/public/help-media/import-booking-file/step-5.webp differ diff --git a/client/public/help-media/import-transport-file/step-4.webp b/client/public/help-media/import-transport-file/step-4.webp index 657cc7df9..ba319109a 100644 Binary files a/client/public/help-media/import-transport-file/step-4.webp and b/client/public/help-media/import-transport-file/step-4.webp differ diff --git a/client/public/help-media/import-transport-file/step-5.webp b/client/public/help-media/import-transport-file/step-5.webp index 257614abe..c46a0d7eb 100644 Binary files a/client/public/help-media/import-transport-file/step-5.webp and b/client/public/help-media/import-transport-file/step-5.webp differ diff --git a/client/public/help-media/link-booking/step-2.webp b/client/public/help-media/link-booking/step-2.webp index 62b81ee6c..36cdbdcfc 100644 Binary files a/client/public/help-media/link-booking/step-2.webp and b/client/public/help-media/link-booking/step-2.webp differ diff --git a/client/public/help-media/link-booking/step-3.webp b/client/public/help-media/link-booking/step-3.webp index 93647ae24..933161b26 100644 Binary files a/client/public/help-media/link-booking/step-3.webp and b/client/public/help-media/link-booking/step-3.webp differ diff --git a/client/public/help-media/link-booking/step-4.webp b/client/public/help-media/link-booking/step-4.webp index abbe8a177..75b68b809 100644 Binary files a/client/public/help-media/link-booking/step-4.webp and b/client/public/help-media/link-booking/step-4.webp differ diff --git a/client/public/help-media/place-booking/result.webp b/client/public/help-media/place-booking/result.webp index c2fc7c0e5..5111e1b82 100644 Binary files a/client/public/help-media/place-booking/result.webp and b/client/public/help-media/place-booking/result.webp differ diff --git a/client/public/help-media/place-booking/step-1.webp b/client/public/help-media/place-booking/step-1.webp index 425c59099..2ac4b0076 100644 Binary files a/client/public/help-media/place-booking/step-1.webp and b/client/public/help-media/place-booking/step-1.webp differ diff --git a/client/public/help-media/place-booking/step-2.webp b/client/public/help-media/place-booking/step-2.webp index cd8438f2e..d83f62ad4 100644 Binary files a/client/public/help-media/place-booking/step-2.webp and b/client/public/help-media/place-booking/step-2.webp differ diff --git a/client/public/help-media/place-booking/step-3.webp b/client/public/help-media/place-booking/step-3.webp index 370a000c7..7784a9856 100644 Binary files a/client/public/help-media/place-booking/step-3.webp and b/client/public/help-media/place-booking/step-3.webp differ diff --git a/client/public/help-media/place-booking/step-4.webp b/client/public/help-media/place-booking/step-4.webp index 77c21d752..a391d6d32 100644 Binary files a/client/public/help-media/place-booking/step-4.webp and b/client/public/help-media/place-booking/step-4.webp differ diff --git a/client/public/help-media/place-booking/step-5.webp b/client/public/help-media/place-booking/step-5.webp index a4b16dd74..1a3d58d7c 100644 Binary files a/client/public/help-media/place-booking/step-5.webp and b/client/public/help-media/place-booking/step-5.webp differ diff --git a/client/public/help-media/transports-list/step-4.webp b/client/public/help-media/transports-list/step-4.webp index e88557b10..0fdfd7af0 100644 Binary files a/client/public/help-media/transports-list/step-4.webp and b/client/public/help-media/transports-list/step-4.webp differ diff --git a/client/public/help-media/transports-list/step-5.webp b/client/public/help-media/transports-list/step-5.webp index 82a60adf5..8f8af652f 100644 Binary files a/client/public/help-media/transports-list/step-5.webp and b/client/public/help-media/transports-list/step-5.webp differ diff --git a/client/src/components/Planner/BookingCostsSection.test.tsx b/client/src/components/Planner/BookingCostsSection.test.tsx new file mode 100644 index 000000000..0d6f102d2 --- /dev/null +++ b/client/src/components/Planner/BookingCostsSection.test.tsx @@ -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> = {}) { + const handlers = { onCreate: vi.fn(), onEdit: vi.fn(), onRemove: vi.fn() }; + render(); + 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 }[] = []; + server.use( + http.put('/api/trips/1/budget/:itemId', async ({ params, request }) => { + const body = (await request.json()) as Record; + 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( + , + ); + expect(screen.getByText(money(12, 'GBP'))).toBeInTheDocument(); + unmount(); + + seedStore(useSettingsStore, { settings: { default_currency: '' } }); + render( + , + ); + 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(); + }); +}); diff --git a/client/src/components/Planner/BookingCostsSection.tsx b/client/src/components/Planner/BookingCostsSection.tsx index b8a491985..f8c5e9bec 100644 --- a/client/src/components/Planner/BookingCostsSection.tsx +++ b/client/src/components/Planner/BookingCostsSection.tsx @@ -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 (
-
+
{t(meta.labelKey)}
@@ -60,35 +66,66 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = ' ) } - if (linked) { - const meta = catMeta(linked.category) - const Icon = meta.Icon - return ( -
- -
- -
-
{linked.name}
-
{t(meta.labelKey)}
-
- {formatMoney(linked.total_price, linked.currency || base, locale)} - - -
-
- ) - } - return (
- - -
{t(hintKey)}
+ + + {linked.length > 0 && ( +
+ {linked.map(item => { + const meta = catMeta(item.category) + const Icon = meta.Icon + return ( +
+ +
+
{item.name}
+
{t(meta.labelKey)}
+
+ {formatMoney(item.total_price, item.currency || base, locale)} + + + +
+ ) + })} +
+ )} + +
+ + {/* Linking needs the record to exist, so it waits for the first save. */} + {targetId && ( + { + 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: , + } + })} + searchable + disabled={unlinked.length === 0} + /> + )} +
+ {linked.length === 0 && ( +
{t(hintKey)}
+ )}
) } diff --git a/client/src/components/Planner/BookingFileButtons.test.tsx b/client/src/components/Planner/BookingFileButtons.test.tsx new file mode 100644 index 000000000..0ba5ba087 --- /dev/null +++ b/client/src/components/Planner/BookingFileButtons.test.tsx @@ -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> = {}) { + const onAttach = vi.fn(); + const onLink = vi.fn(async (_file: TripFile) => true); + const view = render( + , + ); + 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( +
+ false)} /> +

elsewhere

+
, + ); + 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(); + }); +}); diff --git a/client/src/components/Planner/BookingFileButtons.tsx b/client/src/components/Planner/BookingFileButtons.tsx new file mode 100644 index 000000000..f3699e48f --- /dev/null +++ b/client/src/components/Planner/BookingFileButtons.tsx @@ -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 +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const pickerRef = useRef(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 ( +
+ {canAttach && ( + + )} + {canLink && ( +
+ + {open && ( +
+ {linkable.map(f => ( + + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/client/src/components/Planner/BookingLinkAndFiles.test.tsx b/client/src/components/Planner/BookingLinkAndFiles.test.tsx new file mode 100644 index 000000000..cfe902353 --- /dev/null +++ b/client/src/components/Planner/BookingLinkAndFiles.test.tsx @@ -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; + +type Props = ComponentProps; + +function setup(overrides: Partial = {}) { + 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(), + onFileChange: vi.fn(), + canAttach: true, + uploading: false, + onLinked: vi.fn(), + onDetached: vi.fn(), + ...overrides, + }; + const view = render(); + 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(); + 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); + }); +}); diff --git a/client/src/components/Planner/BookingLinkAndFiles.tsx b/client/src/components/Planner/BookingLinkAndFiles.tsx new file mode 100644 index 000000000..21581a995 --- /dev/null +++ b/client/src/components/Planner/BookingLinkAndFiles.tsx @@ -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 + onFileChange: (event: ChangeEvent) => 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 ( +
+
+ +
+ + onUrlChange(e.target.value)} + placeholder={t('reservations.urlPlaceholder')} className={inputClass} style={{ paddingLeft: 34 }} /> +
+
+
+ +
+ + 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 => ( +
+ + {f.original_name} + + +
+ ))} + {pendingFiles.map((f, i) => ( +
+ + {f.name} + +
+ ))} +
+
+
+ ) +} diff --git a/client/src/components/Planner/BookingTypeSelect.test.tsx b/client/src/components/Planner/BookingTypeSelect.test.tsx new file mode 100644 index 000000000..c7ecfd1f6 --- /dev/null +++ b/client/src/components/Planner/BookingTypeSelect.test.tsx @@ -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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/client/src/components/Planner/BookingTypeSelect.tsx b/client/src/components/Planner/BookingTypeSelect.tsx new file mode 100644 index 000000000..394254384 --- /dev/null +++ b/client/src/components/Planner/BookingTypeSelect.tsx @@ -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 ( + onChange(String(v))} + options={options.map(({ value: optionValue, labelKey, Icon }) => ({ + value: optionValue, + label: t(labelKey), + icon: , + }))} + /> + ) +} diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 0ab68e4b8..245338e03 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -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(); 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', () => { diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index 61bff07bd..5aa20b314 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); // 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(); - 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(); - 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(); - 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(); - 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(); 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(); 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(); - 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); }); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index ea708266f..148b361ed 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -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([]) - const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) // Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves. const [travelerIds, setTravelerIds] = useState>(new Set()) - const filePickerRef = useRef(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 >
- {/* Type selector */} -
- -
- {TYPE_OPTIONS.map(({ value, labelKey, Icon }) => ( - - ))} + {/* Type and travelers side by side at the head of the dialog */} +
+
+ + set('type', value)} /> +
+ {/* Travelers: trip members and guests on this booking (#1517) */} +
+ +
@@ -677,16 +657,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p )} - {/* Link */} -
- -
- - set('url', e.target.value)} - placeholder={t('reservations.urlPlaceholder')} className={inputClass} style={{ paddingLeft: 34 }} /> -
-
- {/* Notes */}
@@ -695,100 +665,24 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p className={inputClass} style={{ resize: 'none', lineHeight: 1.5 }} />
- {/* Travelers — assign trip members & guests to this booking (#1517) */} -
- - -
- - {/* Files */} -
- -
- {attachedFiles.map(f => ( -
- - {f.original_name} - - -
- ))} - {pendingFiles.map((f, i) => ( -
- - {f.name} - -
- ))} - -
- {onFileUpload && } - {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && ( -
- - {showFilePicker && ( -
- {files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => ( - - ))} -
- )} -
- )} -
-
-
+ {/* Link and files side by side */} + 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 && ( diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx index b8fdd24d7..389c02fb7 100644 --- a/client/src/components/Planner/ReservationsPanel.test.tsx +++ b/client/src/components/Planner/ReservationsPanel.test.tsx @@ -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(); + 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 () => { diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 13a77d74d..7d9ddde3e 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -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 diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 419601716..2262dff2b 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); 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(); 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(); - // 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); + 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(); + 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(); + 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(); + 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' }); + }); }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 35815780c..632efcd34 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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([]) - const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) // Travelers assigned to this booking (#1517) — seeded on open, persisted after save. const [travelerIds, setTravelerIds] = useState>(new Set()) const fileInputRef = useRef(null) - const filePickerRef = useRef(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 ) : ( - {/* Type selector */} -
- -
- {TYPE_OPTIONS.map(({ value, labelKey, Icon }) => ( - - ))} + {/* Type and travelers side by side at the head of the dialog */} +
+
+ + set('type', value as TransportType)} /> +
+ {/* Travelers: trip members and guests on this booking (#1517) */} +
+ +
@@ -1141,100 +1124,24 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel className={inputClass} style={{ resize: 'none', lineHeight: 1.5 }} />
- {/* Travelers — assign trip members & guests to this transport (#1517) */} -
- - -
- - {/* Files */} -
- -
- {attachedFiles.map(f => ( -
- - {f.original_name} - - -
- ))} - {pendingFiles.map((f, i) => ( -
- - {f.name} - -
- ))} - -
- {onFileUpload && } - {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && ( -
- - {showFilePicker && ( -
- {files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => ( - - ))} -
- )} -
- )} -
-
-
+ {/* Link and files side by side */} + 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 && ( diff --git a/client/src/components/Planner/TravelerPicker.test.tsx b/client/src/components/Planner/TravelerPicker.test.tsx index 59bf60441..8aee1a913 100644 --- a/client/src/components/Planner/TravelerPicker.test.tsx +++ b/client/src/components/Planner/TravelerPicker.test.tsx @@ -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) => { + 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(); - 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(); - 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(); - 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(); 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(); - 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(); - 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(); - 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(); - 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(); - 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(); + 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(); + 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(); + 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( +
+ +

elsewhere

+
, + ); + await openPicker(user); + fireEvent.pointerDown(screen.getByText('elsewhere')); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); }); diff --git a/client/src/components/Planner/TravelerPicker.tsx b/client/src/components/Planner/TravelerPicker.tsx index 4ccb6b248..4da469b07 100644 --- a/client/src/components/Planner/TravelerPicker.tsx +++ b/client/src/components/Planner/TravelerPicker.tsx @@ -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(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
{t('reservations.travelers.none')}
+ return
{t('reservations.travelers.none')}
} + + const chosen = tripMembers.filter(m => selectedIds.has(m.id)) return ( -
- {tripMembers.map(m => { - const on = selectedIds.has(m.id) - return ( - - ) - })} +
+ + + {open && ( +
+ {tripMembers.map(m => { + const on = selectedIds.has(m.id) + return ( + + ) + })} +
+ )}
) } diff --git a/client/src/components/Planner/useRecordLinks.test.tsx b/client/src/components/Planner/useRecordLinks.test.tsx new file mode 100644 index 000000000..24d2392d9 --- /dev/null +++ b/client/src/components/Planner/useRecordLinks.test.tsx @@ -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 }) => {children}; + +let addToast: ReturnType; + +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 }[] = []; + server.use( + http.put('/api/trips/1/budget/:itemId', async ({ params, request }) => { + const body = (await request.json()) as Record; + 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([]); + }); +}); diff --git a/client/src/components/Planner/useRecordLinks.ts b/client/src/components/Planner/useRecordLinks.ts new file mode 100644 index 000000000..40d0b3716 --- /dev/null +++ b/client/src/components/Planner/useRecordLinks.ts @@ -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 } +} diff --git a/client/src/mobile/screens/trip/sheets/MBookingFilesCosts.tsx b/client/src/mobile/screens/trip/sheets/MBookingFilesCosts.tsx new file mode 100644 index 000000000..b1d59c507 --- /dev/null +++ b/client/src/mobile/screens/trip/sheets/MBookingFilesCosts.tsx @@ -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> + 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 && ( + 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 && ( + + )} + + ) +} diff --git a/client/src/mobile/screens/trip/sheets/MLinkedCosts.tsx b/client/src/mobile/screens/trip/sheets/MLinkedCosts.tsx new file mode 100644 index 000000000..1a1dbe1d0 --- /dev/null +++ b/client/src/mobile/screens/trip/sheets/MLinkedCosts.tsx @@ -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 ( + <> + {linked.length > 0 ? t('reservations.linkedExpenses') : t('reservations.costsLabel')} + + {linked.length > 0 && ( +
+ {linked.map(item => { + const meta = catMeta(item.category) + const Icon = meta.Icon + return ( +
+ + + +
+ ) + })} +
+ )} + +
+ + {/* Linking needs the record to exist, so it waits for the first save. */} + {targetId && ( + + )} +
+ + {picking && targetId && ( +
+ {unlinked.length >= SEARCH_FROM && ( +
+ + 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" /> +
+ )} + {offered.length === 0 ? ( +
{t('reservations.noUnlinkedExpenses')}
+ ) : ( +
+ {offered.map(item => { + const meta = catMeta(item.category) + const Icon = meta.Icon + return ( + + ) + })} +
+ )} +
+ )} + + {linked.length === 0 &&
{t(hintKey)}
} + + ) +} diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx index 49642ba52..7acdaa165 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx @@ -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 && ( - <> - {t('reservations.costsLabel')} - -
{t('places.createExpenseHint')}
- + { expenseIntentRef.current = true; handleSubmit() }} + onEdit={item => onOpenExpense({ editItem: item })} + /> )}
diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index 13517ed8d..0ae7b9efe 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -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 && ( - <> - {t('files.title')} -
- {attachedFiles.map(f => ( - - ))} -
- - )} - {canUploadFiles && ( - setPendingFiles(prev => [...prev, ...files])} - onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))} - hideHint - /> - )} - - {/* COSTS */} - {isBudgetEnabled && ( - <> - {t('reservations.costsLabel')} - -
{t('reservations.createExpenseHint')}
- - )} + {/* FILES + COSTS */} + { expenseIntentRef.current = true; handleSubmit() }} + onEdit={item => onOpenExpense({ editItem: item })} + />
)} - {/* FILES */} - {canUploadFiles && ( - setPendingFiles(prev => [...prev, ...files])} - onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))} - hideHint - /> - )} - - {/* COSTS */} - {isBudgetEnabled && ( - <> - {t('reservations.costsLabel')} - -
{t('reservations.createExpenseHint')}
- - )} + {/* FILES + COSTS */} + { expenseIntentRef.current = true; handleSubmit() }} + onEdit={item => onOpenExpense({ editItem: item })} + /> )}
diff --git a/client/src/mobile/screens/trip/sheets/PlFileAttach.tsx b/client/src/mobile/screens/trip/sheets/PlFileAttach.tsx index 3f97dbc45..44490ddf9 100644 --- a/client/src/mobile/screens/trip/sheets/PlFileAttach.tsx +++ b/client/src/mobile/screens/trip/sheets/PlFileAttach.tsx @@ -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 } +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(null) + const [picking, setPicking] = useState(false) + const canLink = !!onLink && linkable.length > 0 return (
@@ -27,14 +43,27 @@ export default function PlFileAttach({ planner, files, onAdd, onRemove, hideHint
{t('files.title')}
{!hideHint &&
{t('files.pasteHint')}
}
- + {canLink && ( + + )} + {canAttach && ( + + )}
- {files.length > 0 && ( -
- {files.map((file, idx) => ( -
+ {linkable.map(file => ( + + ))} +
+ )} + {(attached.length > 0 || files.length > 0) && ( +
+ {attached.map(file => ( + + ))} + {files.map((file, idx) => ( +
{file.name} + {canAttach && } {files.map((f, i) => ( ))} + {attached.map(f => {`attached-${f.original_name}`})} + {linkable.map(f => ( + + ))}
), })) @@ -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() + }) }) diff --git a/client/tests/unit/mobile/trip/MTransportsTab.test.tsx b/client/tests/unit/mobile/trip/MTransportsTab.test.tsx index b0e2c4d85..ca14d687f 100644 --- a/client/tests/unit/mobile/trip/MTransportsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MTransportsTab.test.tsx @@ -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()), 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() }) diff --git a/client/tests/unit/mobile/trip/PlFileAttach.test.tsx b/client/tests/unit/mobile/trip/PlFileAttach.test.tsx index 097975ff7..9fafc7ef5 100644 --- a/client/tests/unit/mobile/trip/PlFileAttach.test.tsx +++ b/client/tests/unit/mobile/trip/PlFileAttach.test.tsx @@ -1,10 +1,15 @@ import type { ComponentProps } from 'react' import { describe, expect, it, vi } from 'vitest' import PlFileAttach from '../../../../src/mobile/screens/trip/sheets/PlFileAttach' +import { openFile } from '../../../../src/utils/fileDownload' +import type { TripFile } from '../../../../src/types' +import { buildTripFile } from '../../../helpers/factories' import { buildPlanner } from '../../../helpers/mobileTrip' -import { fireEvent, render, screen } from '../../../helpers/render' +import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-PLFILE-001 to FE-MOB-PLFILE-010 +// FE-MOB-PLFILE-001 to FE-MOB-PLFILE-017 + +vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn(async () => undefined) })) const planner = buildPlanner() @@ -89,4 +94,74 @@ describe('PlFileAttach', () => { fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[1]) expect(onRemove).toHaveBeenCalledWith(1) }) + + // ── Files already on a saved booking (#2084) ─────────────────────────────── + + const voucher = buildTripFile({ id: 1, original_name: 'voucher.pdf', url: '/uploads/files/voucher.pdf' }) + const ticket = buildTripFile({ id: 2, original_name: 'ticket.pdf' }) + const map = buildTripFile({ id: 3, original_name: 'map.png' }) + + it('FE-MOB-PLFILE-011: canAttach false drops the picker pill but keeps the row', () => { + setup({ canAttach: false, attached: [voucher] }) + expect(screen.queryByRole('button', { name: 'files.attach' })).not.toBeInTheDocument() + expect(screen.getByText('files.title')).toBeInTheDocument() + expect(screen.getByText('voucher.pdf')).toBeInTheDocument() + }) + + it('FE-MOB-PLFILE-012: attached files are listed above the pending ones and open on tap', () => { + setup({ attached: [voucher], files: [file('fresh.pdf')] }) + const names = screen.getAllByText(/voucher\.pdf|fresh\.pdf/).map(n => n.textContent) + expect(names).toEqual(['voucher.pdf', 'fresh.pdf']) + // Only the pending one can be dropped here. + expect(screen.getAllByRole('button', { name: 'common.delete' })).toHaveLength(1) + fireEvent.click(screen.getByText('voucher.pdf')) + expect(openFile).toHaveBeenCalledWith('/uploads/files/voucher.pdf', 'voucher.pdf') + }) + + it('FE-MOB-PLFILE-013: the link pill needs both candidates and a link handler', () => { + const { unmount } = setup({ linkable: [ticket] }) + expect(screen.queryByRole('button', { name: 'files.link' })).not.toBeInTheDocument() + unmount() + setup({ linkable: [], onLink: vi.fn(async () => true) }) + expect(screen.queryByRole('button', { name: 'files.link' })).not.toBeInTheDocument() + }) + + it('FE-MOB-PLFILE-014: the link pill folds the trip files in and out', () => { + setup({ linkable: [ticket, map], onLink: vi.fn(async () => true) }) + const pill = screen.getByRole('button', { name: 'files.link' }) + expect(pill).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument() + fireEvent.click(pill) + expect(pill).toHaveAttribute('aria-expanded', 'true') + expect(pill.className).toContain('bg-m-act') + expect(screen.getByText('ticket.pdf')).toBeInTheDocument() + expect(screen.getByText('map.png')).toBeInTheDocument() + fireEvent.click(pill) + expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument() + }) + + it('FE-MOB-PLFILE-015: a linked file folds the list away', async () => { + const onLink = vi.fn(async (_f: TripFile) => true) + setup({ linkable: [ticket, map], onLink }) + fireEvent.click(screen.getByRole('button', { name: 'files.link' })) + fireEvent.click(screen.getByText('map.png')) + expect(onLink).toHaveBeenCalledWith(map) + await waitFor(() => expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument()) + }) + + it('FE-MOB-PLFILE-016: a failed link keeps the list open for another try', async () => { + const onLink = vi.fn(async (_f: TripFile) => false) + setup({ linkable: [ticket], onLink }) + fireEvent.click(screen.getByRole('button', { name: 'files.link' })) + fireEvent.click(screen.getByText('ticket.pdf')) + await waitFor(() => expect(onLink).toHaveBeenCalledTimes(1)) + expect(screen.getByText('ticket.pdf')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'files.link' })).toHaveAttribute('aria-expanded', 'true') + }) + + it('FE-MOB-PLFILE-017: link and attach sit side by side when both are allowed', () => { + setup({ linkable: [ticket], onLink: vi.fn(async () => true) }) + const buttons = screen.getAllByRole('button').map(b => b.textContent) + expect(buttons).toEqual(['files.link', 'files.attach']) + }) }) diff --git a/client/tests/unit/slices/filesSlice.test.ts b/client/tests/unit/slices/filesSlice.test.ts index dce431680..c7f9bc523 100644 --- a/client/tests/unit/slices/filesSlice.test.ts +++ b/client/tests/unit/slices/filesSlice.test.ts @@ -123,4 +123,43 @@ describe('filesSlice', () => { expect(useTripStore.getState().files).toHaveLength(1); }); }); + + describe('linkFile', () => { + it('FE-FILES-007: linkFile posts the link, then reloads the trip files', async () => { + const file = buildTripFile({ id: 30, trip_id: 1 }); + seedStore(useTripStore, { files: [file] }); + let body: unknown = null; + const linked = { ...file, linked_reservation_ids: [9] }; + server.use( + http.post('/api/trips/1/files/30/link', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + http.get('/api/trips/1/files', () => HttpResponse.json({ files: [linked] })), + ); + + await useTripStore.getState().linkFile(1, 30, { reservation_id: 9 }); + + expect(body).toEqual({ reservation_id: 9 }); + expect(useTripStore.getState().files[0].linked_reservation_ids).toEqual([9]); + }); + + it('FE-FILES-008: linkFile throws the server message and does not reload', async () => { + const file = buildTripFile({ id: 31, trip_id: 1, filename: 'kept.pdf' }); + seedStore(useTripStore, { files: [file] }); + let reloaded = false; + server.use( + http.post('/api/trips/1/files/31/link', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })), + http.get('/api/trips/1/files', () => { + reloaded = true; + return HttpResponse.json({ files: [] }); + }), + ); + + await expect(useTripStore.getState().linkFile(1, 31, { place_id: 4 })).rejects.toThrow('Not allowed'); + + expect(reloaded).toBe(false); + expect(useTripStore.getState().files[0].filename).toBe('kept.pdf'); + }); + }); }); diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts index a0b589a9f..21c72f176 100644 --- a/server/src/nest/accommodations/accommodations.service.ts +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -603,8 +603,7 @@ export class AccommodationsService { const linkedRes = this.db.all<{ id: number }>('SELECT id FROM reservations WHERE accommodation_id = ?', Number(id)); const deletedBudgetItemIds: number[] = []; for (const res of linkedRes) { - const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE reservation_id = ?', res.id); - if (linkedBudget) { + for (const linkedBudget of this.db.all<{ id: number }>('SELECT id FROM budget_items WHERE reservation_id = ?', res.id)) { this.db.run('DELETE FROM budget_items WHERE id = ?', linkedBudget.id); deletedBudgetItemIds.push(linkedBudget.id); } diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index e575bd441..c15806d33 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -149,7 +149,11 @@ export class BudgetController { @Body() body: BudgetCreateItemDto, @Headers('x-socket-id') socketId?: string, ) { + const refusal = this.budget.linkRefusal(tripId, body); + if (refusal) throw new HttpException({ error: refusal }, 400); const item = await this.budget.create(tripId, body); + // A booking mirrors the total of its expenses (#2084); a new one adds to it. + if (item.reservation_id) this.budget.resyncReservationPrice(tripId, item.reservation_id, socketId); this.budget.broadcast(tripId, 'budget:created', { item }, socketId); return { item }; } @@ -189,13 +193,15 @@ export class BudgetController { @Body() body: BudgetUpdateItemDto, @Headers('x-socket-id') socketId?: string, ) { + const refusal = this.budget.linkRefusal(tripId, body); + if (refusal) throw new HttpException({ error: refusal }, 400); + // The booking an expense leaves also needs its price worked out again. + const before = body.reservation_id !== undefined ? this.budget.getBudgetItem(id, tripId) : null; const updated = await this.budget.update(id, tripId, body); if (!updated) { throw new HttpException({ error: 'Budget item not found' }, 404); } - if (updated.reservation_id && body.total_price !== undefined) { - this.budget.syncReservationPrice(tripId, updated.reservation_id, updated.total_price, socketId); - } + this.budget.resyncLinkedPrices(tripId, before?.reservation_id, updated, body, socketId); this.budget.broadcast(tripId, 'budget:updated', { item: updated }, socketId); return { item: updated }; } @@ -230,6 +236,8 @@ export class BudgetController { if (!item) { throw new HttpException({ error: 'Budget item not found' }, 404); } + // The payers derive the total, which the linked booking mirrors. + if (item.reservation_id) this.budget.resyncReservationPrice(tripId, item.reservation_id, socketId); this.budget.broadcast(tripId, 'budget:updated', { item }, socketId); return { item }; } diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 77df7e97b..ac6dd1e82 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -264,17 +264,19 @@ export class BudgetMcp { days: z.number().int().positive().nullable().optional(), expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD; null clears it. Omit to leave unchanged.'), note: z.string().max(500).nullable().optional(), + reservation_id: z.number().int().positive().nullable().optional().describe('Booking or transport on this trip to link the expense to (a booking can carry several); null unlinks it and keeps the expense. Omit to leave unchanged.'), + place_id: z.number().int().positive().nullable().optional().describe('Place on this trip to link the expense to; null unlinks it and keeps the expense. Omit to leave unchanged.'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: budgetAddonOn, access: { group: 'budget', mode: 'write' }, }) async updateBudgetItem( - { tripId, itemId, name, category, total_price, currency, member_ids, members, payers, persons, days, expense_date, note }: { + { tripId, itemId, name, category, total_price, currency, member_ids, members, payers, persons, days, expense_date, note, reservation_id, place_id }: { tripId: number; itemId: number; name?: string; category?: string; total_price?: number; currency?: string | null; member_ids?: number[]; members?: { user_id: number; amount: number }[]; payers?: { user_id: number; amount: number }[]; persons?: number | null; days?: number | null; - expense_date?: string | null; note?: string | null; + expense_date?: string | null; note?: string | null; reservation_id?: number | null; place_id?: number | null; }, ctx: McpContext, ) { @@ -282,6 +284,8 @@ export class BudgetMcp { if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.'); + const refusal = this.budget.linkRefusal(tripId, { reservation_id, place_id }); + if (refusal) return errorResult(refusal); if (members !== undefined) { // An edit that leaves the total alone still has to reconcile against it, so // the stored figure stands in when the call does not restate one. @@ -292,8 +296,10 @@ export class BudgetMcp { } // Freeze-then-write composite: a currency change re-freezes the rate at entry // time (#1445) on the same code path the REST update uses. - const item = await this.budget.update(itemId, tripId, { name, category, total_price, currency, member_ids, members, payers, persons, days, expense_date, note }); + const before = reservation_id !== undefined ? this.budget.getBudgetItem(itemId, tripId) : null; + const item = await this.budget.update(itemId, tripId, { name, category, total_price, currency, member_ids, members, payers, persons, days, expense_date, note, reservation_id, place_id }); if (!item) return errorResult('Budget item not found.'); + this.budget.resyncLinkedPrices(tripId, before?.reservation_id, item, { total_price, reservation_id }); this.guards.safeBroadcast(tripId, 'budget:updated', { item }); return ok({ item }); } diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index b2a6272f1..2579841be 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -559,6 +559,8 @@ export class BudgetService { persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null; ticket_json?: string | null; receipt_file_ids?: number[]; + reservation_id?: number | null; + place_id?: number | null; }, ) { return this.db.transaction(() => { @@ -583,7 +585,9 @@ export class BudgetService { note = CASE WHEN ? THEN ? ELSE note END, ticket_json = CASE WHEN ? THEN ? ELSE ticket_json END, sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END, - expense_date = CASE WHEN ? THEN ? ELSE expense_date END + expense_date = CASE WHEN ? THEN ? ELSE expense_date END, + reservation_id = CASE WHEN ? THEN ? ELSE reservation_id END, + place_id = CASE WHEN ? THEN ? ELSE place_id END WHERE id = ? `, data.category || null, @@ -597,6 +601,8 @@ export class BudgetService { ticketTouched ? 1 : 0, ticketTouched ? ticket : null, data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0, data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null, + data.reservation_id !== undefined ? 1 : 0, data.reservation_id ?? null, + data.place_id !== undefined ? 1 : 0, data.place_id ?? null, id, ); @@ -709,15 +715,77 @@ export class BudgetService { this.unlinkReceipts(id); this.db.run('DELETE FROM budget_items WHERE id = ?', id); - // The booking keeps a copy of this expense's total in its metadata, and - // the reservation update path preserves that copy across edits. With the - // expense gone there is nothing left to mirror, so drop it here rather - // than leave a price on the card that no longer has anything behind it. - if (item.reservation_id) this.clearReservationPrice(tripId, item.reservation_id); + // The booking keeps a copy of its expenses' total in its metadata, and + // the reservation update path preserves that copy across edits. With this + // expense gone the copy is worked out again from what is still linked, and + // dropped when nothing is, rather than leave a price on the card that no + // longer has anything behind it. + if (item.reservation_id) this.resyncReservationPrice(tripId, item.reservation_id); return true; }); } + /** + * After an update, the bookings whose mirrored price may have moved: the one + * the expense is linked to when its total changed, and on a re-link both the + * booking it left and the one it joined. + */ + resyncLinkedPrices( + tripId: string | number, + previousReservationId: number | null | undefined, + updated: { reservation_id?: number | null }, + data: { total_price?: number; reservation_id?: number | null }, + socketId?: string, + ): void { + const affected = new Set(); + if (data.reservation_id !== undefined && previousReservationId) affected.add(previousReservationId); + // Not only a new total: the currency and the payers (which derive the total) + // move the booking's price as well, so any edit of a linked expense resyncs. + if (updated.reservation_id) affected.add(updated.reservation_id); + for (const reservationId of affected) this.resyncReservationPrice(tripId, reservationId, socketId); + } + + /** + * Why a link from an expense can't be made, or null when it can: a booking or + * a place it points at has to exist on the same trip (#2084). REST and MCP + * both ask this before they write, so neither can reach into another trip. + */ + linkRefusal(tripId: string | number, data: { reservation_id?: number | null; place_id?: number | null }): string | null { + if (data.reservation_id != null && !this.db.get('SELECT id FROM reservations WHERE id = ? AND trip_id = ?', data.reservation_id, tripId)) { + return 'reservation_id does not belong to this trip.'; + } + if (data.place_id != null && !this.db.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', data.place_id, tripId)) { + return 'place_id does not belong to this trip.'; + } + return null; + } + + /** + * Works the booking's mirrored price out again from every expense linked to + * it (#2084): their sum while they share one currency, the first one's total + * when they don't (a sum across currencies would be meaningless), and no + * price at all once none is linked. One expense gives exactly the old mirror. + */ + resyncReservationPrice(tripId: string | number, reservationId: number, socketId?: string): void { + const linked = this.db.all<{ total_price: number | null; currency: string | null }>( + 'SELECT total_price, currency FROM budget_items WHERE trip_id = ? AND reservation_id = ? ORDER BY id', + tripId, reservationId, + ); + if (linked.length === 0) { + this.clearReservationPrice(tripId, reservationId); + return; + } + // No currency means the trip's, and a code is a code whatever its case, so + // an expense in EUR and one without a currency on a EUR trip do add up. + const tripCurrency = (this.db.get<{ currency: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId)?.currency || '').toUpperCase(); + const codeOf = (currency: string | null) => (currency || tripCurrency).toUpperCase(); + const oneCurrency = new Set(linked.map(row => codeOf(row.currency))).size === 1; + const total = oneCurrency ? sumMoney(linked.map(row => row.total_price || 0)) : (linked[0].total_price || 0); + // Either way the figure is in the first expense's currency; none means the trip's. + const allInTripCurrency = oneCurrency && linked.every(row => !row.currency); + this.syncReservationPrice(String(tripId), reservationId, total, socketId, allInTripCurrency ? null : codeOf(linked[0].currency) || null); + } + /** * The counterpart to syncReservationPrice: take the mirrored total back off * the booking. Non-fatal, exactly like the writer. @@ -1317,7 +1385,7 @@ export class BudgetService { * total_price changes, write it into the reservation's metadata and broadcast * reservation:updated. Non-fatal — a failure here never breaks the budget update. */ - syncReservationPrice(tripId: string, reservationId: number, totalPrice: number, socketId: string | undefined): void { + syncReservationPrice(tripId: string, reservationId: number, totalPrice: number, socketId: string | undefined, currency?: string | null): void { try { const reservation = this.db.get<{ id: number; metadata: string | null }>( 'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?', @@ -1329,6 +1397,12 @@ export class BudgetService { // it is linked to — and so a row stamped before #1964 heals on the next // edit. The panels print this string as it stands. meta.price = String(Math.round(totalPrice * 100) / 100); + // The card names the currency beside the figure (#2084). An expense in the + // trip's own currency carries none, and neither does its mirror then. + if (currency !== undefined) { + if (currency) meta.priceCurrency = currency.toUpperCase(); + else delete meta.priceCurrency; + } this.db.run('UPDATE reservations SET metadata = ? WHERE id = ?', JSON.stringify(meta), reservation.id); const updatedRes = this.db.get('SELECT * FROM reservations WHERE id = ?', reservation.id); this.realtime.broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId); diff --git a/server/src/nest/budget/costs.rpc.ts b/server/src/nest/budget/costs.rpc.ts index 0aebef480..909bab26a 100644 --- a/server/src/nest/budget/costs.rpc.ts +++ b/server/src/nest/budget/costs.rpc.ts @@ -65,9 +65,11 @@ export class CostsRpc { const parsed = budgetCreateItemRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`); this.requireCostEdit(tripId, actor); + this.refuseForeignLinks(tripId, parsed.data); // BudgetService.create freezes the FX rate and resolves members/payers, so the // plugin path produces the same row the web app would. const item = await this.budget.create(String(tripId), parsed.data); + if (item.reservation_id) this.budget.resyncReservationPrice(tripId, item.reservation_id); this.realtime.broadcast(tripId, 'budget:created', { item }); return item; } @@ -81,13 +83,22 @@ export class CostsRpc { const parsed = budgetUpdateItemRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`); this.requireCostEdit(tripId, actor); + this.refuseForeignLinks(tripId, parsed.data); + const before = parsed.data.reservation_id !== undefined ? this.budget.getBudgetItem(itemId, tripId) : null; // update re-freezes the FX rate on a currency change, exactly like create. const item = await this.budget.update(String(itemId), String(tripId), parsed.data); if (item == null) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`); + this.budget.resyncLinkedPrices(tripId, before?.reservation_id, item, parsed.data); this.realtime.broadcast(tripId, 'budget:updated', { item }); return item; } + /** A booking or place the cost links to has to be on the same trip, as over REST and MCP. */ + private refuseForeignLinks(tripId: number, data: { reservation_id?: number | null; place_id?: number | null }): void { + const refusal = this.budget.linkRefusal(tripId, data); + if (refusal) throw new ForbiddenResource(refusal); + } + @PluginMethod('costs.delete', { permission: 'db:write:costs' }) delete(params: Record, ctx: PluginRpcContext): unknown { const tripId = num(params.tripId, 'tripId'); diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index af8509e21..dd152b10d 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -150,15 +150,15 @@ export class ReservationsController { @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - const { deleted, accommodationDeleted, deletedBudgetItemId } = this.reservations.remove(id, tripId); + const { deleted, accommodationDeleted, deletedBudgetItemIds } = this.reservations.remove(id, tripId); if (!deleted) { throw new HttpException({ error: 'Reservation not found' }, 404); } if (accommodationDeleted) { this.reservations.broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, socketId); } - if (deletedBudgetItemId) { - this.reservations.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, socketId); + for (const itemId of deletedBudgetItemIds) { + this.reservations.broadcast(tripId, 'budget:deleted', { itemId }, socketId); } this.reservations.broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, socketId); this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || ''); diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 4e3772437..4e2109423 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -472,11 +472,12 @@ export class ReservationsMcp { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); - const { deleted, accommodationDeleted } = this.reservations.remove(reservationId, tripId); + const { deleted, accommodationDeleted, deletedBudgetItemIds } = this.reservations.remove(reservationId, tripId); if (!deleted) return errorResult('Reservation not found.'); if (accommodationDeleted) { this.guards.safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }); } + for (const itemId of deletedBudgetItemIds) this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId }); this.guards.safeBroadcast(tripId, 'reservation:deleted', { reservationId }); return ok({ success: true }); } @@ -902,8 +903,9 @@ export class ReservationsMcp { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); - const { deleted } = this.reservations.remove(reservationId, tripId); + const { deleted, deletedBudgetItemIds } = this.reservations.remove(reservationId, tripId); if (!deleted) return errorResult('Transport not found.'); + for (const itemId of deletedBudgetItemIds) this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId }); this.guards.safeBroadcast(tripId, 'reservation:deleted', { reservationId }); return ok({ success: true }); } diff --git a/server/src/nest/reservations/reservations.rpc.ts b/server/src/nest/reservations/reservations.rpc.ts index 73a664d49..fb0ac38c4 100644 --- a/server/src/nest/reservations/reservations.rpc.ts +++ b/server/src/nest/reservations/reservations.rpc.ts @@ -77,10 +77,10 @@ export class ReservationsRpc { const reservationId = num(params.reservationId, 'reservationId'); const actor = this.guards.requireActor(ctx, 'reservation'); this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); - const { deleted, accommodationDeleted, deletedBudgetItemId } = this.reservations.remove(String(reservationId), String(tripId)); + const { deleted, accommodationDeleted, deletedBudgetItemIds } = this.reservations.remove(String(reservationId), String(tripId)); if (!deleted) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`); if (accommodationDeleted) this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, undefined); - if (deletedBudgetItemId) this.realtime.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, undefined); + for (const itemId of deletedBudgetItemIds) this.realtime.broadcast(tripId, 'budget:deleted', { itemId }, undefined); this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId }, undefined); this.notifyBooking(actor, tripId, deleted.title, deleted.type || ''); return { deleted: true }; diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index eb4ae18a1..b571e8029 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -987,12 +987,12 @@ export class ReservationsService { /** The accommodation + budget-item + reservation deletes are one logical * cascade — all-or-nothing. */ - remove(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null } { + remove(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null; deletedBudgetItemIds: number[] } { const removed = this.db.transaction(() => { const reservation = this.db.get<{ id: number; title: string; type: string; accommodation_id: number | null }>( 'SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?', id, tripId ); - if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null, stayMirror: noStayMirror() }; + if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [], stayMirror: noStayMirror() }; let accommodationDeleted = false; let stayMirror = noStayMirror(); @@ -1015,13 +1015,16 @@ export class ReservationsService { } } - const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id); - if (linkedBudget) { - this.db.run('DELETE FROM budget_items WHERE id = ?', linkedBudget.id); + // A booking can carry several expenses (#2084); every one of them goes with it. + const deletedBudgetItemIds = this.db + .all<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id) + .map(item => item.id); + if (deletedBudgetItemIds.length > 0) { + this.db.run(`DELETE FROM budget_items WHERE id IN (${deletedBudgetItemIds.map(() => '?').join(', ')})`, ...deletedBudgetItemIds); } this.db.run('DELETE FROM reservations WHERE id = ?', id); - return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null, stayMirror }; + return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: deletedBudgetItemIds[0] ?? null, deletedBudgetItemIds, stayMirror }; }); const { stayMirror, ...answer } = removed; this.announceStayMirror(tripId, stayMirror); @@ -1049,14 +1052,13 @@ export class ReservationsService { // but only if it still carries the auto-derived category (so a manual pick in // the Costs editor is preserved). Runs regardless of create_budget_entry. if (type && currentType && type !== currentType) { - const linked = this.db.get<{ id: number; category: string }>('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id); - if (linked) { - const oldCat = typeToCostCategory(currentType); - const newCat = typeToCostCategory(type); - if (oldCat !== newCat && linked.category === oldCat) { - const updated = this.budget.updateBudgetItem(linked.id, tripId, { category: newCat }); - this.realtime.broadcast(tripId, 'budget:updated', { item: updated }, socketId); - } + const oldCat = typeToCostCategory(currentType); + const newCat = typeToCostCategory(type); + // Every linked expense (#2084), each only while it still has the derived category. + const linked = this.db.all<{ id: number; category: string }>('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id); + for (const item of oldCat === newCat ? [] : linked.filter(i => i.category === oldCat)) { + const updated = this.budget.updateBudgetItem(item.id, tripId, { category: newCat }); + this.realtime.broadcast(tripId, 'budget:updated', { item: updated }, socketId); } } @@ -1064,6 +1066,10 @@ export class ReservationsService { // expense, so leave any linked item alone. Expenses are managed from the // booking's Costs section / the Costs tab, not by re-saving the booking. if (!entry) return; + // The price field speaks for a single expense. With several linked (#2084) + // it has none to mean, so they are managed from the Costs block only. + const linkedCount = this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id)?.n ?? 0; + if (linkedCount > 1) return; if (!(Number(entry.total_price) > 0)) { // Explicit clear (total_price 0/empty) — drop the linked item. diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 04c4efd09..3d491e89c 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -4,6 +4,8 @@ * schema (createTables + runMigrations), so the folded BudgetService runs its * real SQL. Only the db singleton (trip access) and the WebSocket broadcast are * mocked; the permission check is a spy on the container's PermissionsService. + * ReservationsModule is mounted beside it because an expense can be linked to a + * booking (#2084), and deleting that booking has to take the expense with it. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; import request from 'supertest'; @@ -43,6 +45,8 @@ let checkPermission: MockInstance; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { BudgetModule } from '../../src/nest/budget/budget.module'; +import { ReservationsModule } from '../../src/nest/reservations/reservations.module'; +import { NotificationsService } from '../../src/nest/notifications/notifications.service'; import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -53,11 +57,14 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, BudgetModule] }) + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, BudgetModule, ReservationsModule] }) // The settlement read awaits live FX rates; the trip here is all-EUR, so a // null result is the identity — and the test never touches the network. .overrideProvider(ExchangeRatesService) .useValue({ getRates: async () => null }) + // A booking delete notifies the trip; nothing here is listening. + .overrideProvider(NotificationsService) + .useValue({ send: vi.fn().mockResolvedValue(undefined) }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); @@ -273,4 +280,154 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Settlement not found' }); }); + + // #2084: an expense that already exists can be linked to a booking of its trip + // (several per booking), let go of again, and goes with the booking it is on. + describe('linking expenses to bookings (#2084)', () => { + let bookingId: number; + let foreignBookingId: number; + let foreignPlaceId: number; + let fareId: number; + let luggageId: number; + let seatId: number; + + const metadataOf = (id: number) => { + const row = db.prepare('SELECT metadata FROM reservations WHERE id = ?').get(id) as { metadata: string | null }; + return row.metadata ? JSON.parse(row.metadata) : null; + }; + const linkOf = (id: number) => + db.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(id) as { reservation_id: number | null } | undefined; + const put = (id: number, body: Record) => + request(server).put(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1)).send(body); + const create = (body: Record) => + request(server).post(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1)).send(body); + + beforeAll(async () => { + bookingId = Number(db.prepare( + "INSERT INTO reservations (trip_id, title, type, metadata) VALUES (?, 'Flight', 'flight', ?)", + ).run(tripId, JSON.stringify({ seat: '12A' })).lastInsertRowid); + const otherTripId = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'Other Trip', 'EUR')").run().lastInsertRowid); + foreignBookingId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Elsewhere', 'flight')").run(otherTripId).lastInsertRowid); + foreignPlaceId = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'Elsewhere')").run(otherTripId).lastInsertRowid); + }); + + it('200 on linking two expenses to one booking, which then mirrors their sum', async () => { + const fare = await create({ name: 'Fare', total_price: 120 }); + const luggage = await create({ name: 'Luggage', total_price: 30.5 }); + expect(fare.status).toBe(201); + expect(luggage.status).toBe(201); + fareId = fare.body.item.id; + luggageId = luggage.body.item.id; + + const first = await put(fareId, { reservation_id: bookingId }); + expect(first.status).toBe(200); + expect(first.body.item).toMatchObject({ id: fareId, reservation_id: bookingId, total_price: 120 }); + expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' }); + + const second = await put(luggageId, { reservation_id: bookingId }); + expect(second.status).toBe(200); + expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '150.5' }); + + const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1)); + const linked = (list.body.items as { id: number; reservation_id: number | null }[]) + .filter(i => i.reservation_id === bookingId).map(i => i.id); + expect(linked).toEqual([fareId, luggageId]); + }); + + it('400 on a booking or a place from another trip, on update and on create, writing nothing', async () => { + const relink = await put(fareId, { reservation_id: foreignBookingId, name: 'Hijacked' }); + expect(relink.status).toBe(400); + expect(relink.body).toEqual({ error: 'reservation_id does not belong to this trip.' }); + expect(linkOf(fareId)).toEqual({ reservation_id: bookingId }); + expect(db.prepare('SELECT name FROM budget_items WHERE id = ?').get(fareId)).toEqual({ name: 'Fare' }); + expect(metadataOf(foreignBookingId)).toBeNull(); + + const place = await put(fareId, { place_id: foreignPlaceId }); + expect(place.status).toBe(400); + expect(place.body).toEqual({ error: 'place_id does not belong to this trip.' }); + + const before = (db.prepare('SELECT COUNT(*) AS n FROM budget_items').get() as { n: number }).n; + const created = await create({ name: 'Smuggled', total_price: 5, reservation_id: foreignBookingId }); + expect(created.status).toBe(400); + expect(created.body).toEqual({ error: 'reservation_id does not belong to this trip.' }); + expect((db.prepare('SELECT COUNT(*) AS n FROM budget_items').get() as { n: number }).n).toBe(before); + }); + + it('400 on a reservation_id that is not a positive integer (Zod pipe envelope)', async () => { + const res = await put(fareId, { reservation_id: 0 }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('reservation_id'); + expect(linkOf(fareId)).toEqual({ reservation_id: bookingId }); + }); + + it('200 on unlinking: the expense stays and the booking price is worked out again', async () => { + const res = await put(luggageId, { reservation_id: null }); + expect(res.status).toBe(200); + expect(res.body.item).toMatchObject({ id: luggageId, reservation_id: null, total_price: 30.5 }); + expect(linkOf(luggageId)).toEqual({ reservation_id: null }); + expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' }); + }); + + it('201 on a second expense created straight onto the booking, which raises its price to the sum', async () => { + const seat = await create({ name: 'Seat', total_price: 15, reservation_id: bookingId }); + expect(seat.status).toBe(201); + expect(seat.body.item.reservation_id).toBe(bookingId); + seatId = seat.body.item.id; + expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '135' }); + }); + + it('200 on new payers for a linked expense, whose derived total the booking price follows', async () => { + const res = await request(server) + .put(`/api/trips/${tripId}/budget/${seatId}/payers`) + .set('Cookie', sessionCookie(1)) + .send({ payers: [{ user_id: 1, amount: 12 }, { user_id: 2, amount: 6 }] }); + expect(res.status).toBe(200); + expect(res.body.item).toMatchObject({ id: seatId, total_price: 18 }); + expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '138' }); + }); + + it('200 on a currency-only change of a linked expense, which re-names the booking price', async () => { + const trainId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Train', 'train')").run(tripId).lastInsertRowid); + const ticket = await create({ name: 'Ticket', total_price: 60, reservation_id: trainId }); + expect(ticket.status).toBe(201); + // In the trip currency, so the price carries no currency of its own. + expect(metadataOf(trainId)).toEqual({ price: '60' }); + + const chf = await put(ticket.body.item.id, { currency: 'CHF' }); + expect(chf.status).toBe(200); + expect(chf.body.item).toMatchObject({ currency: 'CHF', total_price: 60 }); + expect(metadataOf(trainId)).toEqual({ price: '60', priceCurrency: 'CHF' }); + + const back = await put(ticket.body.item.id, { currency: null }); + expect(back.status).toBe(200); + expect(metadataOf(trainId)).toEqual({ price: '60' }); + + // An expense with no currency and one naming the trip's own (EUR, in any + // case) are in the same money, so the booking shows their sum. + const bike = await create({ name: 'Bike ticket', total_price: 9.5, currency: 'eur', reservation_id: trainId }); + expect(bike.status).toBe(201); + expect(metadataOf(trainId)).toEqual({ price: '69.5', priceCurrency: 'EUR' }); + }); + + it('deleting the booking removes every expense linked to it, and only those', async () => { + const unlinkedId = luggageId; + + const res = await request(server) + .delete(`/api/trips/${tripId}/reservations/${bookingId}`) + .set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + + expect(db.prepare('SELECT id FROM reservations WHERE id = ?').get(bookingId)).toBeUndefined(); + expect(linkOf(fareId)).toBeUndefined(); + expect(linkOf(seatId)).toBeUndefined(); + expect(linkOf(unlinkedId)).toEqual({ reservation_id: null }); + + const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1)); + const ids = (list.body.items as { id: number }[]).map(i => i.id); + expect(ids).not.toContain(fareId); + expect(ids).not.toContain(seatId); + expect(ids).toContain(unlinkedId); + }); + }); }); diff --git a/server/tests/unit/budget/costs.rpc.test.ts b/server/tests/unit/budget/costs.rpc.test.ts index c75322b4f..98ef8972c 100644 --- a/server/tests/unit/budget/costs.rpc.test.ts +++ b/server/tests/unit/budget/costs.rpc.test.ts @@ -23,13 +23,24 @@ import { makeDeps } from '../../helpers/rpc-host-deps'; const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); -/** Trip 1 belongs to user 42, cost 5 sits on it, and the addon is on unless said otherwise. */ -function build(opts: { addonOn?: boolean; canEdit?: boolean; missing?: boolean } = {}) { +/** + * Trip 1 belongs to user 42, cost 5 sits on it, and the addon is on unless said otherwise. + * `refusal` is what BudgetService.linkRefusal answers for the body's links (#2084), and + * `linkedTo` the booking cost 5 is linked to before the write. + */ +function build(opts: { addonOn?: boolean; canEdit?: boolean; missing?: boolean; refusal?: string; linkedTo?: number | null } = {}) { const budget = { listBudgetItems: vi.fn((tripId: number) => [{ id: 5, trip_id: tripId }]), create: vi.fn(async (tripId: string, i: Record) => ({ id: 9, trip_id: tripId, ...i })), - update: vi.fn(async () => (opts.missing ? null : { id: 5, name: 'Hotel' })), + // The row after the write: a link in the body moves it, otherwise it keeps its booking. + update: vi.fn(async (_id: string, _tripId: string, i: Record) => (opts.missing + ? null + : { id: 5, name: 'Hotel', reservation_id: 'reservation_id' in i ? i.reservation_id : (opts.linkedTo ?? null) })), remove: vi.fn(() => !opts.missing), + linkRefusal: vi.fn(() => opts.refusal ?? null), + getBudgetItem: vi.fn(() => (opts.missing ? null : { id: 5, reservation_id: opts.linkedTo ?? null })), + resyncLinkedPrices: vi.fn(), + resyncReservationPrice: vi.fn(), } as unknown as BudgetService & Record>; const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; const db = { @@ -149,3 +160,86 @@ describe('CostsRpc writes', () => { expectRegisteredProvider(BudgetModule, CostsRpc); }); }); + +// #2084: a cost can point at a booking or a place, which has to be on the same trip +// (the REST route answers 400, the MCP tool an error result, the plugin surface +// RESOURCE_FORBIDDEN, all in the same words), and the booking mirrors its costs' total. +describe('CostsRpc links to bookings and places', () => { + it('COSTS-RPC-013 a create linking another trip\'s booking is refused in linkRefusal\'s words, before any write', async () => { + const f = build({ refusal: 'reservation_id does not belong to this trip.' }); + const res = (await f.host().dispatch(req('costs.create', { tripId: 1, input: { name: 'Seat', total_price: 15, reservation_id: 4711 } }), 42)) as RpcError; + expect(res.error.code).toBe('RESOURCE_FORBIDDEN'); + expect(res.error.message).toBe('reservation_id does not belong to this trip.'); + expect(f.budget.linkRefusal).toHaveBeenCalledWith(1, expect.objectContaining({ reservation_id: 4711 })); + expect(f.budget.create).not.toHaveBeenCalled(); + expect(f.budget.resyncReservationPrice).not.toHaveBeenCalled(); + expect(f.realtime.broadcast).not.toHaveBeenCalled(); + }); + + it('COSTS-RPC-014 an update linking another trip\'s place is refused before it reads, writes or resyncs', async () => { + const f = build({ refusal: 'place_id does not belong to this trip.', linkedTo: 77 }); + const res = (await f.host().dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { place_id: 4711 } }), 42)) as RpcError; + expect(res.error.code).toBe('RESOURCE_FORBIDDEN'); + expect(res.error.message).toBe('place_id does not belong to this trip.'); + expect(f.budget.linkRefusal).toHaveBeenCalledWith(1, { place_id: 4711 }); + expect(f.budget.getBudgetItem).not.toHaveBeenCalled(); + expect(f.budget.update).not.toHaveBeenCalled(); + expect(f.budget.resyncLinkedPrices).not.toHaveBeenCalled(); + expect(f.realtime.broadcast).not.toHaveBeenCalled(); + }); + + it('COSTS-RPC-015 the permission refusal still comes first, so a link check never answers a caller without budget_edit', async () => { + const f = build({ canEdit: false, refusal: 'reservation_id does not belong to this trip.' }); + for (const [method, params] of [ + ['costs.create', { tripId: 1, input: { name: 'x', reservation_id: 4711 } }], + ['costs.update', { tripId: 1, itemId: 5, input: { reservation_id: 4711 } }], + ] as const) { + expect(((await f.host().dispatch(req(method, params), 42)) as RpcError).error.message) + .toBe('no permission to edit costs on trip 1'); + } + expect(f.budget.linkRefusal).not.toHaveBeenCalled(); + }); + + it('COSTS-RPC-016 a cost created on a booking resyncs that booking\'s price; an unlinked one resyncs nothing', async () => { + const f = build(); + expect((await f.host().dispatch(req('costs.create', { tripId: 1, input: { name: 'Seat', total_price: 15, reservation_id: 77 } }), 42)).ok).toBe(true); + expect(f.budget.resyncReservationPrice).toHaveBeenCalledWith(1, 77); + expect(f.realtime.broadcast.mock.calls.map((c) => c[1])).toEqual(['budget:created']); + + const g = build(); + await g.host().dispatch(req('costs.create', { tripId: 1, input: { name: 'Coffee', total_price: 3 } }), 42); + expect(g.budget.resyncReservationPrice).not.toHaveBeenCalled(); + }); + + it('COSTS-RPC-017 an update moving the cost hands the booking it left and the row it wrote to resyncLinkedPrices', async () => { + const f = build({ linkedTo: 77 }); + const res = await f.host().dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { reservation_id: 78 } }), 42); + expect(res.ok).toBe(true); + // The row is read before the write, so the booking it leaves is still known. + expect(f.budget.getBudgetItem).toHaveBeenCalledWith(5, 1); + expect(vi.mocked(f.budget.getBudgetItem).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(f.budget.update).mock.invocationCallOrder[0]); + expect(f.budget.resyncLinkedPrices).toHaveBeenCalledWith(1, 77, { id: 5, name: 'Hotel', reservation_id: 78 }, { reservation_id: 78 }); + }); + + it('COSTS-RPC-018 an unlink (null) snapshots too and passes the booking it leaves', async () => { + const f = build({ linkedTo: 77 }); + await f.host().dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { reservation_id: null } }), 42); + expect(f.budget.resyncLinkedPrices).toHaveBeenCalledWith(1, 77, { id: 5, name: 'Hotel', reservation_id: null }, { reservation_id: null }); + }); + + it('COSTS-RPC-019 an update that names no booking reads nothing first, yet still lets the linked booking resync', async () => { + // A new total or currency moves the price of the booking the cost stays on. + const f = build({ linkedTo: 77 }); + await f.host().dispatch(req('costs.update', { tripId: 1, itemId: 5, input: { total_price: 40 } }), 42); + expect(f.budget.getBudgetItem).not.toHaveBeenCalled(); + expect(f.budget.resyncLinkedPrices).toHaveBeenCalledWith(1, undefined, { id: 5, name: 'Hotel', reservation_id: 77 }, { total_price: 40 }); + }); + + it('COSTS-RPC-020 a missing cost resyncs nothing', async () => { + const f = build({ missing: true }); + const res = (await f.host().dispatch(req('costs.update', { tripId: 1, itemId: 404, input: { reservation_id: 78 } }), 42)) as RpcError; + expect(res.error.message).toBe('no cost 404 on trip 1'); + expect(f.budget.resyncLinkedPrices).not.toHaveBeenCalled(); + expect(f.realtime.broadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts index a5084b376..81118cf97 100644 --- a/server/tests/unit/mcp/tools-budget.test.ts +++ b/server/tests/unit/mcp/tools-budget.test.ts @@ -38,7 +38,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip, createBudgetItem, createPlace, addTripMember } from '../../helpers/factories'; +import { createUser, createTrip, createBudgetItem, createPlace, createReservation, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { @@ -764,6 +764,155 @@ describe('Budget tools: place link', () => { }); }); +// --------------------------------------------------------------------------- +// Linking an existing expense to a booking or a place (#2084), the MCP twin of +// PUT /budget/:id with reservation_id / place_id +// --------------------------------------------------------------------------- + +describe('Tool: update_budget_item (links)', () => { + function links(itemId: number) { + return testDb.prepare('SELECT reservation_id, place_id FROM budget_items WHERE id = ?') + .get(itemId) as { reservation_id: number | null; place_id: number | null }; + } + + function priceOf(reservationId: number): Record { + const row = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string | null }; + return row.metadata ? JSON.parse(row.metadata) : {}; + } + + function update(h: McpHarness, args: Record) { + return h.client.callTool({ name: 'update_budget_item', arguments: args }); + } + + it('links two expenses to one booking, which then mirrors their sum', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const booking = createReservation(testDb, trip.id, { title: 'Flight' }); + const fare = createBudgetItem(testDb, trip.id, { name: 'Fare', total_price: 180 }); + const luggage = createBudgetItem(testDb, trip.id, { name: 'Luggage', total_price: 35.5 }); + await withHarness(user.id, async (h) => { + const result = await update(h, { tripId: trip.id, itemId: fare.id, reservation_id: booking.id }); + expect((parseToolResult(result) as any).item.reservation_id).toBe(booking.id); + expect(priceOf(booking.id)).toEqual({ price: '180' }); + + await update(h, { tripId: trip.id, itemId: luggage.id, reservation_id: booking.id }); + expect(links(luggage.id).reservation_id).toBe(booking.id); + expect(priceOf(booking.id)).toEqual({ price: '215.5' }); + // The booking card updates in every open session, not only the expense row. + expect(broadcastMock).toHaveBeenCalledWith(String(trip.id), 'reservation:updated', expect.objectContaining({ reservation: expect.objectContaining({ id: booking.id }) }), undefined); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:updated', expect.objectContaining({ item: expect.objectContaining({ id: luggage.id }) })); + }); + }); + + it('refuses a booking from another trip, in the words the REST route uses, and writes nothing', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const elsewhere = createTrip(testDb, user.id); + const foreign = createReservation(testDb, elsewhere.id); + const item = createBudgetItem(testDb, trip.id, { name: 'Fare' }); + await withHarness(user.id, async (h) => { + const result = await update(h, { tripId: trip.id, itemId: item.id, reservation_id: foreign.id, name: 'Renamed' }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('reservation_id does not belong to this trip.'); + }); + expect(links(item.id)).toEqual({ reservation_id: null, place_id: null }); + expect(testDb.prepare('SELECT name FROM budget_items WHERE id = ?').get(item.id)).toEqual({ name: 'Fare' }); + expect(priceOf(foreign.id)).toEqual({}); + expect(broadcastMock).not.toHaveBeenCalled(); + }); + + it('refuses a place from another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const elsewhere = createTrip(testDb, user.id); + const foreign = createPlace(testDb, elsewhere.id); + const item = createBudgetItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await update(h, { tripId: trip.id, itemId: item.id, place_id: foreign.id }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('place_id does not belong to this trip.'); + }); + expect(links(item.id).place_id).toBeNull(); + }); + + it('links an expense to a place, and null lets go of it while the expense stays', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }); + const item = createBudgetItem(testDb, trip.id, { name: 'Tickets', total_price: 34 }); + await withHarness(user.id, async (h) => { + await update(h, { tripId: trip.id, itemId: item.id, place_id: place.id }); + expect(links(item.id).place_id).toBe(place.id); + // An edit that leaves the link out keeps it. + await update(h, { tripId: trip.id, itemId: item.id, name: 'Museum tickets' }); + expect(links(item.id).place_id).toBe(place.id); + await update(h, { tripId: trip.id, itemId: item.id, place_id: null }); + }); + expect(links(item.id).place_id).toBeNull(); + expect(itemRow(item.id).total_price).toBe(34); + }); + + it('null lets go of the booking, keeps the expense and takes its share off the booking price', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const booking = createReservation(testDb, trip.id); + const fare = createBudgetItem(testDb, trip.id, { total_price: 100 }); + const extra = createBudgetItem(testDb, trip.id, { total_price: 20 }); + await withHarness(user.id, async (h) => { + await update(h, { tripId: trip.id, itemId: fare.id, reservation_id: booking.id }); + await update(h, { tripId: trip.id, itemId: extra.id, reservation_id: booking.id }); + expect(priceOf(booking.id)).toEqual({ price: '120' }); + + await update(h, { tripId: trip.id, itemId: extra.id, reservation_id: null }); + expect(priceOf(booking.id)).toEqual({ price: '100' }); + await update(h, { tripId: trip.id, itemId: fare.id, reservation_id: null }); + expect(priceOf(booking.id)).toEqual({}); + }); + expect(itemCount(trip.id)).toBe(2); + }); + + it('moving an expense to another booking resyncs both', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const outbound = createReservation(testDb, trip.id, { title: 'Out' }); + const inbound = createReservation(testDb, trip.id, { title: 'Back' }); + const item = createBudgetItem(testDb, trip.id, { total_price: 75 }); + await withHarness(user.id, async (h) => { + await update(h, { tripId: trip.id, itemId: item.id, reservation_id: outbound.id }); + await update(h, { tripId: trip.id, itemId: item.id, reservation_id: inbound.id }); + }); + expect(priceOf(outbound.id)).toEqual({}); + expect(priceOf(inbound.id)).toEqual({ price: '75' }); + }); + + it('a new total on a linked expense reaches the booking price', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const booking = createReservation(testDb, trip.id); + const item = createBudgetItem(testDb, trip.id, { total_price: 50 }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(booking.id, item.id); + await withHarness(user.id, async (h) => { + await update(h, { tripId: trip.id, itemId: item.id, total_price: 64.2 }); + }); + expect(priceOf(booking.id)).toEqual({ price: '64.2' }); + }); + + it('a currency change alone on a linked expense re-names the booking price', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const booking = createReservation(testDb, trip.id); + const item = createBudgetItem(testDb, trip.id, { total_price: 50 }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(booking.id, item.id); + stubRates({ USD: 1.1 }); + await withHarness(user.id, async (h) => { + await update(h, { tripId: trip.id, itemId: item.id, currency: 'USD' }); + expect(priceOf(booking.id)).toEqual({ price: '50', priceCurrency: 'USD' }); + await update(h, { tripId: trip.id, itemId: item.id, currency: null }); + expect(priceOf(booking.id)).toEqual({ price: '50' }); + }); + }); +}); + // --------------------------------------------------------------------------- // Settle-up payments made in another currency (the #1445 freeze) // --------------------------------------------------------------------------- diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts index 074128ae4..7c9778b96 100644 --- a/server/tests/unit/mcp/tools-reservations.test.ts +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -36,7 +36,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment, createDayAccommodation, createCategory, addTripMember } from '../../helpers/factories'; +import { createUser, createTrip, createDay, createPlace, createReservation, createBudgetItem, createDayAssignment, createDayAccommodation, createCategory, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { @@ -319,9 +319,31 @@ describe('Tool: delete_reservation', () => { await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } }); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:deleted', expect.any(Object)); + // Nothing was linked, so no expense is announced gone. + expect(broadcastMock.mock.calls.some(c => c[1] === 'budget:deleted')).toBe(false); }); }); + it('takes every linked expense with it and announces each one, as the REST route does (#2084)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + const fare = createBudgetItem(testDb, trip.id, { name: 'Fare' }); + const luggage = createBudgetItem(testDb, trip.id, { name: 'Luggage' }); + const unrelated = createBudgetItem(testDb, trip.id, { name: 'Coffee' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservation.id, fare.id, luggage.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } }); + expect(parseToolResult(result)).toEqual({ success: true }); + }); + expect(testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').all(trip.id)).toEqual([{ id: unrelated.id }]); + const deleted = broadcastMock.mock.calls.filter(c => c[1] === 'budget:deleted').map(c => (c[2] as { itemId: number }).itemId); + expect(deleted).toEqual([fare.id, luggage.id]); + // The expenses go out before the booking, so no client is left holding a link to nothing. + const events = broadcastMock.mock.calls.map(c => c[1]); + expect(events.lastIndexOf('budget:deleted')).toBeLessThan(events.indexOf('reservation:deleted')); + }); + it('returns error for reservation not found', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/mcp/tools-transports.test.ts b/server/tests/unit/mcp/tools-transports.test.ts index afa465da3..36057a30f 100644 --- a/server/tests/unit/mcp/tools-transports.test.ts +++ b/server/tests/unit/mcp/tools-transports.test.ts @@ -409,6 +409,35 @@ describe('Tool: delete_transport', () => { expect(parseToolResult(result)).toEqual({ success: true }); expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id)).toBeUndefined(); expect(broadcastMock.mock.calls.some(c => c[1] === 'reservation:deleted')).toBe(true); + expect(broadcastMock.mock.calls.some(c => c[1] === 'budget:deleted')).toBe(false); + }); + }); + + it('takes every expense linked to the transport with it and announces each one (#2084)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const created = await h.client.callTool({ + name: 'create_transport', + arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints }, + }); + const { reservation } = parseToolResult(created) as { reservation: { id: number } }; + const expense = (name: string) => Number(testDb.prepare( + 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)', + ).run(trip.id, name, 'flights', 50, reservation.id).lastInsertRowid); + const fare = expense('Fare'); + const seat = expense('Seat'); + broadcastMock.mockClear(); + + const result = await h.client.callTool({ + name: 'delete_transport', + arguments: { tripId: trip.id, reservationId: reservation.id }, + }); + expect(parseToolResult(result)).toEqual({ success: true }); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 }); + // One event per expense, and all of them before the transport itself goes. + expect(broadcastMock.mock.calls.map(c => c[1])).toEqual(['budget:deleted', 'budget:deleted', 'reservation:deleted']); + expect(broadcastMock.mock.calls.slice(0, 2).map(c => (c[2] as { itemId: number }).itemId)).toEqual([fare, seat]); }); }); diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 30758cfef..9977e105a 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -347,6 +347,35 @@ describe('deleteAccommodation', () => { expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(budgetItemId)).toBeUndefined(); }); + it('ACC-035 — every expense on every linked booking goes with the stay (#2084)', () => { + // A booking can carry several expenses now; taking only the first stranded the + // rest on a reservation id that no longer exists. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id) as any; + const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; + const { accommodation: accom } = svc.createAccommodation(trip.id, { + place_id: place.id, start_day_id: day.id, end_day_id: day.id, + }) as any; + const first = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + const second = testDb.prepare( + 'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)' + ).run(trip.id, 'hotel', 'Second booking', accom.id).lastInsertRowid as number; + const expense = (reservationId: number | null, name: string) => testDb.prepare( + 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)' + ).run(trip.id, name, 'accommodation', 50, reservationId).lastInsertRowid as number; + const room = expense(first.id, 'Room'); + const breakfast = expense(first.id, 'Breakfast'); + const parking = expense(second, 'Parking'); + const unrelated = expense(null, 'Coffee'); + + const result = svc.deleteAccommodation(accom.id); + + expect(result.deletedBudgetItemIds).toEqual([room, breakfast, parking]); + expect(result.deletedBudgetItemId).toBe(room); + expect(testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').all(trip.id)).toEqual([{ id: unrelated }]); + }); + it('ACC-010 — a second booking pointed at the same stay goes too, instead of being orphaned', () => { // reservations.accommodation_id carries no foreign key and no unique constraint, // and the booking form lets a hotel booking pick an existing stay. Deleting only diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index e33f96685..580968ca1 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -13,6 +13,11 @@ function makeService(overrides: Partial = {}): BudgetService { canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), syncReservationPrice: vi.fn(), + // Both write routes ask this before they write (#2084); null lets them through. + linkRefusal: vi.fn().mockReturnValue(null), + getBudgetItem: vi.fn().mockReturnValue(null), + resyncLinkedPrices: vi.fn(), + resyncReservationPrice: vi.fn(), ...overrides, } as unknown as BudgetService; } @@ -134,36 +139,130 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou it('creates and broadcasts', async () => { const create = vi.fn().mockReturnValue({ id: 9, name: 'Hotel' }); const broadcast = vi.fn(); - const svc = makeService({ create, broadcast } as Partial); + const linkRefusal = vi.fn().mockReturnValue(null); + const resyncReservationPrice = vi.fn(); + const svc = makeService({ create, broadcast, linkRefusal, resyncReservationPrice } as Partial); expect(await new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } }); + expect(linkRefusal).toHaveBeenCalledWith('5', { name: 'Hotel', total_price: 200 }); expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock'); + // Not linked to a booking, so no booking price to work out. + expect(resyncReservationPrice).not.toHaveBeenCalled(); + }); + + it('an expense created on a booking adds to the price the booking mirrors (#2084)', async () => { + const item = { id: 9, name: 'Seat', total_price: 15, reservation_id: 42 }; + const resyncReservationPrice = vi.fn(); + const broadcast = vi.fn(); + const svc = makeService({ create: vi.fn().mockReturnValue(item), resyncReservationPrice, broadcast } as Partial); + expect(await new BudgetController(svc).create(user, '5', { name: 'Seat', total_price: 15, reservation_id: 42 }, 'sock')).toEqual({ item }); + expect(resyncReservationPrice).toHaveBeenCalledWith('5', 42, 'sock'); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item }, 'sock'); + }); + + // #2084: an expense can only point at a booking or a place of its own trip. + // The body and the status are the contract the MCP tool mirrors word for word. + it('400s on a reservation_id from another trip, without writing or broadcasting', async () => { + const create = vi.fn(); + const broadcast = vi.fn(); + const resyncReservationPrice = vi.fn(); + const linkRefusal = vi.fn().mockReturnValue('reservation_id does not belong to this trip.'); + const svc = makeService({ create, broadcast, linkRefusal, resyncReservationPrice } as Partial); + const body = { name: 'Hotel', total_price: 200, reservation_id: 4711 }; + expect(await thrownAsync(() => new BudgetController(svc).create(user, '5', body, 'sock'))).toEqual({ + status: 400, body: { error: 'reservation_id does not belong to this trip.' }, + }); + expect(linkRefusal).toHaveBeenCalledWith('5', body); + expect(create).not.toHaveBeenCalled(); + expect(resyncReservationPrice).not.toHaveBeenCalled(); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('400s on a place_id from another trip, without writing', async () => { + const create = vi.fn(); + const svc = makeService({ + create, + linkRefusal: vi.fn().mockReturnValue('place_id does not belong to this trip.'), + } as Partial); + expect(await thrownAsync(() => new BudgetController(svc).create(user, '5', { name: 'Tickets', place_id: 4711 }))).toEqual({ + status: 400, body: { error: 'place_id does not belong to this trip.' }, + }); + expect(create).not.toHaveBeenCalled(); }); }); describe('PUT /:id', () => { - it('404 when item missing', async () => { - const svc = makeService({ update: vi.fn().mockReturnValue(null) } as Partial); + it('404 when item missing, without resyncing any booking', async () => { + const resyncLinkedPrices = vi.fn(); + const svc = makeService({ update: vi.fn().mockReturnValue(null), resyncLinkedPrices } as Partial); expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Budget item not found' }, }); + expect(resyncLinkedPrices).not.toHaveBeenCalled(); }); - it('syncs the reservation price when a linked item changes total_price', async () => { - const update = vi.fn().mockReturnValue({ id: 9, reservation_id: 42, total_price: 250 }); - const syncReservationPrice = vi.fn(); + it('hands a total change to resyncLinkedPrices without snapshotting the item first', async () => { + const updated = { id: 9, reservation_id: 42, total_price: 250 }; + const update = vi.fn().mockReturnValue(updated); + const getBudgetItem = vi.fn(); + const resyncLinkedPrices = vi.fn(); const broadcast = vi.fn(); - const svc = makeService({ update, syncReservationPrice, broadcast } as Partial); - await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock'); - expect(syncReservationPrice).toHaveBeenCalledWith('5', 42, 250, 'sock'); - expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, reservation_id: 42, total_price: 250 } }, 'sock'); + const svc = makeService({ update, getBudgetItem, resyncLinkedPrices, broadcast } as Partial); + expect(await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock')).toEqual({ item: updated }); + // No link in the body, so the booking cannot have changed and the stored row is not read. + expect(getBudgetItem).not.toHaveBeenCalled(); + expect(resyncLinkedPrices).toHaveBeenCalledWith('5', undefined, updated, { total_price: 250 }, 'sock'); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: updated }, 'sock'); }); - it('does not sync when the item has no linked reservation', async () => { - const update = vi.fn().mockReturnValue({ id: 9, reservation_id: null, total_price: 250 }); - const syncReservationPrice = vi.fn(); - const svc = makeService({ update, syncReservationPrice } as Partial); - await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }); - expect(syncReservationPrice).not.toHaveBeenCalled(); + it('snapshots the item before a re-link, so the booking it leaves is resynced too', async () => { + const calls: string[] = []; + const getBudgetItem = vi.fn().mockImplementation(() => { calls.push('snapshot'); return { id: 9, reservation_id: 42 }; }); + const updated = { id: 9, reservation_id: 43, total_price: 80 }; + const update = vi.fn().mockImplementation(() => { calls.push('write'); return updated; }); + const resyncLinkedPrices = vi.fn(); + const svc = makeService({ update, getBudgetItem, resyncLinkedPrices } as Partial); + await new BudgetController(svc).update(user, '5', '9', { reservation_id: 43 }, 'sock'); + expect(getBudgetItem).toHaveBeenCalledWith('9', '5'); + expect(calls).toEqual(['snapshot', 'write']); + expect(resyncLinkedPrices).toHaveBeenCalledWith('5', 42, updated, { reservation_id: 43 }, 'sock'); + }); + + it('snapshots on an unlink (reservation_id null) as well', async () => { + const getBudgetItem = vi.fn().mockReturnValue({ id: 9, reservation_id: 42 }); + const updated = { id: 9, reservation_id: null, total_price: 80 }; + const resyncLinkedPrices = vi.fn(); + const svc = makeService({ update: vi.fn().mockReturnValue(updated), getBudgetItem, resyncLinkedPrices } as Partial); + expect(await new BudgetController(svc).update(user, '5', '9', { reservation_id: null })).toEqual({ item: updated }); + expect(resyncLinkedPrices).toHaveBeenCalledWith('5', 42, updated, { reservation_id: null }, undefined); + }); + + it('400s on a reservation_id from another trip, before reading, writing or resyncing', async () => { + const update = vi.fn(); + const getBudgetItem = vi.fn(); + const resyncLinkedPrices = vi.fn(); + const broadcast = vi.fn(); + const linkRefusal = vi.fn().mockReturnValue('reservation_id does not belong to this trip.'); + const svc = makeService({ update, getBudgetItem, resyncLinkedPrices, broadcast, linkRefusal } as Partial); + expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { reservation_id: 4711 }, 'sock'))).toEqual({ + status: 400, body: { error: 'reservation_id does not belong to this trip.' }, + }); + expect(linkRefusal).toHaveBeenCalledWith('5', { reservation_id: 4711 }); + expect(getBudgetItem).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(resyncLinkedPrices).not.toHaveBeenCalled(); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('400s on a place_id from another trip, without writing', async () => { + const update = vi.fn(); + const svc = makeService({ + update, + linkRefusal: vi.fn().mockReturnValue('place_id does not belong to this trip.'), + } as Partial); + expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { place_id: 4711 }))).toEqual({ + status: 400, body: { error: 'place_id does not belong to this trip.' }, + }); + expect(update).not.toHaveBeenCalled(); }); }); @@ -194,20 +293,34 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou // ZodValidationPipe (budgetUpdatePayersRequestSchema) before the handler runs. it('404 when the item is missing', () => { - const svc = makeService({ setPayers: vi.fn().mockReturnValue(null) } as Partial); + const resyncReservationPrice = vi.fn(); + const svc = makeService({ setPayers: vi.fn().mockReturnValue(null), resyncReservationPrice } as Partial); expect(thrown(() => new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }))).toEqual({ status: 404, body: { error: 'Budget item not found' }, }); + expect(resyncReservationPrice).not.toHaveBeenCalled(); }); it('sets payers and broadcasts budget:updated', () => { const setPayers = vi.fn().mockReturnValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] }); const broadcast = vi.fn(); - const svc = makeService({ setPayers, broadcast } as Partial); + const resyncReservationPrice = vi.fn(); + const svc = makeService({ setPayers, broadcast, resyncReservationPrice } as Partial); const res = new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }, 'sock'); expect(res).toEqual({ item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }); expect(setPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]); expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }, 'sock'); + expect(resyncReservationPrice).not.toHaveBeenCalled(); + }); + + it('resyncs the linked booking, since the payers derive the total it mirrors (#2084)', () => { + const item = { id: 9, reservation_id: 42, total_price: 30, payers: [{ user_id: 2, amount: 30 }] }; + const resyncReservationPrice = vi.fn(); + const broadcast = vi.fn(); + const svc = makeService({ setPayers: vi.fn().mockReturnValue(item), resyncReservationPrice, broadcast } as Partial); + expect(new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 30 }] }, 'sock')).toEqual({ item }); + expect(resyncReservationPrice).toHaveBeenCalledWith('5', 42, 'sock'); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item }, 'sock'); }); }); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 97ad73ae5..f293b1590 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -711,6 +711,234 @@ describe('deleting an expense takes its price off the booking (#2233)', () => { }); }); +describe('linking existing expenses to bookings and places (#2084)', () => { + function tripWithBooking(metadata: Record | null = null) { + const { user } = createUser(testDb, { username: 'owner' }); + const trip = createTrip(testDb, user.id, { title: 'Trip' }); + const reservationId = insertReservation(trip.id, 'Flight', metadata); + return { user, trip, reservationId }; + } + + function insertReservation(tripId: number, title: string, metadata: Record | null = null): number { + return Number(testDb + .prepare("INSERT INTO reservations (trip_id, title, type, metadata) VALUES (?, ?, 'flight', ?)") + .run(tripId, title, metadata ? JSON.stringify(metadata) : null).lastInsertRowid); + } + + function insertPlace(tripId: number, name = 'Louvre'): number { + return Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(tripId, name).lastInsertRowid); + } + + function metadataOf(reservationId: number): Record | null { + const row = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string | null }; + return row.metadata ? JSON.parse(row.metadata) : null; + } + + function linksOf(itemId: number) { + return testDb.prepare('SELECT reservation_id, place_id FROM budget_items WHERE id = ?').get(itemId) as { + reservation_id: number | null; place_id: number | null; + }; + } + + it('BUDGET-SVC-DB-044: an update links an existing expense, leaves the link alone when omitted, and null lets go of it', () => { + const { trip, reservationId } = tripWithBooking(); + const placeId = insertPlace(trip.id); + const item = budget.createBudgetItem(trip.id, { name: 'Tickets', total_price: 34 }); + + const linked = budget.updateBudgetItem(item.id, trip.id, { reservation_id: reservationId, place_id: placeId }); + expect(linked).toMatchObject({ reservation_id: reservationId, place_id: placeId }); + expect(linksOf(item.id)).toEqual({ reservation_id: reservationId, place_id: placeId }); + + // An edit that does not name the links keeps both. + budget.updateBudgetItem(item.id, trip.id, { name: 'Museum tickets' }); + expect(linksOf(item.id)).toEqual({ reservation_id: reservationId, place_id: placeId }); + + // null unlinks, one field at a time, and the expense itself stays. + budget.updateBudgetItem(item.id, trip.id, { reservation_id: null }); + expect(linksOf(item.id)).toEqual({ reservation_id: null, place_id: placeId }); + budget.updateBudgetItem(item.id, trip.id, { place_id: null }); + expect(linksOf(item.id)).toEqual({ reservation_id: null, place_id: null }); + expect(testDb.prepare('SELECT name, total_price FROM budget_items WHERE id = ?').get(item.id)).toEqual({ name: 'Museum tickets', total_price: 34 }); + }); + + it('BUDGET-SVC-DB-045: linkRefusal passes this trip\'s ids and names the foreign or missing one', () => { + const { user, trip, reservationId } = tripWithBooking(); + const placeId = insertPlace(trip.id); + const elsewhere = createTrip(testDb, user.id, { title: 'Elsewhere' }); + const foreignReservation = insertReservation(elsewhere.id, 'Other flight'); + const foreignPlace = insertPlace(elsewhere.id, 'Elsewhere'); + + expect(budget.linkRefusal(trip.id, {})).toBeNull(); + expect(budget.linkRefusal(trip.id, { reservation_id: null, place_id: null })).toBeNull(); + expect(budget.linkRefusal(trip.id, { reservation_id: reservationId, place_id: placeId })).toBeNull(); + // The route param arrives as a string; the lookup still matches. + expect(budget.linkRefusal(String(trip.id), { reservation_id: reservationId })).toBeNull(); + + expect(budget.linkRefusal(trip.id, { reservation_id: foreignReservation })).toBe('reservation_id does not belong to this trip.'); + expect(budget.linkRefusal(trip.id, { place_id: foreignPlace })).toBe('place_id does not belong to this trip.'); + // An id that exists nowhere is refused the same way, never a foreign-key 500. + expect(budget.linkRefusal(trip.id, { reservation_id: 999999 })).toBe('reservation_id does not belong to this trip.'); + expect(budget.linkRefusal(trip.id, { place_id: 999999 })).toBe('place_id does not belong to this trip.'); + // Both wrong: the booking is named first. + expect(budget.linkRefusal(trip.id, { reservation_id: foreignReservation, place_id: foreignPlace })) + .toBe('reservation_id does not belong to this trip.'); + }); + + it('BUDGET-SVC-DB-046: the booking mirrors the sum of its expenses while they share one currency', () => { + const { trip, reservationId } = tripWithBooking({ seat: '12A' }); + const first = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 10.1, currency: 'usd' }); + const second = budget.createBudgetItem(trip.id, { name: 'Luggage', total_price: 20.2, currency: 'usd' }); + // An expense on another booking of the same trip is not part of the sum. + const other = insertReservation(trip.id, 'Return'); + const unrelated = budget.createBudgetItem(trip.id, { name: 'Return fare', total_price: 99, currency: 'usd' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservationId, first.id, second.id); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(other, unrelated.id); + + budget.resyncReservationPrice(trip.id, reservationId); + + // Cent-clean (10.1 + 20.2 is 30.299999... in floats), upper-cased currency, + // and the rest of the metadata untouched. + expect(metadataOf(reservationId)).toEqual({ seat: '12A', price: '30.3', priceCurrency: 'USD' }); + }); + + it('BUDGET-SVC-DB-052: an expense without a currency and one in the trip currency by code add up', () => { + // No currency means the trip's; the two rows are in the same money and the + // booking shows their sum, not the first one alone. + const { trip, reservationId } = tripWithBooking(); + testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + const implicit = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 40 }); + const explicit = budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 12, currency: 'eur' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservationId, implicit.id, explicit.id); + + budget.resyncReservationPrice(trip.id, reservationId); + + // Named by its code once one of them names it. + expect(metadataOf(reservationId)).toEqual({ price: '52', priceCurrency: 'EUR' }); + }); + + it('BUDGET-SVC-DB-053: a currency code is the same currency whatever its case', () => { + const { trip, reservationId } = tripWithBooking(); + const lower = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 100, currency: 'usd' }); + const upper = budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 25, currency: 'USD' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservationId, lower.id, upper.id); + + budget.resyncReservationPrice(trip.id, reservationId); + + expect(metadataOf(reservationId)).toEqual({ price: '125', priceCurrency: 'USD' }); + }); + + it('BUDGET-SVC-DB-054: a first expense in the trip currency, beside a foreign one, is named by the trip code', () => { + const { trip, reservationId } = tripWithBooking(); + testDb.prepare("UPDATE trips SET currency = 'eur' WHERE id = ?").run(trip.id); + const implicit = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 100 }); + const foreign = budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 20, currency: 'USD' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservationId, implicit.id, foreign.id); + + budget.resyncReservationPrice(trip.id, reservationId); + // The currencies differ, so the first expense stands alone, and its implicit + // currency is spelled out so the card does not read the figure as dollars. + expect(metadataOf(reservationId)).toEqual({ price: '100', priceCurrency: 'EUR' }); + + // A trip with no currency at all leaves nothing to spell out. + testDb.prepare('UPDATE trips SET currency = NULL WHERE id = ?').run(trip.id); + budget.resyncReservationPrice(trip.id, reservationId); + expect(metadataOf(reservationId)).toEqual({ price: '100' }); + }); + + it('BUDGET-SVC-DB-047: expenses in different currencies mirror the first one alone, in its currency', () => { + const { trip, reservationId } = tripWithBooking(); + const first = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 120, currency: 'CHF' }); + const second = budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 15, currency: 'EUR' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservationId, first.id, second.id); + + budget.resyncReservationPrice(trip.id, reservationId); + + expect(metadataOf(reservationId)).toEqual({ price: '120', priceCurrency: 'CHF' }); + }); + + it('BUDGET-SVC-DB-048: expenses in the trip currency sum up and carry no currency of their own', () => { + // An imported booking can carry a currency stamped by the importer; once the + // mirror comes from expenses in the trip's own currency, that stamp goes. + const { trip, reservationId } = tripWithBooking({ price: '5', priceCurrency: 'CNY' }); + const first = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 40 }); + const second = budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 2.5 }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservationId, first.id, second.id); + + budget.resyncReservationPrice(trip.id, reservationId); + + expect(metadataOf(reservationId)).toEqual({ price: '42.5' }); + }); + + it('BUDGET-SVC-DB-049: a booking with nothing linked loses its price and currency, and nothing else', () => { + const { trip, reservationId } = tripWithBooking({ seat: '12A', price: '30', priceCurrency: 'USD' }); + + budget.resyncReservationPrice(trip.id, reservationId); + expect(metadataOf(reservationId)).toEqual({ seat: '12A' }); + + // A booking without a price, or without metadata at all, is not rewritten. + const bare = insertReservation(trip.id, 'Bare'); + budget.resyncReservationPrice(trip.id, bare); + expect(metadataOf(bare)).toBeNull(); + }); + + it('BUDGET-SVC-DB-050: deleting one of two expenses leaves the booking with the other one\'s total', () => { + const { trip, reservationId } = tripWithBooking({ price: '30' }); + const first = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 25 }); + const second = budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 5 }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservationId, first.id, second.id); + + expect(budget.deleteBudgetItem(first.id, trip.id)).toBe(true); + // Before #2084 the price was dropped outright, though an expense still stood behind it. + expect(metadataOf(reservationId)).toEqual({ price: '5' }); + + expect(budget.deleteBudgetItem(second.id, trip.id)).toBe(true); + expect(metadataOf(reservationId)).toEqual({}); + }); + + it('BUDGET-SVC-DB-051: moving an expense to another booking moves its share of the price along', () => { + const { trip, reservationId: from } = tripWithBooking(); + const to = insertReservation(trip.id, 'Return'); + const stays = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 100 }); + const moves = budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 20 }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(from, stays.id, moves.id); + budget.resyncReservationPrice(trip.id, from); + expect(metadataOf(from)).toEqual({ price: '120' }); + + const before = budget.getBudgetItem(moves.id, trip.id)!; + const data = { reservation_id: to }; + const updated = budget.updateBudgetItem(moves.id, trip.id, data)!; + budget.resyncLinkedPrices(trip.id, before.reservation_id, updated, data); + + expect(metadataOf(from)).toEqual({ price: '100' }); + expect(metadataOf(to)).toEqual({ price: '20' }); + + // And letting go of the link takes the price off the booking it left. + const unlink = { reservation_id: null }; + const unlinked = budget.updateBudgetItem(moves.id, trip.id, unlink)!; + budget.resyncLinkedPrices(trip.id, to, unlinked, unlink); + expect(metadataOf(to)).toEqual({}); + expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(moves.id)).toEqual({ id: moves.id }); + }); + + it('BUDGET-SVC-DB-055: an edit that only changes the currency of a linked expense re-names the booking price', () => { + const { trip, reservationId } = tripWithBooking(); + const item = budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 80 }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id); + budget.resyncReservationPrice(trip.id, reservationId); + expect(metadataOf(reservationId)).toEqual({ price: '80' }); + + // Shaped like the route body, which names neither the total nor the link here. + type Body = { currency: string | null; total_price?: number; reservation_id?: number | null }; + const toChf: Body = { currency: 'chf' }; + budget.resyncLinkedPrices(trip.id, undefined, budget.updateBudgetItem(item.id, trip.id, toChf)!, toChf); + expect(metadataOf(reservationId)).toEqual({ price: '80', priceCurrency: 'CHF' }); + + const back: Body = { currency: null }; + budget.resyncLinkedPrices(trip.id, undefined, budget.updateBudgetItem(item.id, trip.id, back)!, back); + expect(metadataOf(reservationId)).toEqual({ price: '80' }); + }); +}); + describe('an expense nobody paid stays out of the ledger (#2225)', () => { it('BUDGET-SVC-DB-037: an unpaid expense moves neither the balances nor the offered flows', () => { const { user: alice } = createUser(testDb, { username: 'alice' }); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index f7317cc7b..4e3c17383 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -212,5 +212,78 @@ describe('BudgetService', () => { expect(() => svc().syncReservationPrice('5', 42, 250, 'sock')).not.toThrow(); expect(broadcast).not.toHaveBeenCalled(); }); + + // #2084: the card names the currency beside the mirrored figure. + it('writes the currency beside the price, upper-cased', () => { + dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"seat":"1A"}' }).mockReturnValueOnce({ id: 42 }); + svc().syncReservationPrice('5', 42, 99.5, 'sock', 'usd'); + expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ seat: '1A', price: '99.5', priceCurrency: 'USD' }); + }); + + it('drops a stored currency when the expense is in the trip currency (null)', () => { + dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }).mockReturnValueOnce({ id: 42 }); + svc().syncReservationPrice('5', 42, 12, undefined, null); + expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ price: '12' }); + }); + + it('leaves a stored currency alone when none is passed at all', () => { + dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }).mockReturnValueOnce({ id: 42 }); + svc().syncReservationPrice('5', 42, 12, undefined); + expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ price: '12', priceCurrency: 'CNY' }); + }); + }); + + describe('resyncLinkedPrices', () => { + // Which bookings an update touches is pure bookkeeping over the before/after + // link and the body; the SQL behind resyncReservationPrice is pinned in + // budget.service.db.test.ts. + function resynced( + previous: number | null | undefined, + updated: { reservation_id?: number | null }, + data: { total_price?: number; reservation_id?: number | null }, + socketId?: string, + ) { + const s = svc(); + const spy = vi.spyOn(s, 'resyncReservationPrice').mockImplementation(() => {}); + s.resyncLinkedPrices('5', previous, updated, data, socketId); + return spy.mock.calls; + } + + it('resyncs the linked booking when the total changes', () => { + expect(resynced(undefined, { reservation_id: 42 }, { total_price: 250 }, 'sock')).toEqual([['5', 42, 'sock']]); + }); + + it('resyncs the linked booking on an edit that names neither the total nor the link', () => { + // A new currency, or payers that derive a new total, move the booking's + // price too, and neither shows up in the two fields this looks at. + expect(resynced(undefined, { reservation_id: 42 }, {}, 'sock')).toEqual([['5', 42, 'sock']]); + }); + + it('leaves every booking alone when the expense is linked to none', () => { + expect(resynced(undefined, { reservation_id: null }, { total_price: 250 })).toEqual([]); + expect(resynced(undefined, {}, {})).toEqual([]); + }); + + it('resyncs both the booking left and the booking joined on a re-link', () => { + expect(resynced(42, { reservation_id: 43 }, { reservation_id: 43 }, 'sock')).toEqual([['5', 42, 'sock'], ['5', 43, 'sock']]); + }); + + it('resyncs only the booking left on an unlink', () => { + expect(resynced(42, { reservation_id: null }, { reservation_id: null })).toEqual([['5', 42, undefined]]); + }); + + it('resyncs only the booking joined when the expense had none before', () => { + expect(resynced(null, { reservation_id: 43 }, { reservation_id: 43, total_price: 10 })).toEqual([['5', 43, undefined]]); + }); + + it('resyncs a booking once when the expense is re-linked to the one it already had', () => { + expect(resynced(42, { reservation_id: 42 }, { reservation_id: 42, total_price: 10 })).toEqual([['5', 42, undefined]]); + }); + + it('ignores the stored link when the body does not name one', () => { + // A total change on an expense that stays put must not touch some other booking + // the caller happened to pass as the previous one. + expect(resynced(41, { reservation_id: 42 }, { total_price: 10 })).toEqual([['5', 42, undefined]]); + }); }); }); diff --git a/server/tests/unit/nest/reservations.controller.test.ts b/server/tests/unit/nest/reservations.controller.test.ts index bd21888d2..0f0775814 100644 --- a/server/tests/unit/nest/reservations.controller.test.ts +++ b/server/tests/unit/nest/reservations.controller.test.ts @@ -176,18 +176,44 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese describe('DELETE /:id', () => { it('404 when nothing deleted', () => { - const svc = makeService({ remove: vi.fn().mockReturnValue({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }) } as Partial); + const broadcast = vi.fn(); + const svc = makeService({ + remove: vi.fn().mockReturnValue({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] }), + broadcast, + } as Partial); expect(thrown(() => new ReservationsController(svc, airtrailLink).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Reservation not found' } }); + expect(broadcast).not.toHaveBeenCalled(); }); it('broadcasts the accommodation + budget cascade then reservation:deleted', () => { - const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: 3 }, accommodationDeleted: true, deletedBudgetItemId: 7 }); + const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: 3 }, accommodationDeleted: true, deletedBudgetItemId: 7, deletedBudgetItemIds: [7] }); const broadcast = vi.fn(); const notifyBookingChange = vi.fn(); const svc = makeService({ remove, broadcast, notifyBookingChange } as Partial); expect(new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 3 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 9 }, 'sock'); + expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging'); + }); + + it('announces every expense the booking took with it, before the booking itself (#2084)', () => { + const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Flight', type: 'flight', accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: 7, deletedBudgetItemIds: [7, 8] }); + const broadcast = vi.fn(); + const svc = makeService({ remove, broadcast } as Partial); + new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock'); + expect(broadcast.mock.calls).toEqual([ + ['5', 'budget:deleted', { itemId: 7 }, 'sock'], + ['5', 'budget:deleted', { itemId: 8 }, 'sock'], + ['5', 'reservation:deleted', { reservationId: 9 }, 'sock'], + ]); + }); + + it('announces no expense when the booking carried none', () => { + const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Museum', type: 'activity', accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] }); + const broadcast = vi.fn(); + const svc = makeService({ remove, broadcast } as Partial); + new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock'); + expect(broadcast.mock.calls).toEqual([['5', 'reservation:deleted', { reservationId: 9 }, 'sock']]); }); }); }); diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index a43cece1e..1f894a052 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -287,6 +287,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { expect(result.deleted).toMatchObject({ id: res.id, title: 'Hotel', type: 'hotel' }); expect(result.accommodationDeleted).toBe(true); expect(result.deletedBudgetItemId).toBe(item.id); + expect(result.deletedBudgetItemIds).toEqual([item.id]); expect(testDb.prepare('SELECT COUNT(*) as c FROM reservations WHERE id = ?').get(res.id)).toEqual({ c: 0 }); expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE id = ?').get(acc.id)).toEqual({ c: 0 }); expect(testDb.prepare('SELECT COUNT(*) as c FROM budget_items WHERE id = ?').get(item.id)).toEqual({ c: 0 }); @@ -294,7 +295,34 @@ describe('ReservationsService (DI-native, real SQL)', () => { it('RESV-SVC-012: returns the empty shape when the reservation is missing', () => { const { trip } = ownerTrip(); - expect(svc.remove('999', String(trip.id))).toEqual({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }); + expect(svc.remove('999', String(trip.id))).toEqual({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] }); + }); + + it('RESV-SVC-037: takes every linked expense with it and reports each one (#2084)', () => { + const { trip } = ownerTrip(); + const res = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' }); + const fare = createBudgetItem(testDb, trip.id, { name: 'Fare' }); + const luggage = createBudgetItem(testDb, trip.id, { name: 'Luggage' }); + const unlinked = createBudgetItem(testDb, trip.id, { name: 'Coffee' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(res.id, fare.id, luggage.id); + + const result = svc.remove(String(res.id), String(trip.id)); + + expect(result.deletedBudgetItemIds).toEqual([fare.id, luggage.id]); + // The single-id field stays for older readers, as the first of them. + expect(result.deletedBudgetItemId).toBe(fare.id); + expect(testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ? ORDER BY id').all(trip.id)).toEqual([{ id: unlinked.id }]); + }); + + it('RESV-SVC-038: a booking without expenses reports none and deletes no expense', () => { + const { trip } = ownerTrip(); + const res = createReservation(testDb, trip.id); + const unlinked = createBudgetItem(testDb, trip.id); + + const result = svc.remove(String(res.id), String(trip.id)); + + expect(result).toMatchObject({ accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] }); + expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(unlinked.id)).toEqual({ id: unlinked.id }); }); }); @@ -566,6 +594,69 @@ describe('ReservationsService (DI-native, real SQL)', () => { expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: item.id, category: 'flights' } }, 'sock'); }); + it('RESV-SVC-039: a type change re-files every linked expense still on the derived category, one broadcast each (#2084)', () => { + const { trip, res, item: fare } = linkedItem({ name: 'Fare', category: 'transport' }); + const luggage = createBudgetItem(testDb, trip.id, { name: 'Luggage', category: 'transport' }); + const picked = createBudgetItem(testDb, trip.id, { name: 'Lounge', category: 'food' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(res.id, luggage.id, picked.id); + budget.updateBudgetItem.mockImplementation((id: number) => ({ id, category: 'flights' })); + + // train -> flight: transport -> flights. + svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'train', undefined, 'sock'); + + expect(budget.updateBudgetItem.mock.calls).toEqual([ + [fare.id, String(trip.id), { category: 'flights' }], + [luggage.id, String(trip.id), { category: 'flights' }], + ]); + // The hand-picked category survives. + expect(budget.updateBudgetItem).not.toHaveBeenCalledWith(picked.id, expect.anything(), expect.anything()); + expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: fare.id, category: 'flights' } }, 'sock'); + expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: luggage.id, category: 'flights' } }, 'sock'); + expect(broadcast).toHaveBeenCalledTimes(2); + }); + + it('RESV-SVC-041: with several expenses linked, the booking price field touches none of them (#2084)', () => { + const { trip, res, item: fare } = linkedItem({ name: 'Fare', total_price: 100 }); + const seat = createBudgetItem(testDb, trip.id, { name: 'Seat', total_price: 20 }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, seat.id); + + // A price speaks for one expense; with two there is no telling which, so + // neither a new figure nor a clear may land on an arbitrary one of them. + svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'flight', { total_price: 500 }, 'sock'); + svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'flight', { total_price: 0 }, 'sock'); + + expect(budget.updateBudgetItem).not.toHaveBeenCalled(); + expect(budget.deleteBudgetItem).not.toHaveBeenCalled(); + expect(budget.createBudgetItem).not.toHaveBeenCalled(); + expect(broadcast).not.toHaveBeenCalled(); + expect(testDb.prepare('SELECT id, total_price FROM budget_items WHERE reservation_id = ? ORDER BY id').all(res.id)) + .toEqual([{ id: fare.id, total_price: 100 }, { id: seat.id, total_price: 20 }]); + }); + + it('RESV-SVC-042: the type-change re-file still runs when several expenses stop the price path', () => { + const { trip, res, item: fare } = linkedItem({ name: 'Fare', category: 'transport' }); + const seat = createBudgetItem(testDb, trip.id, { name: 'Seat', category: 'transport' }); + testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, seat.id); + budget.updateBudgetItem.mockImplementation((id: number) => ({ id, category: 'flights' })); + + svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'train', { total_price: 500 }, 'sock'); + + // Only the two category writes, never the price. + expect(budget.updateBudgetItem.mock.calls).toEqual([ + [fare.id, String(trip.id), { category: 'flights' }], + [seat.id, String(trip.id), { category: 'flights' }], + ]); + expect(budget.createBudgetItem).not.toHaveBeenCalled(); + }); + + it('RESV-SVC-040: a type change that keeps the derived category touches no linked expense', () => { + const { trip, res } = linkedItem({ category: 'transport' }); + // train -> bus: both file under transport. + svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'bus', 'X', 'train', undefined, 'sock'); + expect(budget.updateBudgetItem).not.toHaveBeenCalled(); + expect(broadcast).not.toHaveBeenCalled(); + }); + it('updates an existing linked item when a price is provided', () => { const { trip, res, item } = linkedItem(); budget.updateBudgetItem.mockReturnValue({ id: item.id }); diff --git a/server/tests/unit/plugins/addon-gates.rpc.test.ts b/server/tests/unit/plugins/addon-gates.rpc.test.ts index a0a8ebb7d..953744d88 100644 --- a/server/tests/unit/plugins/addon-gates.rpc.test.ts +++ b/server/tests/unit/plugins/addon-gates.rpc.test.ts @@ -39,6 +39,8 @@ function spyService(calls: string[], name: string) { if (prop === 'votePoll' || prop === 'createMessage') return { error: null, poll: {}, message: {} }; if (prop === 'listEntries') return []; if (prop === 'getActivePlanId') return 1; + // A cost write asks whether its links may be made; null is "yes" (#2084). + if (prop === 'linkRefusal') return null; // collections.deletePlace is async in production (it deletes a storage // object); a promise-returning double here pins the fix that awaits it — // an un-awaited call would leave an unhandled rejection unnoticed by every diff --git a/server/tests/unit/plugins/rpc-fallbacks.test.ts b/server/tests/unit/plugins/rpc-fallbacks.test.ts index d3bb06b3a..1cece291b 100644 --- a/server/tests/unit/plugins/rpc-fallbacks.test.ts +++ b/server/tests/unit/plugins/rpc-fallbacks.test.ts @@ -180,7 +180,7 @@ describe('service errors are translated into the RPC taxonomy', () => { const { guards } = guardsFor(); const notifyBookingChange = vi.fn(); const reservations = { - remove: vi.fn(() => ({ deleted: { title: 'Hotel', type: null, accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null })), + remove: vi.fn(() => ({ deleted: { title: 'Hotel', type: null, accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] })), notifyBookingChange, } as never; const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new ReservationsRpc(reservations, realtime(), guards)])); diff --git a/server/tests/unit/reservations/bookings.rpc.test.ts b/server/tests/unit/reservations/bookings.rpc.test.ts index 0cbac2d25..c96345b90 100644 --- a/server/tests/unit/reservations/bookings.rpc.test.ts +++ b/server/tests/unit/reservations/bookings.rpc.test.ts @@ -30,16 +30,18 @@ const req = (method: string, params: Record = {}): RpcRequest = const events = (r: { broadcast: ReturnType }) => r.broadcast.mock.calls.map((c) => c[1]); /** Trip 1 belongs to user 42; reservation 5 and accommodation 11 sit on it. */ -function build(opts: { canEdit?: boolean; cascade?: boolean; stop?: boolean; seenActions?: string[]; unresolved?: string[]; foreign?: string[] } = {}) { +function build(opts: { canEdit?: boolean; cascade?: boolean; stop?: boolean; seenActions?: string[]; unresolved?: string[]; foreign?: string[]; expenses?: number[] } = {}) { const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; + // The expenses a delete takes with it: one with `cascade`, any number with `expenses` (#2084). + const expenses = opts.expenses ?? (opts.cascade ? [7] : []); const reservations = { create: vi.fn(() => ({ reservation: { id: 40 }, accommodationCreated: !!opts.cascade })), getReservation: vi.fn((id: string) => (id === '5' ? { id: 5, title: 'Hotel', type: 'lodging' } : undefined)), update: vi.fn(() => ({ reservation: { id: 5 }, accommodationChanged: !!opts.cascade })), remove: vi.fn((id: string) => id === '5' - ? { deleted: { title: 'Hotel', type: 'lodging', accommodation_id: opts.cascade ? 11 : null }, accommodationDeleted: !!opts.cascade, deletedBudgetItemId: opts.cascade ? 7 : null } - : { deleted: null, accommodationDeleted: false, deletedBudgetItemId: null }, + ? { deleted: { title: 'Hotel', type: 'lodging', accommodation_id: opts.cascade ? 11 : null }, accommodationDeleted: !!opts.cascade, deletedBudgetItemId: expenses[0] ?? null, deletedBudgetItemIds: expenses } + : { deleted: null, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] }, ), syncBudgetOnCreate: vi.fn(), syncBudgetOnUpdate: vi.fn(), @@ -145,6 +147,16 @@ describe('ReservationsRpc', () => { expect(events(f.realtime)).toEqual(['accommodation:deleted', 'budget:deleted', 'reservation:deleted']); }); + it('BOOK-RPC-021 a booking carrying several expenses announces each one it took with it (#2084)', async () => { + const f = build({ expenses: [7, 8] }); + await f.host().dispatch(req('reservations.delete', { tripId: 1, reservationId: 5 }), 42); + expect(f.realtime.broadcast.mock.calls).toEqual([ + [1, 'budget:deleted', { itemId: 7 }, undefined], + [1, 'budget:deleted', { itemId: 8 }, undefined], + [1, 'reservation:deleted', { reservationId: 5 }, undefined], + ]); + }); + it('BOOK-RPC-007 the budget sync and the booking notification both run', async () => { const f = build(); await f.host().dispatch(req('reservations.create', { tripId: 1, input: { title: 'Hotel', type: 'lodging' } }), 42); diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index 1e7978014..d3c0f4caf 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -195,6 +195,10 @@ export const budgetUpdateItemRequestSchema = z.object({ ticket_json: z.string().nullable().optional(), expense_date: z.string().nullable().optional(), receipt_file_ids: z.array(z.number()).optional(), + // Link an existing expense to a booking or a place on the same trip (#2084); + // null lets go of the link and keeps the expense. Omitted leaves it as is. + reservation_id: z.number().int().positive().nullable().optional(), + place_id: z.number().int().positive().nullable().optional(), }); export type BudgetUpdateItemRequest = z.infer; diff --git a/shared/src/i18n/ar/help.ts b/shared/src/i18n/ar/help.ts index 1e1a46197..5f08a144f 100644 --- a/shared/src/i18n/ar/help.ts +++ b/shared/src/i18n/ar/help.ts @@ -1682,7 +1682,7 @@ const help: TranslationStrings = { 'انقر على «إضافة». إذا كان في الرحلة مكان بالاسم نفسه، فسيقول النموذج ذلك ويتحوّل الزر إلى «الإضافة على أي حال».', 'help.guide.create-place.result': 'المكان في القائمة وعلى الخريطة، تحت «غير مخطط» إلى أن يوضع في يوم.', 'help.guide.create-place.tip.1': - '«الملفات» و«Costs» أسفل النموذج يرفقان مستندًا بالمكان، أو يفتحان محرر «Costs» لمصروفه بعد الحفظ مباشرة.', + '«الملفات» و«التكاليف» أسفل النموذج يرفقان مستندًا بالمكان، أو يفتحان محرر «التكاليف» لمصروفه بعد الحفظ مباشرة.', 'help.guide.create-place.tip.2': 'فهرس TREK و OpenStreetMap يجيبان على البحث في كل TREK، و«تفاصيل المكان» يملأ نفسه من Wikipedia و Wikivoyage و Wikimedia. ولا يُسأل Google إلا حيث يعود الاثنان فارغين، وهو وحده يجلب التقييمات.', 'help.guide.create-place.tip.3': @@ -2894,7 +2894,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.goal': 'أدخل مطعمًا أو فعالية أو جولة أو موقف سيارات أو أي شيء آخر إلى الرحلة يدويًا.', 'help.guide.create-booking.step.1': 'انقر على «حجز يدوي» في أعلى يمين علامة التبويب. يُفتح «حجز جديد».', 'help.guide.create-booking.step.2': - 'اختر «نوع الحجز». «إقامة» و«مطعم» و«فعالية» و«جولة» و«موقف سيارات» و«أخرى» هي الأنواع الستة التي تنشئها علامة التبويب هذه، ويتغير النموذج مع الاختيار: «إقامة» وحدها تستبدل بتواريخها مدى من الأيام.', + 'اختر «نوع الحجز» من القائمة أعلى النموذج، بجانب «المسافرون». «إقامة» و«مطعم» و«فعالية» و«جولة» و«موقف سيارات» و«أخرى» هي الأنواع الستة التي تنشئها علامة التبويب هذه، ويتغير النموذج مع الاختيار: «إقامة» وحدها تستبدل بتواريخها مدى من الأيام.', 'help.guide.create-booking.step.3': 'اكتب «العنوان». هو الحقل الوحيد الذي يصر عليه النموذج، ويبقى «إضافة» معطلًا إلى أن يحمل شيئًا.', 'help.guide.create-booking.step.4': @@ -2947,9 +2947,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'تحديد لمن الحجز', 'help.guide.booking-travelers.goal': 'علّم المسافرين الذين يشملهم الحجز، ثم اعرض حجوزاتهم وحدها.', 'help.guide.booking-travelers.step.1': - 'افتح الحجز بقلم الرصاص وانزل إلى «المسافرون». يقف هناك كل عضو في الرحلة، ومعهم كل «ضيف» له اسم.', + 'افتح الحجز بقلم الرصاص. يقع «المسافرون» أعلى النموذج، بجانب «نوع الحجز»، ويعرض «تعيين المسافرين» ما دام لا أحد في الحجز.', 'help.guide.booking-travelers.step.2': - 'انقر على الأشخاص الذين لهم هذا الحجز. يحصل المختار على علامة صح؛ وانقر عليه ثانية لإزالتها.', + 'انقر عليه واختر الأشخاص الذين لهم هذا الحجز؛ وكل «ضيف» له اسم موجود في القائمة أيضًا. يحصل المختار على علامة صح وتظهر صورته الرمزية في الحقل. وانقر على الاسم ثانية لإزالته.', 'help.guide.booking-travelers.step.3': 'انقر على «تحديث».', 'help.guide.booking-travelers.step.4': 'في شريط الأدوات أعلى الصفحة، بجانب شرائح الأنواع، انقر على الصورة الرمزية لمسافر لترى حجوزاته وحدها.', @@ -2978,20 +2978,21 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'تحويل سعر الحجز إلى تكلفة', 'help.guide.booking-cost.goal': 'انقل ما يكلفه حجز إلى «التكاليف»، مقسومًا بين من يدفعونه.', 'help.guide.booking-cost.step.1': - 'افتح الحجز وانزل إلى أسفل النموذج. تحت «Costs» يقف «Create expense»، ومعه الملاحظة «Saves the booking, then opens the Costs editor.»', - 'help.guide.booking-cost.step.2': 'انقر على «Create expense». يُحفظ الحجز ويُغلق نموذجه ويُفتح محرر التكاليف.', + 'افتح الحجز وانزل إلى أسفل النموذج. تحت «التكاليف» يقف «إنشاء مصروف» و«ربط مصروف موجود»، ومعهما الملاحظة «يحفظ الحجز ثم يفتح محرر التكاليف.»', + 'help.guide.booking-cost.step.2': 'انقر على «إنشاء مصروف». يُحفظ الحجز ويُغلق نموذجه ويُفتح محرر التكاليف.', 'help.guide.booking-cost.step.3': '«لأجل ماذا كان؟» هو أصلًا عنوان الحجز. أدخل «المبلغ الإجمالي» وتحقق من «العملة» و«اليوم».', 'help.guide.booking-cost.step.4': '«الفئة» هي التي يقتضيها نوع الحجز. حدّد «من دفع؟» وطريقة تقسيم المبلغ عبر «Split».', 'help.guide.booking-cost.step.5': 'انقر على «إضافة مصروف».', 'help.guide.booking-cost.result': - 'يعرض نموذج الحجز الآن «Linked expense» مع المبلغ، ويقف المصروف نفسه في علامة تبويب «التكاليف» مرتبطًا بهذا الحجز.', + 'يعرض نموذج الحجز الآن المصروف تحت «المصروفات المرتبطة» مع مبلغه، ويقف المصروف نفسه في علامة تبويب «التكاليف» مرتبطًا بهذا الحجز.', 'help.guide.booking-cost.tip.1': 'تتبع الفئة النوع: «مطعم» يصير «الطعام والشراب»، و«إقامة» تصير «الإقامة»، و«موقف سيارات» يصير «موقف سيارات»، أما «فعالية» و«جولة» فتحطان كلتاهما في «أخرى».', - 'help.guide.booking-cost.tip.2': 'سلة المهملات على «Linked expense» تزيل المصروف لا الحجز.', + 'help.guide.booking-cost.tip.2': + 'يمكن أن يحمل الحجز عدة مصروفات. يعرض «ربط مصروف موجود» المصروفات التي في «التكاليف» ولا تتبع شيئًا بعد. وعلى المصروف المرتبط، يفكه «إلغاء الربط، مع الإبقاء على المصروف» ويتركه في «التكاليف»، بينما تزيله سلة المهملات.', 'help.guide.booking-cost.tip.3': - 'لا يوجد «Costs» في النموذج إلا ما دامت إضافة «التكاليف» مشغّلة، وهي التي يبدّلها المدير تحت «الإضافات».', + 'لا يوجد قسم «التكاليف» في النموذج إلا ما دامت إضافة «التكاليف» مشغّلة، وهي التي يبدّلها المدير تحت «الإضافات».', // filter-bookings 'help.guide.filter-bookings.title': 'العثور على حجز', 'help.guide.filter-bookings.goal': 'احصر علامة تبويب طويلة في النوع أو الشخص أو الحالة التي تبحث عنها.', @@ -3057,7 +3058,7 @@ const help: TranslationStrings = { 'المستندات التي أُرفقت تبقى في علامة تبويب «الملفات» للرحلة؛ ولا يزول إلا ارتباطها بالحجز.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'كل حجز عُثر عليه يُفتح في «حجز جديد»، واحدًا تلو الآخر، معبّأً سلفًا. وللفندق يعني ذلك الاسم في «العنوان»، وتحت «الإقامة» حين يكون المكان في الرحلة، و«الموقع / العنوان»، و«من» و«إلى» على لياليه، و«تسجيل الوصول» و«تسجيل المغادرة»، و«رمز الحجز»، والتأكيد تحت «الملفات»، ومع تشغيل «التكاليف» السعر بوصفه «Linked expense». تحقق منه وانقر على «إضافة».', + 'كل حجز عُثر عليه يُفتح في «حجز جديد»، واحدًا تلو الآخر، معبّأً سلفًا. وللفندق يعني ذلك الاسم في «العنوان»، وتحت «الإقامة» حين يكون المكان في الرحلة، و«الموقع / العنوان»، و«من» و«إلى» على لياليه، و«تسجيل الوصول» و«تسجيل المغادرة»، و«رمز الحجز»، والتأكيد تحت «الملفات»، ومع تشغيل «التكاليف» السعر بوصفه «مصروف مرتبط». تحقق منه وانقر على «إضافة».', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'التكاليف', @@ -3217,16 +3218,16 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.goal': 'ألحق ما كلّفته رحلة جوية أو فندق أو مكان فعلًا بالسجل الذي ينتمي إليه.', 'help.guide.expense-from-booking.step.1': 'افتح الحجز في تبويب «المواصلات» أو «الحجوزات» وانقر على قلمه.', 'help.guide.expense-from-booking.step.2': - 'انزل إلى كتلة Costs في أسفل النموذج. ومع عدم وجود شيء مرتبط بعد، تعرض Create expense وتقول إنها تحفظ الحجز أولًا.', + 'انزل إلى كتلة «التكاليف» في أسفل النموذج. تعرض «إنشاء مصروف»، التي تحفظ الحجز أولًا، و«ربط مصروف موجود» لمصروف موجود بالفعل في «التكاليف».', 'help.guide.expense-from-booking.step.3': - 'انقر على Create expense. يُحفظ الحجز، ويُغلق النموذج، ويُفتح محرر التكاليف وعنوان الحجز اسمًا له ونوعه مطابَق سلفًا لفئة.', + 'انقر على «إنشاء مصروف». يُحفظ الحجز، ويُغلق النموذج، ويُفتح محرر التكاليف وعنوان الحجز اسمًا له ونوعه مطابَق سلفًا لفئة.', 'help.guide.expense-from-booking.step.4': - 'املأ المبلغ وعملته ومن دفع والتقسيم كما في أي مصروف، ثم احفظ. وإعادة فتح الحجز تُظهره الآن تحت Linked expense، ومعه قلم لتعديله وسلة مهملات لإزالته.', + 'املأ المبلغ وعملته ومن دفع والتقسيم كما في أي مصروف، ثم احفظ. وإعادة فتح الحجز تُظهره الآن تحت «المصروفات المرتبطة»، ومعه قلم لتعديله و«إلغاء الربط، مع الإبقاء على المصروف» لفكه وسلة مهملات لإزالته.', 'help.guide.expense-from-booking.result': 'يحمل الحجز تكلفته، والمصروف صف عادي في تبويب «التكاليف»، له دافع وتقسيم وعملة كأي مصروف آخر.', 'help.guide.expense-from-booking.tip.1': - 'حذف الحجز يحذف معه المصروف المرتبط به. أما Remove expense في كتلة Costs الخاصة بالحجز فتفعل العكس: يذهب المصروف ويبقى الحجز.', - 'help.guide.expense-from-booking.tip.2': 'للمكان الكتلة نفسها في نموذجه، وفيه Create expense يحفظ المكان أولًا.', + 'حذف الحجز يحذف معه المصروفات المرتبطة به. أما «إزالة المصروف» في كتلة «التكاليف» الخاصة بالحجز فتفعل العكس: يذهب المصروف ويبقى الحجز. و«إلغاء الربط، مع الإبقاء على المصروف» يُبقي على الاثنين.', + 'help.guide.expense-from-booking.tip.2': 'للمكان الكتلة نفسها في نموذجه، وفيه «إنشاء مصروف» يحفظ المكان أولًا.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'المواصلات', @@ -3281,7 +3282,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'يهبط الصف حيث يضعه وقت بدايته، بعد آخر محطة تبدأ قبله. ومقبض السحب فيه ينقله إلى أي مكان آخر في اليوم، أو إلى يوم آخر.', 'help.guide.add-transport.tip.2': - '«إرفاق ملف» تحت «الملفات» يأخذ التذكرة، و«Create expense» تحت «Costs» يحفظ الحجز ويفتح محرر «التكاليف» لثمن الرحلة.', + '«إرفاق ملف» تحت «الملفات» يأخذ التذكرة، و«إنشاء مصروف» تحت «التكاليف» يحفظ الحجز ويفتح محرر «التكاليف» لثمن الرحلة.', 'help.guide.add-transport.tip.3': '«المسافرون» يحدد من على متن هذه الرحلة. وما إن تحمل وسيلة نقل واحدة مسافرين، حتى يضيف شريط أدوات التبويب صورهم ويصفّي القائمة بها.', // plan-transit diff --git a/shared/src/i18n/ar/reservations.ts b/shared/src/i18n/ar/reservations.ts index 22b6637d4..ce24ec4ab 100644 --- a/shared/src/i18n/ar/reservations.ts +++ b/shared/src/i18n/ar/reservations.ts @@ -164,11 +164,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.', 'reservations.airtrail.importCta': 'استيراد {count}', 'reservations.airtrail.joinConnection': 'الاستيراد كرحلة واحدة مع توقف في {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'التكاليف', + 'reservations.createExpense': 'إنشاء مصروف', + 'reservations.createExpenseHint': 'يحفظ الحجز ثم يفتح محرر التكاليف.', + 'reservations.linkedExpense': 'مصروف مرتبط', + 'reservations.removeExpense': 'إزالة المصروف', + 'reservations.linkedExpenses': 'المصروفات المرتبطة', + 'reservations.linkExpense': 'ربط مصروف موجود', + 'reservations.unlinkExpense': 'إلغاء الربط، مع الإبقاء على المصروف', + 'reservations.noUnlinkedExpenses': 'لا توجد مصروفات غير مرتبطة', 'reservations.urlLabel': 'رابط', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'المسافرون', diff --git a/shared/src/i18n/az/help.ts b/shared/src/i18n/az/help.ts index 568c7ff0e..c5346fa10 100644 --- a/shared/src/i18n/az/help.ts +++ b/shared/src/i18n/az/help.ts @@ -2939,7 +2939,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Bölmənin sağ yuxarısında Əl ilə rezervasiya et düyməsinə klikləyin. Yeni rezervasiya açılır.', 'help.guide.create-booking.step.2': - 'Rezervasiya növü seçin. Bu bölmənin yaratdığı altı növ Yaşayış yeri, Restoran, Tədbir, Tur, Parkinq və Digərdir, forma isə seçimə uyğun dəyişir: yalnız Yaşayış yeri tarixlər əvəzinə günlər aralığı istifadə edir.', + 'Rezervasiya növü formanın yuxarısındakı siyahıdan, Səyahətçilər yanında seçin. Bu bölmənin yaratdığı altı növ Yaşayış yeri, Restoran, Tədbir, Tur, Parkinq və Digərdir, forma isə seçimə uyğun dəyişir: yalnız Yaşayış yeri tarixlər əvəzinə günlər aralığı istifadə edir.', 'help.guide.create-booking.step.3': 'Başlığı yazın. Bu, formanın tələb etdiyi yeganə sahədir və o doldurulmayana qədər Əlavə et işləmir.', 'help.guide.create-booking.step.4': @@ -2997,9 +2997,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Rezervasiyanın əhatə etdiyi səyahətçiləri qeyd edin və sonra yalnız onlarınkını görün.', 'help.guide.booking-travelers.step.1': - 'Rezervasiyanı karandaşla açın və Səyahətçilər bölməsinə enin. Səyahətin bütün üzvləri, o cümlədən adlı qonaqlar orada göstərilir.', + 'Rezervasiyanı karandaşla açın. Səyahətçilər formanın yuxarısında, Rezervasiya növü yanında yerləşir və rezervasiyada heç kim olmadıqca Səyahətçiləri təyin et yazısını göstərir.', 'help.guide.booking-travelers.step.2': - 'Bu rezervasiyanın aid olduğu insanlara klikləyin. Seçilmiş şəxsdə işarə görünür; onu götürmək üçün yenidən klikləyin.', + 'Ona klikləyin və bu rezervasiyanın aid olduğu insanları seçin; adlı qonaqlar da siyahıdadır. Seçilmiş şəxsdə işarə görünür, onun avatarı isə sahədə göstərilir. Onu götürmək üçün ada yenidən klikləyin.', 'help.guide.booking-travelers.step.3': 'Yenilə düyməsinə klikləyin.', 'help.guide.booking-travelers.step.4': 'Yuxarıda, alətlər panelində növ düymələrinin yanında yalnız həmin səyahətçinin rezervasiyalarını görmək üçün onun avatarına klikləyin.', @@ -3029,7 +3029,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Rezervasiyanın qiymətini xərcə çevirin', 'help.guide.booking-cost.goal': 'Rezervasiyanın dəyərini onu ödəyən insanlar arasında bölərək Xərclərə daxil edin.', 'help.guide.booking-cost.step.1': - 'Rezervasiyanı açın və formanın aşağısına gedin. Xərclər altında Rezervasiyanı yadda saxlayır, sonra Xərclər redaktorunu açır qeydi ilə Xərc yarat düyməsi var.', + 'Rezervasiyanı açın və formanın aşağısına gedin. Xərclər altında Rezervasiyanı yadda saxlayır, sonra Xərclər redaktorunu açır qeydi ilə Xərc yarat və Mövcud xərci əlaqələndir düymələri var.', 'help.guide.booking-cost.step.2': 'Xərc yarat düyməsinə klikləyin. Rezervasiya yadda saxlanılır, forması bağlanır və Xərclər redaktoru açılır.', 'help.guide.booking-cost.step.3': @@ -3038,10 +3038,11 @@ const help: TranslationStrings = { 'Kateqoriya rezervasiya növünə uyğun seçilib. Kim ödədi? sahəsini və məbləğin necə bölünəcəyini təyin edin.', 'help.guide.booking-cost.step.5': 'Xərc əlavə et düyməsinə klikləyin.', 'help.guide.booking-cost.result': - 'Rezervasiyanın formasında indi məbləğlə birlikdə Əlaqəli xərc göstərilir, eyni xərc isə bu rezervasiyaya bağlı olaraq Xərclər bölməsində yer alır.', + 'Rezervasiyanın formasında xərc indi məbləği ilə birlikdə Əlaqəli xərclər altında göstərilir, eyni xərc isə bu rezervasiyaya bağlı olaraq Xərclər bölməsində yer alır.', 'help.guide.booking-cost.tip.1': 'Kateqoriya növə uyğun seçilir: Restoran Yemək və içki, Yaşayış yeri Yaşayış yeri, Parkinq Parkinq olur, Tədbir və Tur isə Digər kateqoriyasına düşür.', - 'help.guide.booking-cost.tip.2': 'Əlaqəli xərc üzərindəki zibil qutusu rezervasiyanı deyil, xərci silir.', + 'help.guide.booking-cost.tip.2': + 'Bir rezervasiyanın bir neçə xərci ola bilər. Mövcud xərci əlaqələndir Xərclər bölməsində hələ heç yerə aid olmayan xərcləri təklif edir. Əlaqəli xərcdə Əlaqəni sil, xərci saxla onu ayırır və Xərclər bölməsində saxlayır, zibil qutusu isə onu silir.', 'help.guide.booking-cost.tip.3': 'Xərclər formada yalnız Xərclər əlavəsi aktiv olduqda görünür; onu administrator Əlavələr bölməsində aktivləşdirir.', // filter-bookings @@ -3474,15 +3475,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Rezervasiyanı Nəqliyyat və ya Rezervasiyalar tabında açın və onun qələminə klikləyin.', 'help.guide.expense-from-booking.step.2': - 'Formanın aşağısındakı Xərclər blokuna qədər sürüşdürün. Hələ heç nə əlaqələndirilməyibsə, orada Xərc yarat təklif olunur və əvvəlcə rezervasiyanın yadda saxlanılacağı qeyd edilir.', + 'Formanın aşağısındakı Xərclər blokuna qədər sürüşdürün. Orada əvvəlcə rezervasiyanı yadda saxlayan Xərc yarat və artıq Xərclər bölməsində olan xərc üçün Mövcud xərci əlaqələndir təklif olunur.', 'help.guide.expense-from-booking.step.3': 'Xərc yarat düyməsinə klikləyin. Rezervasiya yadda saxlanılır, forma bağlanır və Xərclər redaktoru ad olaraq rezervasiyanın başlığı və növünə artıq uyğunlaşdırılmış kateqoriya ilə açılır.', 'help.guide.expense-from-booking.step.4': - 'İstənilən xərcdə olduğu kimi məbləği və valyutasını, kimin ödədiyini və bölüşdürməni doldurun və yadda saxlayın. Rezervasiyanı yenidən açdıqda xərc Əlaqəli xərc altında, redaktə etmək üçün qələm və silmək üçün zibil qutusu ilə göstərilir.', + 'İstənilən xərcdə olduğu kimi məbləği və valyutasını, kimin ödədiyini və bölüşdürməni doldurun və yadda saxlayın. Rezervasiyanı yenidən açdıqda xərc Əlaqəli xərclər altında, redaktə etmək üçün qələm, ayırmaq üçün Əlaqəni sil, xərci saxla və silmək üçün zibil qutusu ilə göstərilir.', 'help.guide.expense-from-booking.result': 'Rezervasiya öz xərcini daşıyır, xərc isə Xərclər tabında digərləri kimi ödəyicisi, bölüşdürməsi və valyutası olan adi sətirdir.', 'help.guide.expense-from-booking.tip.1': - 'Rezervasiyanı silmək onunla əlaqəli xərci də silir. Rezervasiyanın Xərclər blokundakı Xərci sil isə əksini edir: xərc silinir, rezervasiya qalır.', + 'Rezervasiyanı silmək onunla əlaqəli xərcləri də silir. Rezervasiyanın Xərclər blokundakı Xərci sil isə əksini edir: xərc silinir, rezervasiya qalır. Əlaqəni sil, xərci saxla isə hər ikisini saxlayır.', 'help.guide.expense-from-booking.tip.2': 'Məkanın formasında da eyni blok var; orada Xərc yarat əvvəlcə məkanı yadda saxlayır.', diff --git a/shared/src/i18n/az/reservations.ts b/shared/src/i18n/az/reservations.ts index 047bc2648..dcfa39e07 100644 --- a/shared/src/i18n/az/reservations.ts +++ b/shared/src/i18n/az/reservations.ts @@ -240,6 +240,10 @@ const reservations: TranslationStrings = { 'Rezervasiyanı yadda saxlayır, sonra Xərclər redaktorunu açır.', 'reservations.linkedExpense': 'Əlaqəli xərc', 'reservations.removeExpense': 'Xərci sil', + 'reservations.linkedExpenses': 'Əlaqəli xərclər', + 'reservations.linkExpense': 'Mövcud xərci əlaqələndir', + 'reservations.unlinkExpense': 'Əlaqəni sil, xərci saxla', + 'reservations.noUnlinkedExpenses': 'Əlaqəsiz xərc yoxdur', 'reservations.travelers.label': 'Səyahətçilər', 'reservations.travelers.assign': 'Səyahətçiləri təyin et', diff --git a/shared/src/i18n/br/help.ts b/shared/src/i18n/br/help.ts index 7b89c12bd..007677a9e 100644 --- a/shared/src/i18n/br/help.ts +++ b/shared/src/i18n/br/help.ts @@ -1845,7 +1845,7 @@ const help: TranslationStrings = { 'Clique em Adicionar. Se já houver um lugar com o mesmo nome na viagem, o formulário avisa e o botão vira Adicionar mesmo assim.', 'help.guide.create-place.result': 'O lugar está na lista e no mapa, em Não planejados até ser posto em um dia.', 'help.guide.create-place.tip.1': - 'Arquivos e Costs no fim do formulário anexam um documento ao lugar, ou abrem o editor Costs para a despesa dele logo depois de salvar.', + 'Arquivos e Custos no fim do formulário anexam um documento ao lugar, ou abrem o editor de Custos para a despesa dele logo depois de salvar.', 'help.guide.create-place.tip.2': 'O índice do TREK e o OpenStreetMap respondem à busca em qualquer TREK, e Detalhes do lugar se preenche com Wikipédia, Wikivoyage e Wikimedia. O Google só é consultado onde os dois vêm vazios, e só ele traz as avaliações.', 'help.guide.create-place.tip.3': @@ -3164,7 +3164,7 @@ const help: TranslationStrings = { 'Coloque à mão na viagem um restaurante, um evento, um passeio, uma vaga de estacionamento ou qualquer outra coisa.', 'help.guide.create-booking.step.1': 'Clique em Reserva manual no canto superior direito da aba. Nova reserva abre.', 'help.guide.create-booking.step.2': - 'Escolha o Tipo de reserva. Hospedagem, Restaurante, Evento, Passeio, Estacionamento e Outro são os seis que esta aba cria, e o formulário muda com a escolha: só Hospedagem troca suas datas por um intervalo de dias.', + 'Escolha o Tipo de reserva na lista no topo do formulário, ao lado de Viajantes. Hospedagem, Restaurante, Evento, Passeio, Estacionamento e Outro são os seis que esta aba cria, e o formulário muda com a escolha: só Hospedagem troca suas datas por um intervalo de dias.', 'help.guide.create-booking.step.3': 'Digite o Título. É o único campo em que o formulário insiste, e Adicionar fica morto até ter algo.', 'help.guide.create-booking.step.4': @@ -3220,9 +3220,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Dizer para quem é uma reserva', 'help.guide.booking-travelers.goal': 'Marque os viajantes que uma reserva cobre e depois veja só as deles.', 'help.guide.booking-travelers.step.1': - 'Abra a reserva com o lápis e desça até Viajantes. Ali está cada membro da viagem, convidados com nome incluídos.', + 'Abra a reserva com o lápis. Viajantes fica no topo do formulário, ao lado de Tipo de reserva, e mostra Atribuir viajantes enquanto ninguém estiver na reserva.', 'help.guide.booking-travelers.step.2': - 'Clique nas pessoas para quem esta reserva é. Quem é escolhido ganha um tique; clique de novo para tirar.', + 'Clique nele e escolha as pessoas para quem esta reserva é; convidados com nome também estão na lista. Quem é escolhido ganha um tique e seu avatar no campo. Clique no nome de novo para tirar.', 'help.guide.booking-travelers.step.3': 'Clique em Atualizar.', 'help.guide.booking-travelers.step.4': 'Lá em cima na barra de ferramentas, ao lado dos chips de tipo, clique no avatar de um viajante para ver só as reservas dele.', @@ -3252,21 +3252,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Transformar o preço de uma reserva em custo', 'help.guide.booking-cost.goal': 'Leve o que uma reserva custa para os Custos, dividido entre as pessoas que pagam.', 'help.guide.booking-cost.step.1': - 'Abra a reserva e vá até o pé do formulário. Sob Costs está Create expense, com a nota Saves the booking, then opens the Costs editor.', + 'Abra a reserva e vá até o pé do formulário. Sob Custos estão Criar despesa e Vincular despesa existente, com a nota Salva a reserva e depois abre o editor de despesas.', 'help.guide.booking-cost.step.2': - 'Clique em Create expense. A reserva é salva, o formulário dela fecha e o editor Costs abre.', + 'Clique em Criar despesa. A reserva é salva, o formulário dela fecha e o editor de Custos abre.', 'help.guide.booking-cost.step.3': 'Para que foi? já é o título da reserva. Coloque o Valor total e confira a Moeda e o Dia.', 'help.guide.booking-cost.step.4': 'Categoria é a que o tipo de reserva sugere. Defina Quem pagou? e como o valor é dividido.', 'help.guide.booking-cost.step.5': 'Clique em Adicionar despesa.', 'help.guide.booking-cost.result': - 'O formulário da reserva agora mostra Linked expense com o valor, e a mesma despesa está na aba Custos, amarrada a esta reserva.', + 'O formulário da reserva agora lista a despesa em Despesas vinculadas com o valor dela, e a mesma despesa está na aba Custos, amarrada a esta reserva.', 'help.guide.booking-cost.tip.1': 'A categoria segue o tipo: Restaurante vira Comida e bebida, Hospedagem vira Hospedagem, Estacionamento vira Estacionamento, e Evento e Passeio caem os dois em Outros.', - 'help.guide.booking-cost.tip.2': 'A lixeira em Linked expense remove a despesa, não a reserva.', + 'help.guide.booking-cost.tip.2': + 'Uma reserva pode levar várias despesas. Vincular despesa existente oferece as de Custos que ainda não pertencem a nada. Em uma vinculada, Desvincular, manter a despesa a solta e a deixa em Custos, enquanto a lixeira a remove.', 'help.guide.booking-cost.tip.3': - 'Costs só está no formulário enquanto o complemento Custos estiver ligado, o que o administrador liga em Complementos.', + 'Custos só está no formulário enquanto o complemento Custos estiver ligado, o que o administrador liga em Complementos.', // filter-bookings 'help.guide.filter-bookings.title': 'Encontrar uma reserva', 'help.guide.filter-bookings.goal': 'Reduza uma aba longa ao tipo, à pessoa ou ao estado que você procura.', @@ -3320,7 +3321,7 @@ const help: TranslationStrings = { 'help.guide.edit-booking.tip.1': 'Um Código da reserva que você não consegue ler é Ocultar códigos de reserva nas Configurações, em Exibição. Passe o mouse por cima, ou clique nele, e ele fica legível.', 'help.guide.edit-booking.tip.2': - 'Mude o tipo e a categoria de uma despesa vinculada acompanha, a não ser que você tivesse escolhido uma categoria à mão no editor Costs.', + 'Mude o tipo e a categoria de uma despesa vinculada acompanha, a não ser que você tivesse escolhido uma categoria à mão no editor de Custos.', 'help.guide.edit-booking.tip.3': 'Uma hospedagem também é editada aqui: os dias De e Até dela estão no mesmo formulário.', // delete-booking @@ -3338,7 +3339,7 @@ const help: TranslationStrings = { 'Os documentos que estavam anexados ficam na aba Arquivos da viagem; só o vínculo deles com a reserva some.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Cada reserva encontrada abre em Nova reserva, uma após a outra, já preenchida. Para um hotel, isso é o nome em Título e, quando a viagem tem o lugar, em Hospedagem, o Local / endereço dele, De e Até nas noites dele, Check-in e Check-out, o Código da reserva, a confirmação em Arquivos e, com Custos ligado, o preço como Linked expense. Confira e clique em Adicionar.', + 'Cada reserva encontrada abre em Nova reserva, uma após a outra, já preenchida. Para um hotel, isso é o nome em Título e, quando a viagem tem o lugar, em Hospedagem, o Local / endereço dele, De e Até nas noites dele, Check-in e Check-out, o Código da reserva, a confirmação em Arquivos e, com Custos ligado, o preço como Despesa vinculada. Confira e clique em Adicionar.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Custos', @@ -3508,17 +3509,17 @@ const help: TranslationStrings = { 'Anexe o que um voo, um hotel ou um lugar realmente custou ao registro a que pertence.', 'help.guide.expense-from-booking.step.1': 'Abra a reserva na aba Transportes ou Reservas e clique no lápis dela.', 'help.guide.expense-from-booking.step.2': - 'Role até o bloco Costs no fim do formulário. Enquanto nada estiver vinculado, ele oferece Create expense e diz que salva a reserva primeiro.', + 'Role até o bloco Custos no fim do formulário. Ele oferece Criar despesa, que salva a reserva primeiro, e Vincular despesa existente para uma que já está na aba Custos.', 'help.guide.expense-from-booking.step.3': - 'Clique em Create expense. A reserva é salva, o formulário fecha, e o editor de Custos abre com o título da reserva como nome e o seu tipo já associado a uma categoria.', + 'Clique em Criar despesa. A reserva é salva, o formulário fecha, e o editor de Custos abre com o título da reserva como nome e o seu tipo já associado a uma categoria.', 'help.guide.expense-from-booking.step.4': - 'Preencha o valor e a moeda dele, quem pagou e a divisão como em qualquer despesa, e salve. Ao reabrir a reserva, ela aparece em Linked expense, com um lápis para editar e uma lixeira para remover.', + 'Preencha o valor e a moeda dele, quem pagou e a divisão como em qualquer despesa, e salve. Ao reabrir a reserva, ela aparece em Despesas vinculadas, com um lápis para editar, Desvincular, manter a despesa para soltar e uma lixeira para remover.', 'help.guide.expense-from-booking.result': 'A reserva carrega o seu custo, e a despesa é uma linha comum na aba Custos, com pagador, divisão e moeda como qualquer outra.', 'help.guide.expense-from-booking.tip.1': - 'Excluir a reserva exclui junto a despesa vinculada. Remove expense, no bloco Costs da reserva, faz o contrário: a despesa sai, a reserva fica.', + 'Excluir a reserva exclui junto as despesas vinculadas. Remover despesa, no bloco Custos da reserva, faz o contrário: a despesa sai, a reserva fica. Desvincular, manter a despesa mantém as duas.', 'help.guide.expense-from-booking.tip.2': - 'Um lugar tem o mesmo bloco no seu formulário, onde Create expense salva o lugar primeiro.', + 'Um lugar tem o mesmo bloco no seu formulário, onde Criar despesa salva o lugar primeiro.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Transportes', @@ -3574,7 +3575,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'A linha cai onde o horário de início a coloca, depois da última parada que começa mais cedo. A alça dela a arrasta para qualquer outro ponto do dia, ou para outro dia.', 'help.guide.add-transport.tip.2': - 'Anexar arquivo, em Arquivos, recebe a passagem, e Create expense, em Costs, salva a reserva e abre o editor de Custos para a tarifa.', + 'Anexar arquivo, em Arquivos, recebe a passagem, e Criar despesa, em Custos, salva a reserva e abre o editor de Custos para a tarifa.', 'help.guide.add-transport.tip.3': 'Viajantes marca quem está nesse trajeto. Assim que um transporte tem viajantes, a barra de ferramentas da aba faz crescer os avatares deles e filtra a lista por eles.', // plan-transit diff --git a/shared/src/i18n/br/reservations.ts b/shared/src/i18n/br/reservations.ts index a9c0e0eab..ff1856544 100644 --- a/shared/src/i18n/br/reservations.ts +++ b/shared/src/i18n/br/reservations.ts @@ -164,11 +164,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'Nenhum voo encontrado na sua conta do AirTrail.', 'reservations.airtrail.importCta': 'Importar {count}', 'reservations.airtrail.joinConnection': 'Importar como um único voo com escala em {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Custos', + 'reservations.createExpense': 'Criar despesa', + 'reservations.createExpenseHint': 'Salva a reserva e depois abre o editor de despesas.', + 'reservations.linkedExpense': 'Despesa vinculada', + 'reservations.removeExpense': 'Remover despesa', + 'reservations.linkedExpenses': 'Despesas vinculadas', + 'reservations.linkExpense': 'Vincular despesa existente', + 'reservations.unlinkExpense': 'Desvincular, manter a despesa', + 'reservations.noUnlinkedExpenses': 'Nenhuma despesa sem vínculo', 'reservations.urlLabel': 'Link', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Viajantes', diff --git a/shared/src/i18n/ca/help.ts b/shared/src/i18n/ca/help.ts index 19f9124ee..ca50c54c8 100644 --- a/shared/src/i18n/ca/help.ts +++ b/shared/src/i18n/ca/help.ts @@ -3222,7 +3222,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Fes clic a Reserva manual, a dalt a la dreta de la pestanya. S’obre Reserva nova.', 'help.guide.create-booking.step.2': - 'Tria el Tipus de reserva. Allotjament, Restaurant, Esdeveniment, Excursió, Aparcament i Altres són els sis que fa aquesta pestanya, i el formulari canvia amb la tria: només Allotjament bescanvia les dates per un interval de dies.', + 'Tria el Tipus de reserva a la llista de dalt del formulari, al costat de Viatgers. Allotjament, Restaurant, Esdeveniment, Excursió, Aparcament i Altres són els sis que fa aquesta pestanya, i el formulari canvia amb la tria: només Allotjament bescanvia les dates per un interval de dies.', 'help.guide.create-booking.step.3': 'Escriu el Títol. És l’únic camp en què el formulari insisteix, i Afegir es queda mort fins que hi hagi alguna cosa.', 'help.guide.create-booking.step.4': @@ -3278,9 +3278,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Dir per a qui és una reserva', 'help.guide.booking-travelers.goal': 'Marca els viatgers que cobreix una reserva i després mira només les seves.', 'help.guide.booking-travelers.step.1': - 'Obre la reserva amb el llapis i baixa fins a Viatgers. Allà hi ha cada membre del viatge, els convidats amb nom inclosos.', + 'Obre la reserva amb el llapis. Viatgers és a dalt del formulari, al costat de Tipus de reserva, i diu Assigna viatgers mentre no hi ha ningú a la reserva.', 'help.guide.booking-travelers.step.2': - 'Fes clic a les persones per a qui és aquesta reserva. La que tries rep una marca; torna-hi a fer clic per treure-la.', + 'Fes-hi clic i tria les persones per a qui és aquesta reserva; els convidats amb nom també són a la llista. La que tries rep una marca i el seu avatar al camp. Torna a fer clic al nom per treure-la.', 'help.guide.booking-travelers.step.3': 'Fes clic a Actualitzar.', 'help.guide.booking-travelers.step.4': 'A dalt, a la barra d’eines, al costat dels xips de tipus, fes clic a l’avatar d’un viatger per veure només les seves reserves.', @@ -3311,7 +3311,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Porta el que costa una reserva a les Despeses, repartit entre les persones que ho paguen.', 'help.guide.booking-cost.step.1': - 'Obre la reserva i vés al peu del formulari. Sota Despeses hi ha Crear despesa, amb la nota Desa la reserva i obre l’editor de Despeses.', + 'Obre la reserva i vés al peu del formulari. Sota Despeses hi ha Crear despesa i Vincular despesa existent, amb la nota Desa la reserva i obre l’editor de Despeses.', 'help.guide.booking-cost.step.2': 'Fes clic a Crear despesa. La reserva es desa, el seu formulari es tanca i l’editor de Despeses s’obre.', 'help.guide.booking-cost.step.3': @@ -3320,10 +3320,11 @@ const help: TranslationStrings = { 'Categoria és la que implica el tipus de reserva. Posa Qui ha pagat? i com es reparteix l’import.', 'help.guide.booking-cost.step.5': 'Fes clic a Afegir despesa.', 'help.guide.booking-cost.result': - 'El formulari de la reserva ara diu Despesa vinculada amb l’import, i la mateixa despesa és a la pestanya Despeses, lligada a aquesta reserva.', + 'El formulari de la reserva ara mostra la despesa sota Despeses vinculades amb el seu import, i la mateixa despesa és a la pestanya Despeses, lligada a aquesta reserva.', 'help.guide.booking-cost.tip.1': 'La categoria segueix el tipus: Restaurant passa a Menjar i beguda, Allotjament passa a Allotjament, Aparcament passa a Aparcament, i Esdeveniment i Excursió cauen tots dos a Altres.', - 'help.guide.booking-cost.tip.2': 'La paperera de Despesa vinculada treu la despesa, no la reserva.', + 'help.guide.booking-cost.tip.2': + 'Una reserva pot portar diverses despeses. Vincular despesa existent ofereix les de Despeses que encara no pertanyen a res. En una de vinculada, Desvincular, conservar la despesa la deixa anar i la manté a Despeses, mentre que la paperera la treu.', 'help.guide.booking-cost.tip.3': 'Despeses només és al formulari mentre l’addon Pressupost està activat, que l’administrador engega a Complements.', // filter-bookings @@ -3570,15 +3571,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Obre la reserva a la pestanya Transports o Reserves i fes clic al seu llapis.', 'help.guide.expense-from-booking.step.2': - 'Baixa fins al bloc Despeses al final del formulari. Mentre no hi hagi res vinculat, ofereix Crear despesa i diu que primer desa la reserva.', + 'Baixa fins al bloc Despeses al final del formulari. Ofereix Crear despesa, que primer desa la reserva, i Vincular despesa existent per a una que ja és a Despeses.', 'help.guide.expense-from-booking.step.3': 'Fes clic a Crear despesa. La reserva es desa, el formulari es tanca, i l’editor de Despeses s’obre amb el títol de la reserva com a nom i el seu tipus ja associat a una categoria.', 'help.guide.expense-from-booking.step.4': - 'Omple l’import i la seva moneda, qui ha pagat i el repartiment com en qualsevol despesa, i desa. En tornar a obrir la reserva apareix sota Despesa vinculada, amb un llapis per editar-la i una paperera per treure-la.', + 'Omple l’import i la seva moneda, qui ha pagat i el repartiment com en qualsevol despesa, i desa. En tornar a obrir la reserva apareix sota Despeses vinculades, amb un llapis per editar-la, Desvincular, conservar la despesa per deixar-la anar i una paperera per treure-la.', 'help.guide.expense-from-booking.result': 'La reserva porta el seu cost, i la despesa és una fila corrent de la pestanya Despeses, amb un pagador, un repartiment i una divisa com qualsevol altra.', 'help.guide.expense-from-booking.tip.1': - 'Eliminar la reserva elimina també la seva despesa vinculada. Eliminar despesa, al bloc Despeses de la reserva, fa el contrari: la despesa se’n va, la reserva es queda.', + 'Eliminar la reserva elimina també les seves despeses vinculades. Eliminar despesa, al bloc Despeses de la reserva, fa el contrari: la despesa se’n va, la reserva es queda. Desvincular, conservar la despesa les manté totes dues.', 'help.guide.expense-from-booking.tip.2': 'Un lloc té el mateix bloc al seu formulari, on Crear despesa desa primer el lloc.', diff --git a/shared/src/i18n/ca/reservations.ts b/shared/src/i18n/ca/reservations.ts index adc189ff6..c7e83153e 100644 --- a/shared/src/i18n/ca/reservations.ts +++ b/shared/src/i18n/ca/reservations.ts @@ -173,6 +173,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': "Desa la reserva i obre l'editor de Despeses.", 'reservations.linkedExpense': 'Despesa vinculada', 'reservations.removeExpense': 'Eliminar despesa', + 'reservations.linkedExpenses': 'Despeses vinculades', + 'reservations.linkExpense': 'Vincular despesa existent', + 'reservations.unlinkExpense': 'Desvincular, conservar la despesa', + 'reservations.noUnlinkedExpenses': 'No hi ha despeses sense vincular', 'reservations.airtrail.joinConnection': 'Importar com un sol vol amb escala a {stops}', 'reservations.travelers.label': 'Viatgers', 'reservations.travelers.assign': 'Assigna viatgers', diff --git a/shared/src/i18n/cs/help.ts b/shared/src/i18n/cs/help.ts index 9d238ca6d..6588a0732 100644 --- a/shared/src/i18n/cs/help.ts +++ b/shared/src/i18n/cs/help.ts @@ -1793,7 +1793,7 @@ const help: TranslationStrings = { 'Klikněte na Přidat. Pokud už v cestě je místo stejného názvu, formulář to řekne a tlačítko se změní na Přesto přidat.', 'help.guide.create-place.result': 'Místo je v seznamu a na mapě, pod Nezařazené, dokud ho nepoložíte na den.', 'help.guide.create-place.tip.1': - 'Soubory a Costs dole ve formuláři připojí k místu dokument nebo hned po uložení otevřou editor Costs pro jeho výdaj.', + 'Soubory a Náklady dole ve formuláři připojí k místu dokument nebo hned po uložení otevřou editor Náklady pro jeho výdaj.', 'help.guide.create-place.tip.2': 'Hledání na každém TREKu zodpoví index TREK a OpenStreetMap a Podrobnosti místa se plní z Wikipedie, Wikivoyage a Wikimedia. Google přijde na řadu jen tam, kde oba zůstanou prázdné, a jen on přináší hodnocení.', 'help.guide.create-place.tip.3': @@ -3086,7 +3086,7 @@ const help: TranslationStrings = { 'Vložte do cesty ručně restauraci, událost, prohlídku, parkovací místo nebo cokoli jiného.', 'help.guide.create-booking.step.1': 'Klikněte vpravo nahoře na kartě na Ruční rezervace. Otevře se Nová rezervace.', 'help.guide.create-booking.step.2': - 'Vyberte Typ rezervace. Ubytování, Restaurace, Událost, Prohlídka, Parkování a Jiné je těch šest, které tato karta dělá, a formulář se s volbou mění: jen Ubytování vymění svá data za rozsah dnů.', + 'Vyberte Typ rezervace ze seznamu nahoře ve formuláři, vedle pole Cestující. Ubytování, Restaurace, Událost, Prohlídka, Parkování a Jiné je těch šest, které tato karta dělá, a formulář se s volbou mění: jen Ubytování vymění svá data za rozsah dnů.', 'help.guide.create-booking.step.3': 'Napište Název. Je to jediné pole, na kterém formulář trvá, a Přidat zůstane mrtvé, dokud v něm něco není.', 'help.guide.create-booking.step.4': @@ -3141,9 +3141,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Říct, pro koho rezervace je', 'help.guide.booking-travelers.goal': 'Označte cestující, které rezervace pokrývá, a pak si nechte ukázat jen jejich.', 'help.guide.booking-travelers.step.1': - 'Otevřete rezervaci tužkou a sjeďte dolů k poli Cestující. Stojí tam každý člen cesty, jmenovaní hosté mezi nimi.', + 'Otevřete rezervaci tužkou. Pole Cestující je nahoře ve formuláři, vedle pole Typ rezervace, a dokud na rezervaci nikdo není, ukazuje Přiřadit cestující.', 'help.guide.booking-travelers.step.2': - 'Klikněte na lidi, pro které tato rezervace je. Vybraný dostane fajfku; dalším kliknutím ji zase sundáte.', + 'Klikněte na něj a vyberte lidi, pro které tato rezervace je; jmenovaní hosté jsou v seznamu také. Vybraný dostane fajfku a jeho avatar se objeví v poli. Dalším kliknutím na jméno ho zase sundáte.', 'help.guide.booking-travelers.step.3': 'Klikněte na Aktualizovat.', 'help.guide.booking-travelers.step.4': 'Nahoře v liště, vedle štítků typů, klikněte na avatar cestujícího a uvidíte jen jeho rezervace.', @@ -3174,21 +3174,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Udělat z ceny rezervace náklad', 'help.guide.booking-cost.goal': 'Dostaňte to, co rezervace stojí, do Nákladů, rozdělené mezi lidi, kteří ji platí.', 'help.guide.booking-cost.step.1': - 'Otevřete rezervaci a sjeďte na konec formuláře. Pod Costs stojí Create expense, s poznámkou, že rezervaci uloží a pak otevře editor nákladů.', + 'Otevřete rezervaci a sjeďte na konec formuláře. Pod Náklady stojí Vytvořit výdaj a Propojit stávající výdaj, s poznámkou Uloží rezervaci a poté otevře editor nákladů.', 'help.guide.booking-cost.step.2': - 'Klikněte na Create expense. Rezervace se uloží, její formulář se zavře a otevře se editor nákladů.', + 'Klikněte na Vytvořit výdaj. Rezervace se uloží, její formulář se zavře a otevře se editor nákladů.', 'help.guide.booking-cost.step.3': 'Za co to bylo? už je název rezervace. Vložte Celkovou částku a zkontrolujte Měnu a Den.', 'help.guide.booking-cost.step.4': 'Kategorie je ta, kterou typ rezervace napovídá. Nastavte Kdo zaplatil? a to, jak se částka dělí.', 'help.guide.booking-cost.step.5': 'Klikněte na Přidat výdaj.', 'help.guide.booking-cost.result': - 'Ve formuláři rezervace teď stojí Linked expense s částkou, a tentýž výdaj stojí na kartě Náklady, svázaný s touto rezervací.', + 'Formulář rezervace teď uvádí výdaj pod Propojené výdaje s jeho částkou, a tentýž výdaj stojí na kartě Náklady, svázaný s touto rezervací.', 'help.guide.booking-cost.tip.1': 'Kategorie jde za typem: z Restaurace je Jídlo a pití, z Ubytování Ubytování, z Parkování Parkování, a Událost i Prohlídka obě padnou do Ostatní.', - 'help.guide.booking-cost.tip.2': 'Koš u Linked expense odebere výdaj, ne rezervaci.', + 'help.guide.booking-cost.tip.2': + 'Rezervace může nést několik výdajů. Propojit stávající výdaj nabízí ty z Nákladů, které zatím nikam nepatří. U propojeného výdaje ho Odpojit, výdaj ponechat uvolní a nechá v Nákladech, kdežto koš ho odebere.', 'help.guide.booking-cost.tip.3': - 'Costs je ve formuláři jen tehdy, když je zapnutý doplněk Náklady, který správce přepíná pod Doplňky.', + 'Blok Náklady je ve formuláři jen tehdy, když je zapnutý doplněk Náklady, který správce přepíná pod Doplňky.', // filter-bookings 'help.guide.filter-bookings.title': 'Najít rezervaci', 'help.guide.filter-bookings.goal': 'Zužte dlouhou kartu na typ, osobu nebo stav, který hledáte.', @@ -3257,7 +3258,7 @@ const help: TranslationStrings = { 'Dokumenty, které byly přiložené, zůstanou na kartě Soubory dané cesty; jde jen jejich propojení s rezervací.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Každá rezervace, která byla nalezena, se otevře v Nová rezervace, jedna po druhé, už vyplněná. U hotelu je to název v Název a, když cesta to místo má, pod Ubytování, jeho Místo / Adresa, Od dne a Do dne na jeho nocích, Check-in a Check-out, Rezervační kód, potvrzení pod Soubory a, se zapnutými Náklady, cena jako Linked expense. Zkontrolujte to a klikněte na Přidat.', + 'Každá rezervace, která byla nalezena, se otevře v Nová rezervace, jedna po druhé, už vyplněná. U hotelu je to název v Název a, když cesta to místo má, pod Ubytování, jeho Místo / Adresa, Od dne a Do dne na jeho nocích, Check-in a Check-out, Rezervační kód, potvrzení pod Soubory a, se zapnutými Náklady, cena jako Propojený výdaj. Zkontrolujte to a klikněte na Přidat.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Náklady', @@ -3426,17 +3427,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Otevřete rezervaci na kartě Doprava nebo Rezervace a klikněte na její tužku.', 'help.guide.expense-from-booking.step.2': - 'Sjeďte k bloku Costs dole ve formuláři. Dokud není nic propojené, nabízí Create expense a říká, že nejdřív uloží rezervaci.', + 'Sjeďte k bloku Náklady dole ve formuláři. Nabízí Vytvořit výdaj, který nejdřív uloží rezervaci, a Propojit stávající výdaj pro výdaj, který už v Nákladech je.', 'help.guide.expense-from-booking.step.3': - 'Klikněte na Create expense. Rezervace se uloží, formulář se zavře a otevře se editor Náklady s názvem rezervace jako názvem a s jejím typem už přiřazeným ke kategorii.', + 'Klikněte na Vytvořit výdaj. Rezervace se uloží, formulář se zavře a otevře se editor Náklady s názvem rezervace jako názvem a s jejím typem už přiřazeným ke kategorii.', 'help.guide.expense-from-booking.step.4': - 'Vyplňte částku a její měnu, kdo zaplatil, a rozdělení jako u každého výdaje a uložte. Když teď rezervaci otevřete znovu, ukáže výdaj pod Linked expense, s tužkou na úpravu a košem na odebrání.', + 'Vyplňte částku a její měnu, kdo zaplatil, a rozdělení jako u každého výdaje a uložte. Když teď rezervaci otevřete znovu, ukáže výdaj pod Propojené výdaje, s tužkou na úpravu, Odpojit, výdaj ponechat na uvolnění a košem na odebrání.', 'help.guide.expense-from-booking.result': 'Rezervace nese svůj náklad a výdaj je obyčejný řádek na kartě Náklady, s plátcem, rozdělením a měnou jako každý jiný.', 'help.guide.expense-from-booking.tip.1': - 'Smazání rezervace smaže i její propojený výdaj. Remove expense v bloku Costs rezervace dělá opak: výdaj zmizí, rezervace zůstane.', + 'Smazání rezervace smaže i její propojené výdaje. Odebrat výdaj v bloku Náklady rezervace dělá opak: výdaj zmizí, rezervace zůstane. Odpojit, výdaj ponechat zachová obojí.', 'help.guide.expense-from-booking.tip.2': - 'Místo má ve svém formuláři stejný blok, kde Create expense nejdřív uloží místo.', + 'Místo má ve svém formuláři stejný blok, kde Vytvořit výdaj nejdřív uloží místo.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Doprava', @@ -3492,7 +3493,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'Řádek přistane tam, kam jej položí jeho čas začátku, za poslední zastávkou, která začíná dříve. Úchyt jej přetáhne kamkoli jinam ve dni nebo na jiný den.', 'help.guide.add-transport.tip.2': - 'Přiložit soubor v sekci Soubory přijme jízdenku a Create expense v sekci Costs uloží rezervaci a otevře editor Náklady pro jízdné.', + 'Přiložit soubor v sekci Soubory přijme jízdenku a Vytvořit výdaj v sekci Náklady uloží rezervaci a otevře editor Náklady pro jízdné.', 'help.guide.add-transport.tip.3': 'Cestující označuje, kdo jede. Jakmile má jedna přeprava cestující, liště karty narostou jejich avatary a filtruje podle nich seznam.', // plan-transit diff --git a/shared/src/i18n/cs/reservations.ts b/shared/src/i18n/cs/reservations.ts index a61520fb6..a474e6c4a 100644 --- a/shared/src/i18n/cs/reservations.ts +++ b/shared/src/i18n/cs/reservations.ts @@ -164,11 +164,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.', 'reservations.airtrail.importCta': 'Importovat {count}', 'reservations.airtrail.joinConnection': 'Importovat jako jeden let s přestupem v {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Náklady', + 'reservations.createExpense': 'Vytvořit výdaj', + 'reservations.createExpenseHint': 'Uloží rezervaci a poté otevře editor nákladů.', + 'reservations.linkedExpense': 'Propojený výdaj', + 'reservations.removeExpense': 'Odebrat výdaj', + 'reservations.linkedExpenses': 'Propojené výdaje', + 'reservations.linkExpense': 'Propojit stávající výdaj', + 'reservations.unlinkExpense': 'Odpojit, výdaj ponechat', + 'reservations.noUnlinkedExpenses': 'Žádné nepropojené výdaje', 'reservations.urlLabel': 'Odkaz', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Cestující', diff --git a/shared/src/i18n/de/help.ts b/shared/src/i18n/de/help.ts index e82b48e38..e139a9b56 100644 --- a/shared/src/i18n/de/help.ts +++ b/shared/src/i18n/de/help.ts @@ -3254,7 +3254,7 @@ const help: TranslationStrings = { 'Trag ein Restaurant, eine Veranstaltung, eine Tour, einen Parkplatz oder sonst etwas von Hand in die Reise ein.', 'help.guide.create-booking.step.1': 'Klick oben rechts im Tab auf Manuelle Buchung. Neue Buchung öffnet sich.', 'help.guide.create-booking.step.2': - 'Wähl die Art der Buchung. Unterkunft, Restaurant, Veranstaltung, Tour, Parkplatz und Sonstiges sind die sechs, die dieser Tab anlegt, und das Formular ändert sich mit der Wahl: nur Unterkunft tauscht ihre Daten gegen eine Spanne von Tagen.', + 'Wähl die Art der Buchung aus der Liste oben im Formular, neben Reisende. Unterkunft, Restaurant, Veranstaltung, Tour, Parkplatz und Sonstiges sind die sechs, die dieser Tab anlegt, und das Formular ändert sich mit der Wahl: nur Unterkunft tauscht ihre Daten gegen eine Spanne von Tagen.', 'help.guide.create-booking.step.3': 'Tipp den Titel ein. Es ist das eine Feld, auf dem das Formular besteht, und Hinzufügen bleibt tot, bis dort etwas steht.', 'help.guide.create-booking.step.4': @@ -3311,9 +3311,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Sagen, für wen eine Buchung ist', 'help.guide.booking-travelers.goal': 'Markier die Reisenden, die eine Buchung abdeckt, und sieh dann nur ihre.', 'help.guide.booking-travelers.step.1': - 'Öffne die Buchung mit dem Stift und geh hinunter zu Reisende. Dort steht jedes Mitglied der Reise, benannte Gäste darunter.', + 'Öffne die Buchung mit dem Stift. Reisende steht oben im Formular, neben Art der Buchung, und zeigt Reisende zuweisen, solange niemand auf der Buchung ist.', 'help.guide.booking-travelers.step.2': - 'Klick die Personen an, für die diese Buchung ist. Eine gewählte bekommt einen Haken; klick sie noch einmal an, um ihn wieder wegzunehmen.', + 'Klick darauf und wähl die Personen, für die diese Buchung ist; benannte Gäste stehen auch in der Liste. Eine gewählte bekommt einen Haken und ihren Avatar im Feld. Klick den Namen noch einmal an, um sie wieder wegzunehmen.', 'help.guide.booking-travelers.step.3': 'Klick auf Aktualisieren.', 'help.guide.booking-travelers.step.4': 'Oben in der Werkzeugleiste, neben den Chips für die Art, klick den Avatar einer reisenden Person an, um nur ihre Buchungen zu sehen.', @@ -3346,7 +3346,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Bring das, was eine Buchung kostet, in die Kosten, aufgeteilt zwischen den Leuten, die dafür zahlen.', 'help.guide.booking-cost.step.1': - 'Öffne die Buchung und geh ans Ende des Formulars. Unter Kosten steht Ausgabe erstellen, mit dem Hinweis Speichert die Buchung und öffnet dann den Kosten-Editor.', + 'Öffne die Buchung und geh ans Ende des Formulars. Unter Kosten stehen Ausgabe erstellen und Bestehende Ausgabe verknüpfen, mit dem Hinweis Speichert die Buchung und öffnet dann den Kosten-Editor.', 'help.guide.booking-cost.step.2': 'Klick auf Ausgabe erstellen. Die Buchung wird gespeichert, ihr Formular schließt sich, und der Kosten-Editor öffnet sich.', 'help.guide.booking-cost.step.3': @@ -3355,10 +3355,11 @@ const help: TranslationStrings = { 'Kategorie ist die, die die Art der Buchung nahelegt. Setz Wer hat bezahlt? und wie der Betrag aufgeteilt wird.', 'help.guide.booking-cost.step.5': 'Klick auf Ausgabe hinzufügen.', 'help.guide.booking-cost.result': - 'Im Formular der Buchung steht jetzt Verknüpfte Ausgabe mit dem Betrag, und dieselbe Ausgabe steht im Tab Kosten, an diese Buchung gebunden.', + 'Im Formular der Buchung steht die Ausgabe jetzt unter Verknüpfte Ausgaben mit ihrem Betrag, und dieselbe Ausgabe steht im Tab Kosten, an diese Buchung gebunden.', 'help.guide.booking-cost.tip.1': 'Die Kategorie folgt der Art: Restaurant wird zu Essen & Trinken, Unterkunft wird zu Unterkunft, Parkplatz wird zu Parken, und Veranstaltung und Tour landen beide in Sonstiges.', - 'help.guide.booking-cost.tip.2': 'Der Papierkorb bei Verknüpfte Ausgabe entfernt die Ausgabe, nicht die Buchung.', + 'help.guide.booking-cost.tip.2': + 'Eine Buchung kann mehrere Ausgaben tragen. Bestehende Ausgabe verknüpfen bietet die Ausgaben aus Kosten an, die noch nirgends hängen. Bei einer verknüpften löst Lösen, Ausgabe behalten die Verbindung und lässt sie in Kosten stehen, während der Papierkorb sie entfernt.', 'help.guide.booking-cost.tip.3': 'Kosten steht nur im Formular, solange das Addon Kosten an ist, das der Administrator unter Addons schaltet.', // filter-bookings @@ -3605,15 +3606,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Öffne die Buchung im Reiter Transport oder Buchungen und klick auf ihren Stift.', 'help.guide.expense-from-booking.step.2': - 'Scroll zum Block Kosten unten im Formular. Solange nichts verknüpft ist, bietet er Ausgabe erstellen an und sagt, dass er die Buchung zuerst speichert.', + 'Scroll zum Block Kosten unten im Formular. Er bietet Ausgabe erstellen an, das die Buchung zuerst speichert, und Bestehende Ausgabe verknüpfen für eine, die schon in Kosten steht.', 'help.guide.expense-from-booking.step.3': 'Klick auf Ausgabe erstellen. Die Buchung wird gespeichert, das Formular schließt sich, und der Kosten-Editor öffnet sich mit dem Titel der Buchung als Namen und ihrem Typ, der schon einer Kategorie zugeordnet ist.', 'help.guide.expense-from-booking.step.4': - 'Trag den Betrag und seine Währung ein, wer bezahlt hat, und die Aufteilung wie bei jeder Ausgabe, und speichere. Öffnest du die Buchung jetzt wieder, steht sie unter Verknüpfte Ausgabe, mit einem Stift zum Bearbeiten und einem Papierkorb zum Entfernen.', + 'Trag den Betrag und seine Währung ein, wer bezahlt hat, und die Aufteilung wie bei jeder Ausgabe, und speichere. Öffnest du die Buchung jetzt wieder, steht sie unter Verknüpfte Ausgaben, mit einem Stift zum Bearbeiten, Lösen, Ausgabe behalten zum Abkoppeln und einem Papierkorb zum Entfernen.', 'help.guide.expense-from-booking.result': 'Die Buchung trägt ihre Kosten, und die Ausgabe ist eine ganz normale Zeile im Reiter Kosten, mit Zahler, Aufteilung und Währung wie jede andere.', 'help.guide.expense-from-booking.tip.1': - 'Die Buchung zu löschen löscht ihre verknüpfte Ausgabe mit. Ausgabe entfernen im Block Kosten der Buchung macht das Gegenteil: Die Ausgabe geht, die Buchung bleibt.', + 'Die Buchung zu löschen löscht ihre verknüpften Ausgaben mit. Ausgabe entfernen im Block Kosten der Buchung macht das Gegenteil: Die Ausgabe geht, die Buchung bleibt. Lösen, Ausgabe behalten behält beides.', 'help.guide.expense-from-booking.tip.2': 'Ein Ort hat denselben Block in seinem Formular, wobei Ausgabe erstellen dort zuerst den Ort speichert.', diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts index 2f8763e26..a8851f269 100644 --- a/shared/src/i18n/de/reservations.ts +++ b/shared/src/i18n/de/reservations.ts @@ -174,6 +174,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': 'Speichert die Buchung und öffnet dann den Kosten-Editor.', 'reservations.linkedExpense': 'Verknüpfte Ausgabe', 'reservations.removeExpense': 'Ausgabe entfernen', + 'reservations.linkedExpenses': 'Verknüpfte Ausgaben', + 'reservations.linkExpense': 'Bestehende Ausgabe verknüpfen', + 'reservations.unlinkExpense': 'Lösen, Ausgabe behalten', + 'reservations.noUnlinkedExpenses': 'Keine unverknüpften Ausgaben', 'reservations.travelers.label': 'Reisende', 'reservations.travelers.assign': 'Reisende zuweisen', 'reservations.travelers.none': 'Noch keine Reisenden im Trip.', diff --git a/shared/src/i18n/en/help.ts b/shared/src/i18n/en/help.ts index 0d8b080fa..f3d290db2 100644 --- a/shared/src/i18n/en/help.ts +++ b/shared/src/i18n/en/help.ts @@ -2802,7 +2802,7 @@ const help: TranslationStrings = { 'Put a restaurant, an event, a tour, a parking space or anything else into the trip by hand.', 'help.guide.create-booking.step.1': 'Click Manual Booking at the top right of the tab. New Reservation opens.', 'help.guide.create-booking.step.2': - 'Pick the Booking Type. Accommodation, Restaurant, Event, Tour, Parking and Other are the six this tab makes, and the form changes with the choice: only Accommodation trades its dates for a range of days.', + 'Pick the Booking Type from the list at the top of the form, next to Travelers. Accommodation, Restaurant, Event, Tour, Parking and Other are the six this tab makes, and the form changes with the choice: only Accommodation trades its dates for a range of days.', 'help.guide.create-booking.step.3': 'Type the Title. It is the one field the form insists on, and Add stays dead until it has something.', 'help.guide.create-booking.step.4': @@ -2857,9 +2857,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Say who a booking is for', 'help.guide.booking-travelers.goal': 'Mark the travellers a booking covers, and then see only theirs.', 'help.guide.booking-travelers.step.1': - 'Open the booking with the pencil and go down to Travelers. Every member of the trip stands there, named guests among them.', + 'Open the booking with the pencil. Travelers sits at the top of the form, next to Booking Type, and reads Assign travelers while nobody is on the booking.', 'help.guide.booking-travelers.step.2': - 'Click the people this booking is for. A chosen one gets a tick; click it again to take it off.', + 'Click it and pick the people this booking is for; named guests are in the list too. A chosen one gets a tick and their avatar in the field. Click the name again to take it off.', 'help.guide.booking-travelers.step.3': 'Click Update.', 'help.guide.booking-travelers.step.4': 'Up in the toolbar, next to the type chips, click a traveller’s avatar to see only their bookings.', @@ -2889,7 +2889,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Turn a booking’s price into a cost', 'help.guide.booking-cost.goal': 'Get what a booking costs into Costs, split between the people paying for it.', 'help.guide.booking-cost.step.1': - 'Open the booking and go to the foot of the form. Under Costs stands Create expense, with the note Saves the booking, then opens the Costs editor.', + 'Open the booking and go to the foot of the form. Under Costs stand Create expense and Link existing expense, with the note Saves the booking, then opens the Costs editor.', 'help.guide.booking-cost.step.2': 'Click Create expense. The booking is saved, its form closes and the Costs editor opens.', 'help.guide.booking-cost.step.3': @@ -2898,10 +2898,11 @@ const help: TranslationStrings = { 'Category is the one the booking type implies. Set Who paid? and how the amount is split.', 'help.guide.booking-cost.step.5': 'Click Add expense.', 'help.guide.booking-cost.result': - 'The booking’s form now reads Linked expense with the amount, and the same expense stands in the Costs tab, tied to this booking.', + 'The booking’s form now lists the expense under Linked expenses with its amount, and the same expense stands in the Costs tab, tied to this booking.', 'help.guide.booking-cost.tip.1': 'The category follows the type: Restaurant becomes Food & drink, Accommodation becomes Accommodation, Parking becomes Parking, and Event and Tour both land in Other.', - 'help.guide.booking-cost.tip.2': 'The bin on Linked expense removes the expense, not the booking.', + 'help.guide.booking-cost.tip.2': + 'A booking can carry several expenses. Link existing expense offers the ones in Costs that belong nowhere yet. On a linked one, Unlink, keep the expense lets it go and leaves it in Costs, while the bin removes it.', 'help.guide.booking-cost.tip.3': 'Costs is in the form only while the Costs addon is on, which the administrator switches under Addons.', // filter-bookings @@ -3325,15 +3326,15 @@ const help: TranslationStrings = { 'Attach what a flight, a hotel or a place actually cost to the record it belongs to.', 'help.guide.expense-from-booking.step.1': 'Open the booking on the Transports or Bookings tab and click its pencil.', 'help.guide.expense-from-booking.step.2': - 'Scroll to the Costs block at the bottom of the form. With nothing linked yet it offers Create expense and says it saves the booking first.', + 'Scroll to the Costs block at the bottom of the form. It offers Create expense, which saves the booking first, and Link existing expense for one that is already in Costs.', 'help.guide.expense-from-booking.step.3': 'Click Create expense. The booking is saved, the form closes, and the Costs editor opens with the booking’s title as the name and its type already matched to a category.', 'help.guide.expense-from-booking.step.4': - 'Fill in the amount and its currency, who paid and the split as for any expense, and save. Reopening the booking now shows it under Linked expense, with a pencil to edit it and a bin to remove it.', + 'Fill in the amount and its currency, who paid and the split as for any expense, and save. Reopening the booking now shows it under Linked expenses, with a pencil to edit it, Unlink, keep the expense to let it go and a bin to remove it.', 'help.guide.expense-from-booking.result': 'The booking carries its cost, and the expense is an ordinary row on the Costs tab, with a payer, a split and a currency like any other.', 'help.guide.expense-from-booking.tip.1': - 'Deleting the booking deletes its linked expense with it. Remove expense in the booking’s Costs block does the opposite: the expense goes, the booking stays.', + 'Deleting the booking deletes its linked expenses with it. Remove expense in the booking’s Costs block does the opposite: the expense goes, the booking stays. Unlink, keep the expense keeps both.', 'help.guide.expense-from-booking.tip.2': 'A place has the same block in its form, with Create expense saving the place first.', diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts index 33e22abb1..25e112531 100644 --- a/shared/src/i18n/en/reservations.ts +++ b/shared/src/i18n/en/reservations.ts @@ -173,6 +173,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', 'reservations.linkedExpense': 'Linked expense', 'reservations.removeExpense': 'Remove expense', + 'reservations.linkedExpenses': 'Linked expenses', + 'reservations.linkExpense': 'Link existing expense', + 'reservations.unlinkExpense': 'Unlink, keep the expense', + 'reservations.noUnlinkedExpenses': 'No unlinked expenses', 'reservations.travelers.label': 'Travelers', 'reservations.travelers.assign': 'Assign travelers', 'reservations.travelers.none': 'No trip members yet.', diff --git a/shared/src/i18n/es/help.ts b/shared/src/i18n/es/help.ts index daf8e019b..55921b868 100644 --- a/shared/src/i18n/es/help.ts +++ b/shared/src/i18n/es/help.ts @@ -1892,7 +1892,7 @@ const help: TranslationStrings = { 'help.guide.create-place.result': 'El lugar está en la lista y en el mapa, bajo Sin planificar hasta que se pone en un día.', 'help.guide.create-place.tip.1': - 'Archivos y Costs, al final del formulario, adjuntan un documento al lugar, o abren el editor Costs para su gasto justo después de guardar.', + 'Archivos y Costes, al final del formulario, adjuntan un documento al lugar, o abren el editor de Costes para su gasto justo después de guardar.', 'help.guide.create-place.tip.2': 'El índice de TREK y OpenStreetMap responden a la búsqueda en cualquier TREK, y Detalles del lugar se rellena desde Wikipedia, Wikivoyage y Wikimedia. A Google solo se le pregunta donde los dos salen vacíos, y solo él trae las valoraciones.', 'help.guide.create-place.tip.3': @@ -3236,7 +3236,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Haz clic en Reserva manual, arriba a la derecha de la pestaña. Se abre Nueva reserva.', 'help.guide.create-booking.step.2': - 'Elige el Tipo de reserva. Alojamiento, Restaurante, Evento, Excursión, Aparcamiento y Otro son las seis que hace esta pestaña, y el formulario cambia con la elección: solo Alojamiento cambia sus fechas por un rango de días.', + 'Elige el Tipo de reserva en la lista de arriba del formulario, junto a Viajeros. Alojamiento, Restaurante, Evento, Excursión, Aparcamiento y Otro son las seis que hace esta pestaña, y el formulario cambia con la elección: solo Alojamiento cambia sus fechas por un rango de días.', 'help.guide.create-booking.step.3': 'Escribe el Título. Es el único campo en el que el formulario insiste, y Añadir sigue muerto mientras esté vacío.', 'help.guide.create-booking.step.4': @@ -3292,9 +3292,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Decir para quién es una reserva', 'help.guide.booking-travelers.goal': 'Marca los viajeros que cubre una reserva y luego ve solo las suyas.', 'help.guide.booking-travelers.step.1': - 'Abre la reserva con el lápiz y baja hasta Viajeros. Ahí está cada miembro del viaje, los invitados con nombre incluidos.', + 'Abre la reserva con el lápiz. Viajeros está arriba del formulario, junto a Tipo de reserva, y pone Asignar viajeros mientras no haya nadie en la reserva.', 'help.guide.booking-travelers.step.2': - 'Haz clic en las personas para las que es esta reserva. Una elegida recibe una marca; vuelve a hacer clic para quitarla.', + 'Haz clic y elige las personas para las que es esta reserva; los invitados con nombre también están en la lista. Una elegida recibe una marca y su avatar en el campo. Vuelve a hacer clic en el nombre para quitarla.', 'help.guide.booking-travelers.step.3': 'Haz clic en Actualizar.', 'help.guide.booking-travelers.step.4': 'Arriba en la barra de herramientas, junto a los chips de clase, haz clic en el avatar de un viajero para ver solo sus reservas.', @@ -3326,21 +3326,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Lleva lo que cuesta una reserva a los Costes, repartido entre las personas que lo pagan.', 'help.guide.booking-cost.step.1': - 'Abre la reserva y baja al final del formulario. Bajo Costs está Create expense, con la nota Saves the booking, then opens the Costs editor.', + 'Abre la reserva y baja al final del formulario. Bajo Costes están Crear gasto y Vincular gasto existente, con la nota Guarda la reserva y luego abre el editor de gastos.', 'help.guide.booking-cost.step.2': - 'Haz clic en Create expense. La reserva se guarda, su formulario se cierra y el editor Costs se abre.', + 'Haz clic en Crear gasto. La reserva se guarda, su formulario se cierra y el editor de Costes se abre.', 'help.guide.booking-cost.step.3': '¿Para qué fue? ya es el título de la reserva. Mete el Importe total y comprueba la Moneda y el Día.', 'help.guide.booking-cost.step.4': 'Categoría es la que implica el tipo de reserva. Pon ¿Quién pagó? y cómo se reparte el importe.', 'help.guide.booking-cost.step.5': 'Haz clic en Añadir gasto.', 'help.guide.booking-cost.result': - 'El formulario de la reserva pone ahora Linked expense con el importe, y el mismo gasto está en la pestaña Costes, atado a esta reserva.', + 'El formulario de la reserva muestra ahora el gasto bajo Gastos vinculados con su importe, y el mismo gasto está en la pestaña Costes, atado a esta reserva.', 'help.guide.booking-cost.tip.1': 'La categoría sigue al tipo: Restaurante pasa a Comida y bebida, Alojamiento pasa a Alojamiento, Aparcamiento pasa a Aparcamiento, y Evento y Excursión aterrizan ambos en Otros.', - 'help.guide.booking-cost.tip.2': 'La papelera de Linked expense quita el gasto, no la reserva.', + 'help.guide.booking-cost.tip.2': + 'Una reserva puede llevar varios gastos. Vincular gasto existente ofrece los de Costes que aún no pertenecen a nada. En uno vinculado, Desvincular, conservar el gasto lo suelta y lo deja en Costes, mientras que la papelera lo quita.', 'help.guide.booking-cost.tip.3': - 'Costs solo está en el formulario mientras el complemento Costes está activado, algo que el administrador conmuta en Complementos.', + 'Costes solo está en el formulario mientras el complemento Costes está activado, algo que el administrador conmuta en Complementos.', // filter-bookings 'help.guide.filter-bookings.title': 'Encontrar una reserva', 'help.guide.filter-bookings.goal': 'Reduce una pestaña larga a la clase, la persona o el estado que buscas.', @@ -3394,7 +3395,7 @@ const help: TranslationStrings = { 'help.guide.edit-booking.tip.1': 'Un Código de reserva que no puedes leer es Difuminar códigos de reserva en Ajustes, bajo Pantalla. Pasa el ratón por encima, o haz clic, y queda legible.', 'help.guide.edit-booking.tip.2': - 'Cambia el tipo y la categoría de un gasto vinculado le sigue, salvo que hubieras elegido una categoría a mano en el editor Costs.', + 'Cambia el tipo y la categoría de un gasto vinculado le sigue, salvo que hubieras elegido una categoría a mano en el editor de Costes.', 'help.guide.edit-booking.tip.3': 'Un alojamiento se edita aquí también: sus días Desde y Hasta están en el mismo formulario.', // delete-booking @@ -3412,7 +3413,7 @@ const help: TranslationStrings = { 'Los documentos que estaban adjuntos se quedan en la pestaña Archivos del viaje; solo se va su vínculo con la reserva.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Cada reserva que se encontró se abre en Nueva reserva, una tras otra, ya rellenada. Para un hotel eso es el nombre en Título y, cuando el viaje tiene el lugar, bajo Alojamiento, su Ubicación / dirección, Desde y Hasta en sus noches, Registro de entrada y Registro de salida, el Código de reserva, la confirmación bajo Archivos y, con Costes activado, el precio como Linked expense. Compruébala y haz clic en Añadir.', + 'Cada reserva que se encontró se abre en Nueva reserva, una tras otra, ya rellenada. Para un hotel eso es el nombre en Título y, cuando el viaje tiene el lugar, bajo Alojamiento, su Ubicación / dirección, Desde y Hasta en sus noches, Registro de entrada y Registro de salida, el Código de reserva, la confirmación bajo Archivos y, con Costes activado, el precio como Gasto vinculado. Compruébala y haz clic en Añadir.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Costes', @@ -3583,17 +3584,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Abre la reserva en la pestaña Transportes o Reservas y haz clic en su lápiz.', 'help.guide.expense-from-booking.step.2': - 'Baja hasta el bloque Costs al final del formulario. Mientras no haya nada vinculado, ofrece Create expense y dice que primero guarda la reserva.', + 'Baja hasta el bloque Costes al final del formulario. Ofrece Crear gasto, que primero guarda la reserva, y Vincular gasto existente para uno que ya está en la pestaña Costes.', 'help.guide.expense-from-booking.step.3': - 'Haz clic en Create expense. La reserva se guarda, el formulario se cierra, y el editor de Costes se abre con el título de la reserva como nombre y su tipo ya emparejado con una categoría.', + 'Haz clic en Crear gasto. La reserva se guarda, el formulario se cierra, y el editor de Costes se abre con el título de la reserva como nombre y su tipo ya emparejado con una categoría.', 'help.guide.expense-from-booking.step.4': - 'Rellena el importe y su moneda, quién pagó y el reparto como en cualquier gasto, y guarda. Al reabrir la reserva aparece bajo Linked expense, con un lápiz para editarlo y una papelera para quitarlo.', + 'Rellena el importe y su moneda, quién pagó y el reparto como en cualquier gasto, y guarda. Al reabrir la reserva aparece bajo Gastos vinculados, con un lápiz para editarlo, Desvincular, conservar el gasto para soltarlo y una papelera para quitarlo.', 'help.guide.expense-from-booking.result': 'La reserva lleva su coste, y el gasto es una fila corriente de la pestaña Costes, con un pagador, un reparto y una moneda como cualquier otro.', 'help.guide.expense-from-booking.tip.1': - 'Eliminar la reserva elimina con ella su gasto vinculado. Remove expense, en el bloque Costs de la reserva, hace lo contrario: el gasto se va, la reserva se queda.', + 'Eliminar la reserva elimina con ella sus gastos vinculados. Quitar gasto, en el bloque Costes de la reserva, hace lo contrario: el gasto se va, la reserva se queda. Desvincular, conservar el gasto mantiene ambos.', 'help.guide.expense-from-booking.tip.2': - 'Un lugar tiene el mismo bloque en su formulario, donde Create expense guarda primero el lugar.', + 'Un lugar tiene el mismo bloque en su formulario, donde Crear gasto guarda primero el lugar.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Transportes', @@ -3650,7 +3651,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'La fila cae donde la pone su hora de inicio, tras la última parada que empieza antes. Su asa la arrastra a cualquier otro sitio del día, o a otro día.', 'help.guide.add-transport.tip.2': - 'Adjuntar archivo, bajo Archivos, recoge el billete, y Create expense, bajo Costs, guarda la reserva y abre el editor de Costes para el importe.', + 'Adjuntar archivo, bajo Archivos, recoge el billete, y Crear gasto, bajo Costes, guarda la reserva y abre el editor de Costes para el importe.', 'help.guide.add-transport.tip.3': 'Viajeros marca quién va en este trayecto. En cuanto un transporte tiene viajeros, la barra de herramientas de la pestaña hace crecer sus avatares y filtra la lista por ellos.', // plan-transit diff --git a/shared/src/i18n/es/reservations.ts b/shared/src/i18n/es/reservations.ts index 2ad9b1d62..a104831b9 100644 --- a/shared/src/i18n/es/reservations.ts +++ b/shared/src/i18n/es/reservations.ts @@ -167,11 +167,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.', 'reservations.airtrail.importCta': 'Importar {count}', 'reservations.airtrail.joinConnection': 'Importar como un solo vuelo con escala en {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Costes', + 'reservations.createExpense': 'Crear gasto', + 'reservations.createExpenseHint': 'Guarda la reserva y luego abre el editor de gastos.', + 'reservations.linkedExpense': 'Gasto vinculado', + 'reservations.removeExpense': 'Quitar gasto', + 'reservations.linkedExpenses': 'Gastos vinculados', + 'reservations.linkExpense': 'Vincular gasto existente', + 'reservations.unlinkExpense': 'Desvincular, conservar el gasto', + 'reservations.noUnlinkedExpenses': 'No hay gastos sin vincular', 'reservations.urlLabel': 'Enlace', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Viajeros', diff --git a/shared/src/i18n/et/help.ts b/shared/src/i18n/et/help.ts index 330414e7c..a89deed4e 100644 --- a/shared/src/i18n/et/help.ts +++ b/shared/src/i18n/et/help.ts @@ -2857,7 +2857,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Klõpsa vahekaardi paremas ülanurgas nuppu Käsitsi broneering. Avaneb Uus broneering.', 'help.guide.create-booking.step.2': - 'Vali Broneeringu tüüp. Majutus, Restoran, Üritus, Ekskursioon, Parkimine ja Muu on kuus tüüpi, mida see vahekaart loob, ja vorm muutub vastavalt valikule: ainult Majutus asendab kuupäevad päevade vahemikuga.', + 'Vali Broneeringu tüüp vormi ülaosas olevast loendist, välja Reisijad kõrval. Majutus, Restoran, Üritus, Ekskursioon, Parkimine ja Muu on kuus tüüpi, mida see vahekaart loob, ja vorm muutub vastavalt valikule: ainult Majutus asendab kuupäevad päevade vahemikuga.', 'help.guide.create-booking.step.3': 'Sisesta Pealkiri. See on ainus kohustuslik väli ja Lisa jääb passiivseks, kuni selles midagi on.', 'help.guide.create-booking.step.4': @@ -2914,9 +2914,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Märgi reisijad, keda broneering hõlmab, ja vaata seejärel ainult nende broneeringuid.', 'help.guide.booking-travelers.step.1': - 'Ava broneering pliiatsiga ja mine alla jaotiseni Reisijad. Seal on kõik reisi liikmed, nende hulgas nimega külalised.', + 'Ava broneering pliiatsiga. Reisijad asub vormi ülaosas, välja Broneeringu tüüp kõrval, ja näitab teksti Määra reisijad, kuni broneeringul pole kedagi.', 'help.guide.booking-travelers.step.2': - 'Klõpsa inimestel, kellele see broneering on. Valitud inimene saab linnukese; klõpsa uuesti, et see eemaldada.', + 'Klõpsa sellel ja vali inimesed, kellele see broneering on; nimega külalised on samuti loendis. Valitud inimene saab linnukese ja tema avatar kuvatakse väljal. Klõpsa nimel uuesti, et see eemaldada.', 'help.guide.booking-travelers.step.3': 'Klõpsa Uuenda.', 'help.guide.booking-travelers.step.4': 'Klõpsa üleval tööriistaribal tüübikiipide kõrval reisija avataril, et näha ainult tema broneeringuid.', @@ -2946,7 +2946,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Muuda broneeringu hind kuluks', 'help.guide.booking-cost.goal': 'Vii broneeringu hind jaotisse Kulud ja jaga see maksjate vahel.', 'help.guide.booking-cost.step.1': - 'Ava broneering ja mine vormi lõppu. Jaotises Kulud on Loo kulu koos märkusega Salvestab broneeringu ja avab seejärel kulude muutmise vaate.', + 'Ava broneering ja mine vormi lõppu. Jaotises Kulud on Loo kulu ja Seo olemasolev kulu koos märkusega Salvestab broneeringu ja avab seejärel kulude muutmise vaate.', 'help.guide.booking-cost.step.2': 'Klõpsa Loo kulu. Broneering salvestatakse, selle vorm sulgub ja avaneb kulude muutmise vaade.', 'help.guide.booking-cost.step.3': @@ -2955,10 +2955,11 @@ const help: TranslationStrings = { 'Kategooria tuleneb broneeringu tüübist. Määra Kes maksis? ja kuidas summa jagatakse.', 'help.guide.booking-cost.step.5': 'Klõpsa Lisa kulu.', 'help.guide.booking-cost.result': - 'Broneeringu vormis on nüüd Seotud kulu koos summaga ja sama kulu on vahekaardil Kulud, seotud selle broneeringuga.', + 'Broneeringu vormis on kulu nüüd jaotises Seotud kulud koos summaga ja sama kulu on vahekaardil Kulud, seotud selle broneeringuga.', 'help.guide.booking-cost.tip.1': 'Kategooria järgib tüüpi: Restoranist saab Söök ja jook, Majutusest Majutus, Parkimisest Parkimine ning Üritus ja Ekskursioon lähevad mõlemad kategooriasse Muu.', - 'help.guide.booking-cost.tip.2': 'Seotud kulu juures olev prügikast eemaldab kulu, mitte broneeringu.', + 'help.guide.booking-cost.tip.2': + 'Broneeringul võib olla mitu kulu. Seo olemasolev kulu pakub vahekaardilt Kulud neid kulusid, mis pole veel millegagi seotud. Seotud kulu juures vabastab Eemalda seos, jäta kulu alles selle ja jätab selle vahekaardile Kulud, prügikast aga eemaldab selle.', 'help.guide.booking-cost.tip.3': 'Kulud on vormis ainult siis, kui lisamoodul Kulud on sisse lülitatud; administraator lülitab selle jaotises Lisamoodulid.', // filter-bookings @@ -3382,15 +3383,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Ava broneering vahekaardil Transport või Broneeringud ja klõpsa selle pliiatsit.', 'help.guide.expense-from-booking.step.2': - 'Keri vormi allosas olevasse plokki Kulud. Kui midagi pole veel seotud, pakub see valikut Loo kulu ja ütleb, et broneering salvestatakse esmalt.', + 'Keri vormi allosas olevasse plokki Kulud. See pakub valikut Loo kulu, mis salvestab broneeringu esmalt, ja valikut Seo olemasolev kulu sellise kulu jaoks, mis on juba vahekaardil Kulud.', 'help.guide.expense-from-booking.step.3': 'Klõpsa Loo kulu. Broneering salvestatakse, vorm suletakse ja avaneb kulude redaktor, kus nimeks on broneeringu pealkiri ja selle tüübile vastav kategooria on juba valitud.', 'help.guide.expense-from-booking.step.4': - 'Täida summa ja selle valuuta, maksja ja jaotus nagu iga kulu puhul ning salvesta. Kui broneeringu uuesti avad, on kulu näha jaotises Seotud kulu, koos pliiatsiga muutmiseks ja prügikastiga eemaldamiseks.', + 'Täida summa ja selle valuuta, maksja ja jaotus nagu iga kulu puhul ning salvesta. Kui broneeringu uuesti avad, on kulu näha jaotises Seotud kulud, koos pliiatsiga muutmiseks, valikuga Eemalda seos, jäta kulu alles lahtisidumiseks ja prügikastiga eemaldamiseks.', 'help.guide.expense-from-booking.result': 'Broneeringul on nüüd oma hind ja kulu on tavaline rida vahekaardil Kulud, maksja, jaotuse ja valuutaga nagu iga teine.', 'help.guide.expense-from-booking.tip.1': - 'Broneeringu kustutamine kustutab ka sellega seotud kulu. Broneeringu plokis Kulud olev Eemalda kulu teeb vastupidi: kulu kaob, broneering jääb alles.', + 'Broneeringu kustutamine kustutab ka sellega seotud kulud. Broneeringu plokis Kulud olev Eemalda kulu teeb vastupidi: kulu kaob, broneering jääb alles. Eemalda seos, jäta kulu alles hoiab alles mõlemad.', 'help.guide.expense-from-booking.tip.2': 'Koha vormis on sama plokk ja Loo kulu salvestab esmalt koha.', // ── Screen: trip-files ──────────────────────────────────────────────────────────────── diff --git a/shared/src/i18n/et/reservations.ts b/shared/src/i18n/et/reservations.ts index 69fe19b5d..d747c4787 100644 --- a/shared/src/i18n/et/reservations.ts +++ b/shared/src/i18n/et/reservations.ts @@ -173,6 +173,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': 'Salvestab broneeringu ja avab seejärel kulude muutmise vaate.', 'reservations.linkedExpense': 'Seotud kulu', 'reservations.removeExpense': 'Eemalda kulu', + 'reservations.linkedExpenses': 'Seotud kulud', + 'reservations.linkExpense': 'Seo olemasolev kulu', + 'reservations.unlinkExpense': 'Eemalda seos, jäta kulu alles', + 'reservations.noUnlinkedExpenses': 'Sidumata kulusid pole', 'reservations.travelers.label': 'Reisijad', 'reservations.travelers.assign': 'Määra reisijad', 'reservations.travelers.none': 'Reisil pole veel liikmeid.', diff --git a/shared/src/i18n/fr/help.ts b/shared/src/i18n/fr/help.ts index 9eabf3eb5..4bb465305 100644 --- a/shared/src/i18n/fr/help.ts +++ b/shared/src/i18n/fr/help.ts @@ -1933,7 +1933,7 @@ const help: TranslationStrings = { 'help.guide.create-place.result': 'Le lieu est dans la liste et sur la carte, sous Non planifiés tant qu’il n’est pas posé sur un jour.', 'help.guide.create-place.tip.1': - 'Fichiers et Costs en bas du formulaire attachent un document au lieu, ou ouvrent l’éditeur Costs pour sa dépense juste après l’enregistrement.', + 'Fichiers et Coûts en bas du formulaire attachent un document au lieu, ou ouvrent l’éditeur de Coûts pour sa dépense juste après l’enregistrement.', 'help.guide.create-place.tip.2': 'L’index TREK et OpenStreetMap répondent à la recherche sur chaque TREK, et Détails du lieu se remplit depuis Wikipédia, Wikivoyage et Wikimedia. Google n’est interrogé que là où les deux restent vides, et lui seul apporte les notes.', 'help.guide.create-place.tip.3': @@ -3296,7 +3296,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Cliquez sur Réservation manuelle en haut à droite de l’onglet. Nouvelle réservation s’ouvre.', 'help.guide.create-booking.step.2': - 'Choisissez le Type de réservation. Hébergement, Restaurant, Événement, Visite, Parking et Autre sont les six que cet onglet crée, et le formulaire change avec le choix : seul Hébergement échange ses dates contre une plage de jours.', + 'Choisissez le Type de réservation dans la liste en haut du formulaire, à côté de Voyageurs. Hébergement, Restaurant, Événement, Visite, Parking et Autre sont les six que cet onglet crée, et le formulaire change avec le choix : seul Hébergement échange ses dates contre une plage de jours.', 'help.guide.create-booking.step.3': 'Tapez le Titre. C’est le seul champ sur lequel le formulaire insiste, et Ajouter reste mort tant qu’il est vide.', 'help.guide.create-booking.step.4': @@ -3353,9 +3353,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Dire pour qui est une réservation', 'help.guide.booking-travelers.goal': 'Marquez les voyageurs que couvre une réservation, puis ne voyez que les leurs.', 'help.guide.booking-travelers.step.1': - 'Ouvrez la réservation avec le crayon et descendez jusqu’à Voyageurs. Chaque membre du voyage s’y trouve, les invités nommés compris.', + 'Ouvrez la réservation avec le crayon. Voyageurs se trouve en haut du formulaire, à côté de Type de réservation, et affiche Assigner des voyageurs tant que personne n’est sur la réservation.', 'help.guide.booking-travelers.step.2': - 'Cliquez les personnes pour qui est cette réservation. Une personne choisie reçoit une coche ; cliquez-la de nouveau pour l’enlever.', + 'Cliquez dessus et choisissez les personnes pour qui est cette réservation ; les invités nommés sont aussi dans la liste. Une personne choisie reçoit une coche et son avatar dans le champ. Cliquez de nouveau sur le nom pour l’enlever.', 'help.guide.booking-travelers.step.3': 'Cliquez sur Mettre à jour.', 'help.guide.booking-travelers.step.4': 'En haut, dans la barre d’outils, à côté des pastilles de sorte, cliquez l’avatar d’un voyageur pour ne voir que ses réservations.', @@ -3387,21 +3387,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Faites entrer ce que coûte une réservation dans les Coûts, partagé entre les personnes qui paient.', 'help.guide.booking-cost.step.1': - 'Ouvrez la réservation et descendez au bas du formulaire. Sous Costs se trouve Create expense, avec la note Saves the booking, then opens the Costs editor.', + 'Ouvrez la réservation et descendez au bas du formulaire. Sous Coûts se trouvent Créer une dépense et Lier une dépense existante, avec la note Enregistre la réservation, puis ouvre l’éditeur de dépenses.', 'help.guide.booking-cost.step.2': - 'Cliquez sur Create expense. La réservation est enregistrée, son formulaire se ferme et l’éditeur Costs s’ouvre.', + 'Cliquez sur Créer une dépense. La réservation est enregistrée, son formulaire se ferme et l’éditeur de Coûts s’ouvre.', 'help.guide.booking-cost.step.3': 'C’était pour quoi ? est déjà le titre de la réservation. Entrez le Montant total et vérifiez la Devise et le Jour.', 'help.guide.booking-cost.step.4': 'Catégorie est celle qu’implique le type de réservation. Réglez Qui a payé ? et la façon dont le montant est partagé.', 'help.guide.booking-cost.step.5': 'Cliquez sur Ajouter une dépense.', 'help.guide.booking-cost.result': - 'Le formulaire de la réservation affiche maintenant Linked expense avec le montant, et la même dépense se trouve dans l’onglet Coûts, rattachée à cette réservation.', + 'Le formulaire de la réservation affiche maintenant la dépense sous Dépenses liées avec son montant, et la même dépense se trouve dans l’onglet Coûts, rattachée à cette réservation.', 'help.guide.booking-cost.tip.1': 'La catégorie suit le type : Restaurant devient Nourriture et boissons, Hébergement devient Hébergement, Parking devient Parking, et Événement et Visite atterrissent tous deux dans Autre.', - 'help.guide.booking-cost.tip.2': 'La corbeille sur Linked expense retire la dépense, pas la réservation.', + 'help.guide.booking-cost.tip.2': + 'Une réservation peut porter plusieurs dépenses. Lier une dépense existante propose celles de Coûts qui ne sont encore rattachées à rien. Sur une dépense liée, Délier, garder la dépense la détache et la laisse dans Coûts, tandis que la corbeille la retire.', 'help.guide.booking-cost.tip.3': - 'Costs n’est dans le formulaire que tant que l’extension Coûts est activée, ce que l’administrateur règle sous Extensions.', + 'Coûts n’est dans le formulaire que tant que l’extension Coûts est activée, ce que l’administrateur règle sous Extensions.', // filter-bookings 'help.guide.filter-bookings.title': 'Retrouver une réservation', 'help.guide.filter-bookings.goal': 'Réduisez un onglet long à la sorte, à la personne ou à l’état que vous cherchez.', @@ -3455,7 +3456,7 @@ const help: TranslationStrings = { 'help.guide.edit-booking.tip.1': 'Un Code de réservation que vous n’arrivez pas à lire, c’est Masquer les codes de réservation dans les Paramètres, sous Affichage. Survolez-le, ou cliquez-le, et il devient lisible.', 'help.guide.edit-booking.tip.2': - 'Changez le type et la catégorie d’une dépense liée suit, sauf si vous aviez choisi une catégorie à la main dans l’éditeur Costs.', + 'Changez le type et la catégorie d’une dépense liée suit, sauf si vous aviez choisi une catégorie à la main dans l’éditeur de Coûts.', 'help.guide.edit-booking.tip.3': 'Un hébergement se modifie ici aussi : ses jours Du et Au sont dans le même formulaire.', // delete-booking @@ -3473,7 +3474,7 @@ const help: TranslationStrings = { 'Les documents qui étaient joints restent dans l’onglet Fichiers du voyage ; seul leur lien avec la réservation s’en va.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Chaque réservation trouvée s’ouvre dans Nouvelle réservation, l’une après l’autre, déjà remplie. Pour un hôtel, c’est le nom dans Titre et, quand le voyage a le lieu, sous Hébergement, son Lieu / Adresse, Du et Au sur ses nuits, Arrivée et Départ, le Code de réservation, la confirmation sous Fichiers et, avec Coûts activé, le prix en Linked expense. Contrôlez-la et cliquez sur Ajouter.', + 'Chaque réservation trouvée s’ouvre dans Nouvelle réservation, l’une après l’autre, déjà remplie. Pour un hôtel, c’est le nom dans Titre et, quand le voyage a le lieu, sous Hébergement, son Lieu / Adresse, Du et Au sur ses nuits, Arrivée et Départ, le Code de réservation, la confirmation sous Fichiers et, avec Coûts activé, le prix en Dépense liée. Contrôlez-la et cliquez sur Ajouter.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Coûts', @@ -3645,17 +3646,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Ouvrez la réservation dans l’onglet Transports ou Réservations et cliquez sur son crayon.', 'help.guide.expense-from-booking.step.2': - 'Faites défiler jusqu’au bloc Costs en bas du formulaire. Tant que rien n’est lié, il propose Create expense et indique qu’il enregistre d’abord la réservation.', + 'Faites défiler jusqu’au bloc Coûts en bas du formulaire. Il propose Créer une dépense, qui enregistre d’abord la réservation, et Lier une dépense existante pour une dépense déjà dans l’onglet Coûts.', 'help.guide.expense-from-booking.step.3': - 'Cliquez sur Create expense. La réservation est enregistrée, le formulaire se ferme, et l’éditeur de Coûts s’ouvre avec le titre de la réservation comme nom et son type déjà associé à une catégorie.', + 'Cliquez sur Créer une dépense. La réservation est enregistrée, le formulaire se ferme, et l’éditeur de Coûts s’ouvre avec le titre de la réservation comme nom et son type déjà associé à une catégorie.', 'help.guide.expense-from-booking.step.4': - 'Remplissez le montant et sa devise, qui a payé et le partage comme pour n’importe quelle dépense, puis enregistrez. En rouvrant la réservation, elle apparaît sous Linked expense, avec un crayon pour la modifier et une corbeille pour la retirer.', + 'Remplissez le montant et sa devise, qui a payé et le partage comme pour n’importe quelle dépense, puis enregistrez. En rouvrant la réservation, elle apparaît sous Dépenses liées, avec un crayon pour la modifier, Délier, garder la dépense pour la détacher et une corbeille pour la retirer.', 'help.guide.expense-from-booking.result': 'La réservation porte son coût, et la dépense est une ligne ordinaire de l’onglet Coûts, avec un payeur, un partage et une devise comme n’importe quelle autre.', 'help.guide.expense-from-booking.tip.1': - 'Supprimer la réservation supprime aussi sa dépense liée. Remove expense dans le bloc Costs de la réservation fait l’inverse : la dépense part, la réservation reste.', + 'Supprimer la réservation supprime aussi ses dépenses liées. Retirer la dépense dans le bloc Coûts de la réservation fait l’inverse : la dépense part, la réservation reste. Délier, garder la dépense garde les deux.', 'help.guide.expense-from-booking.tip.2': - 'Un lieu a le même bloc dans son formulaire, où Create expense enregistre d’abord le lieu.', + 'Un lieu a le même bloc dans son formulaire, où Créer une dépense enregistre d’abord le lieu.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Transports', @@ -3712,7 +3713,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'La ligne se pose là où son heure de début la met, après la dernière étape qui commence plus tôt. Sa poignée la glisse n’importe où ailleurs dans le jour, ou sur un autre jour.', 'help.guide.add-transport.tip.2': - 'Joindre un fichier sous Fichiers prend le billet, et Create expense sous Costs enregistre la réservation et ouvre l’éditeur de Coûts pour le prix.', + 'Joindre un fichier sous Fichiers prend le billet, et Créer une dépense sous Coûts enregistre la réservation et ouvre l’éditeur de Coûts pour le prix.', 'help.guide.add-transport.tip.3': 'Voyageurs marque qui est de ce trajet. Dès qu’un transport a des voyageurs, la barre d’outils de l’onglet fait pousser leurs avatars et filtre la liste par eux.', // plan-transit diff --git a/shared/src/i18n/fr/reservations.ts b/shared/src/i18n/fr/reservations.ts index 5cb4a263a..9444974de 100644 --- a/shared/src/i18n/fr/reservations.ts +++ b/shared/src/i18n/fr/reservations.ts @@ -170,11 +170,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.', 'reservations.airtrail.importCta': 'Importer {count}', 'reservations.airtrail.joinConnection': 'Importer comme un seul vol avec escale à {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Coûts', + 'reservations.createExpense': 'Créer une dépense', + 'reservations.createExpenseHint': 'Enregistre la réservation, puis ouvre l’éditeur de dépenses.', + 'reservations.linkedExpense': 'Dépense liée', + 'reservations.removeExpense': 'Retirer la dépense', + 'reservations.linkedExpenses': 'Dépenses liées', + 'reservations.linkExpense': 'Lier une dépense existante', + 'reservations.unlinkExpense': 'Délier, garder la dépense', + 'reservations.noUnlinkedExpenses': 'Aucune dépense non liée', 'reservations.urlLabel': 'Lien', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Voyageurs', diff --git a/shared/src/i18n/gr/help.ts b/shared/src/i18n/gr/help.ts index e7067088e..f63407643 100644 --- a/shared/src/i18n/gr/help.ts +++ b/shared/src/i18n/gr/help.ts @@ -1942,7 +1942,7 @@ const help: TranslationStrings = { 'help.guide.create-place.result': 'Το μέρος είναι στη λίστα και στον χάρτη, κάτω από τα Μη προγραμματισμένα ώσπου να μπει σε μια ημέρα.', 'help.guide.create-place.tip.1': - 'Τα Αρχεία και το Costs στο κάτω μέρος της φόρμας επισυνάπτουν ένα έγγραφο στο μέρος ή ανοίγουν τον επεξεργαστή Costs για το έξοδό του αμέσως μετά την αποθήκευση.', + 'Τα Αρχεία και τα Κόστη στο κάτω μέρος της φόρμας επισυνάπτουν ένα έγγραφο στο μέρος ή ανοίγουν τον επεξεργαστή Κόστη για το έξοδό του αμέσως μετά την αποθήκευση.', 'help.guide.create-place.tip.2': 'Το ευρετήριο TREK και το OpenStreetMap απαντούν στην αναζήτηση σε κάθε TREK, και οι Λεπτομέρειες τοποθεσίας γεμίζουν μόνες τους από τη Wikipedia, το Wikivoyage και το Wikimedia. Το Google ρωτιέται μόνο εκεί όπου και τα δύο βγαίνουν άδεια, και μόνο αυτό φέρνει βαθμολογία.', 'help.guide.create-place.tip.3': @@ -3309,7 +3309,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Κάντε κλικ στο Χειροκίνητη Κράτηση πάνω δεξιά στην καρτέλα. Ανοίγει η Νέα Κράτηση.', 'help.guide.create-booking.step.2': - 'Διαλέξτε τον Τύπο Κράτησης. Το Κατάλυμα, το Εστιατόριο, η Εκδήλωση, η Περιήγηση, η Στάθμευση και το Άλλο είναι τα έξι που φτιάχνει αυτή η καρτέλα, και η φόρμα αλλάζει μαζί με την επιλογή: μόνο το Κατάλυμα ανταλλάσσει τις ημερομηνίες του με ένα εύρος ημερών.', + 'Διαλέξτε τον Τύπο Κράτησης από τη λίστα στο πάνω μέρος της φόρμας, δίπλα στους Ταξιδιώτες. Το Κατάλυμα, το Εστιατόριο, η Εκδήλωση, η Περιήγηση, η Στάθμευση και το Άλλο είναι τα έξι που φτιάχνει αυτή η καρτέλα, και η φόρμα αλλάζει μαζί με την επιλογή: μόνο το Κατάλυμα ανταλλάσσει τις ημερομηνίες του με ένα εύρος ημερών.', 'help.guide.create-booking.step.3': 'Γράψτε τον Τίτλο. Είναι το μοναδικό πεδίο στο οποίο επιμένει η φόρμα, και η Προσθήκη μένει νεκρή όσο δεν έχει κάτι μέσα.', 'help.guide.create-booking.step.4': @@ -3367,9 +3367,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Σημειώστε τους ταξιδιώτες που καλύπτει μια κράτηση, και μετά δείτε μόνο τις δικές τους.', 'help.guide.booking-travelers.step.1': - 'Ανοίξτε την κράτηση με το μολύβι και κατεβείτε ως τους Ταξιδιώτες. Εκεί στέκεται κάθε μέλος του ταξιδιού, και οι επώνυμοι επισκέπτες ανάμεσά τους.', + 'Ανοίξτε την κράτηση με το μολύβι. Οι Ταξιδιώτες βρίσκονται στο πάνω μέρος της φόρμας, δίπλα στον Τύπο Κράτησης, και γράφουν Ανάθεση ταξιδιωτών όσο δεν υπάρχει κανείς στην κράτηση.', 'help.guide.booking-travelers.step.2': - 'Κάντε κλικ στα άτομα για τα οποία είναι αυτή η κράτηση. Όποιος επιλεγεί παίρνει ένα σημάδι επιλογής· κάντε ξανά κλικ για να το αφαιρέσετε.', + 'Κάντε κλικ εκεί και διαλέξτε τα άτομα για τα οποία είναι αυτή η κράτηση· οι επώνυμοι επισκέπτες είναι κι αυτοί στη λίστα. Όποιος επιλεγεί παίρνει ένα σημάδι επιλογής και το άβατάρ του μπαίνει στο πεδίο. Κάντε ξανά κλικ στο όνομα για να τον αφαιρέσετε.', 'help.guide.booking-travelers.step.3': 'Κάντε κλικ στην Ενημέρωση.', 'help.guide.booking-travelers.step.4': 'Πάνω στη γραμμή εργαλείων, δίπλα στις ετικέτες τύπου, κάντε κλικ στο άβαταρ ενός ταξιδιώτη για να δείτε μόνο τις δικές του κρατήσεις.', @@ -3403,21 +3403,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Περάστε αυτό που κοστίζει μια κράτηση στα Κόστη, μοιρασμένο ανάμεσα στους ανθρώπους που την πληρώνουν.', 'help.guide.booking-cost.step.1': - 'Ανοίξτε την κράτηση και πηγαίνετε στο κάτω μέρος της φόρμας. Κάτω από το Costs στέκεται το Create expense, με τη σημείωση ότι αποθηκεύει την κράτηση και μετά ανοίγει τον επεξεργαστή κόστους.', + 'Ανοίξτε την κράτηση και πηγαίνετε στο κάτω μέρος της φόρμας. Κάτω από τα Κόστη στέκονται το Δημιουργία εξόδου και το Σύνδεση υπάρχοντος εξόδου, με τη σημείωση Αποθηκεύει την κράτηση και ανοίγει τον επεξεργαστή εξόδων.', 'help.guide.booking-cost.step.2': - 'Κάντε κλικ στο Create expense. Η κράτηση αποθηκεύεται, η φόρμα της κλείνει και ανοίγει ο επεξεργαστής κόστους.', + 'Κάντε κλικ στο Δημιουργία εξόδου. Η κράτηση αποθηκεύεται, η φόρμα της κλείνει και ανοίγει ο επεξεργαστής κόστους.', 'help.guide.booking-cost.step.3': 'Το Για τι ήταν; είναι ήδη ο τίτλος της κράτησης. Βάλτε το Συνολικό ποσό και ελέγξτε το Νόμισμα και την Ημέρα.', 'help.guide.booking-cost.step.4': 'Η Κατηγορία είναι αυτή που υπονοεί ο τύπος της κράτησης. Ορίστε το Ποιος πλήρωσε; και τον τρόπο που μοιράζεται το ποσό.', 'help.guide.booking-cost.step.5': 'Κάντε κλικ στην Προσθήκη εξόδου.', 'help.guide.booking-cost.result': - 'Η φόρμα της κράτησης γράφει τώρα Linked expense με το ποσό, και το ίδιο έξοδο στέκεται στην καρτέλα Κόστη, δεμένο με αυτή την κράτηση.', + 'Η φόρμα της κράτησης δείχνει τώρα το έξοδο κάτω από τα Συνδεδεμένα έξοδα με το ποσό του, και το ίδιο έξοδο στέκεται στην καρτέλα Κόστη, δεμένο με αυτή την κράτηση.', 'help.guide.booking-cost.tip.1': 'Η κατηγορία ακολουθεί τον τύπο: το Εστιατόριο γίνεται Φαγητό & ποτό, το Κατάλυμα γίνεται Διαμονή, η Στάθμευση γίνεται Στάθμευση, και η Εκδήλωση με την Περιήγηση προσγειώνονται και οι δύο στα Άλλα.', - 'help.guide.booking-cost.tip.2': 'Ο κάδος στο Linked expense αφαιρεί το έξοδο, όχι την κράτηση.', + 'help.guide.booking-cost.tip.2': + 'Μια κράτηση μπορεί να έχει πολλά έξοδα. Το Σύνδεση υπάρχοντος εξόδου προσφέρει όσα έξοδα στα Κόστη δεν ανήκουν ακόμη πουθενά. Σε ένα συνδεδεμένο, το Αποσύνδεση, διατήρηση του εξόδου το αποδεσμεύει και το κρατά στα Κόστη, ενώ ο κάδος το αφαιρεί.', 'help.guide.booking-cost.tip.3': - 'Το Costs βρίσκεται στη φόρμα μόνο όσο το πρόσθετο Κόστη είναι ενεργό, το οποίο ο διαχειριστής ανοιγοκλείνει κάτω από τα Πρόσθετα.', + 'Τα Κόστη βρίσκονται στη φόρμα μόνο όσο το πρόσθετο Κόστη είναι ενεργό, το οποίο ο διαχειριστής ανοιγοκλείνει κάτω από τα Πρόσθετα.', // filter-bookings 'help.guide.filter-bookings.title': 'Εύρεση κράτησης', 'help.guide.filter-bookings.goal': 'Στενέψτε μια μακριά καρτέλα στον τύπο, στο άτομο ή στην κατάσταση που ψάχνετε.', @@ -3489,7 +3490,7 @@ const help: TranslationStrings = { 'Τα έγγραφα που ήταν επισυναπτόμενα μένουν στην καρτέλα Αρχεία του ταξιδιού· φεύγει μόνο ο σύνδεσμός τους με την κράτηση.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Κάθε κράτηση που βρέθηκε ανοίγει στη Νέα Κράτηση, η μία μετά την άλλη, ήδη συμπληρωμένη. Για ένα ξενοδοχείο αυτό είναι το όνομα στο πεδίο Τίτλος και, όταν το ταξίδι έχει το μέρος, κάτω από το Κατάλυμα, η Τοποθεσία / Διεύθυνση, το Από και το Προς στις νύχτες του, το Check-in και το Check-out, ο Κωδικός Κράτησης, η επιβεβαίωση κάτω από τα Αρχεία και, με ενεργά τα Κόστη, η τιμή ως Linked expense. Ελέγξτε την και κάντε κλικ στην Προσθήκη.', + 'Κάθε κράτηση που βρέθηκε ανοίγει στη Νέα Κράτηση, η μία μετά την άλλη, ήδη συμπληρωμένη. Για ένα ξενοδοχείο αυτό είναι το όνομα στο πεδίο Τίτλος και, όταν το ταξίδι έχει το μέρος, κάτω από το Κατάλυμα, η Τοποθεσία / Διεύθυνση, το Από και το Προς στις νύχτες του, το Check-in και το Check-out, ο Κωδικός Κράτησης, η επιβεβαίωση κάτω από τα Αρχεία και, με ενεργά τα Κόστη, η τιμή ως Συνδεδεμένο έξοδο. Ελέγξτε την και κάντε κλικ στην Προσθήκη.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Κόστη', @@ -3662,17 +3663,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Ανοίξτε την κράτηση στην καρτέλα Μεταφορές ή Κρατήσεις και κάντε κλικ στο μολύβι της.', 'help.guide.expense-from-booking.step.2': - 'Κυλήστε μέχρι το μπλοκ Costs στο κάτω μέρος της φόρμας. Όσο δεν έχει συνδεθεί τίποτα, προσφέρει Create expense και λέει ότι αποθηκεύει πρώτα την κράτηση.', + 'Κυλήστε μέχρι το μπλοκ Κόστη στο κάτω μέρος της φόρμας. Προσφέρει το Δημιουργία εξόδου, που αποθηκεύει πρώτα την κράτηση, και το Σύνδεση υπάρχοντος εξόδου για ένα έξοδο που είναι ήδη στα Κόστη.', 'help.guide.expense-from-booking.step.3': - 'Κάντε κλικ στο Create expense. Η κράτηση αποθηκεύεται, η φόρμα κλείνει, και ο επεξεργαστής Κόστη ανοίγει με τον τίτλο της κράτησης ως όνομα και τον τύπο της ήδη αντιστοιχισμένο σε μια κατηγορία.', + 'Κάντε κλικ στο Δημιουργία εξόδου. Η κράτηση αποθηκεύεται, η φόρμα κλείνει, και ο επεξεργαστής Κόστη ανοίγει με τον τίτλο της κράτησης ως όνομα και τον τύπο της ήδη αντιστοιχισμένο σε μια κατηγορία.', 'help.guide.expense-from-booking.step.4': - 'Συμπληρώστε το ποσό και το νόμισμά του, ποιος πλήρωσε και τον διαμοιρασμό όπως σε κάθε έξοδο, και αποθηκεύστε. Αν ξανανοίξετε την κράτηση, το έξοδο φαίνεται τώρα κάτω από το Linked expense, με ένα μολύβι για επεξεργασία και έναν κάδο για αφαίρεση.', + 'Συμπληρώστε το ποσό και το νόμισμά του, ποιος πλήρωσε και τον διαμοιρασμό όπως σε κάθε έξοδο, και αποθηκεύστε. Αν ξανανοίξετε την κράτηση, το έξοδο φαίνεται τώρα κάτω από τα Συνδεδεμένα έξοδα, με ένα μολύβι για επεξεργασία, το Αποσύνδεση, διατήρηση του εξόδου για αποδέσμευση και έναν κάδο για αφαίρεση.', 'help.guide.expense-from-booking.result': 'Η κράτηση φέρει το κόστος της, και το έξοδο είναι μια συνηθισμένη γραμμή στην καρτέλα Κόστη, με πληρωτή, διαμοιρασμό και νόμισμα όπως κάθε άλλο.', 'help.guide.expense-from-booking.tip.1': - 'Η διαγραφή της κράτησης διαγράφει μαζί της και το συνδεδεμένο έξοδο. Το Remove expense στο μπλοκ Costs της κράτησης κάνει το αντίθετο: το έξοδο φεύγει, η κράτηση μένει.', + 'Η διαγραφή της κράτησης διαγράφει μαζί της και τα συνδεδεμένα έξοδα. Το Αφαίρεση εξόδου στο μπλοκ Κόστη της κράτησης κάνει το αντίθετο: το έξοδο φεύγει, η κράτηση μένει. Το Αποσύνδεση, διατήρηση του εξόδου κρατά και τα δύο.', 'help.guide.expense-from-booking.tip.2': - 'Ένα μέρος έχει το ίδιο μπλοκ στη φόρμα του, όπου το Create expense αποθηκεύει πρώτα το μέρος.', + 'Ένα μέρος έχει το ίδιο μπλοκ στη φόρμα του, όπου το Δημιουργία εξόδου αποθηκεύει πρώτα το μέρος.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Μεταφορές', @@ -3729,7 +3730,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'Η σειρά προσγειώνεται εκεί που τη βάζει η ώρα έναρξής της, μετά την τελευταία στάση που ξεκινά νωρίτερα. Η λαβή της τη σέρνει οπουδήποτε αλλού μέσα στην ημέρα, ή σε άλλη ημέρα.', 'help.guide.add-transport.tip.2': - 'Η Επισύναψη αρχείου κάτω από τα Αρχεία παίρνει το εισιτήριο, και το Create expense κάτω από το Costs αποθηκεύει την κράτηση και ανοίγει τον επεξεργαστή Κόστη για το ναύλο.', + 'Η Επισύναψη αρχείου κάτω από τα Αρχεία παίρνει το εισιτήριο, και το Δημιουργία εξόδου κάτω από τα Κόστη αποθηκεύει την κράτηση και ανοίγει τον επεξεργαστή Κόστη για το ναύλο.', 'help.guide.add-transport.tip.3': 'Οι Ταξιδιώτες σημειώνουν ποιος είναι σε αυτή τη μετακίνηση. Μόλις μία μεταφορά έχει ταξιδιώτες, η γραμμή εργαλείων της καρτέλας βγάζει τα άβαταρ τους και φιλτράρει τη λίστα με βάση αυτούς.', // plan-transit diff --git a/shared/src/i18n/gr/reservations.ts b/shared/src/i18n/gr/reservations.ts index becb68ad5..b916556cd 100644 --- a/shared/src/i18n/gr/reservations.ts +++ b/shared/src/i18n/gr/reservations.ts @@ -168,11 +168,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.', 'reservations.airtrail.importCta': 'Εισαγωγή {count}', 'reservations.airtrail.joinConnection': 'Εισαγωγή ως μία πτήση με ενδιάμεση στάση σε {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Κόστη', + 'reservations.createExpense': 'Δημιουργία εξόδου', + 'reservations.createExpenseHint': 'Αποθηκεύει την κράτηση και ανοίγει τον επεξεργαστή εξόδων.', + 'reservations.linkedExpense': 'Συνδεδεμένο έξοδο', + 'reservations.removeExpense': 'Αφαίρεση εξόδου', + 'reservations.linkedExpenses': 'Συνδεδεμένα έξοδα', + 'reservations.linkExpense': 'Σύνδεση υπάρχοντος εξόδου', + 'reservations.unlinkExpense': 'Αποσύνδεση, διατήρηση του εξόδου', + 'reservations.noUnlinkedExpenses': 'Δεν υπάρχουν ασύνδετα έξοδα', 'reservations.urlLabel': 'Σύνδεσμος', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Ταξιδιώτες', diff --git a/shared/src/i18n/hu/help.ts b/shared/src/i18n/hu/help.ts index 763b06a23..1b360ecd0 100644 --- a/shared/src/i18n/hu/help.ts +++ b/shared/src/i18n/hu/help.ts @@ -1888,7 +1888,7 @@ const help: TranslationStrings = { 'help.guide.create-place.result': 'A hely benne van a listában és a térképen, a Nem tervezett alatt, amíg egy napra nem kerül.', 'help.guide.create-place.tip.1': - 'Az űrlap alján a Fájlok és a Costs dokumentumot csatol a helyhez, vagy mentés után rögtön megnyitja a Costs szerkesztőt a kiadásához.', + 'Az űrlap alján a Fájlok és a Költségek dokumentumot csatol a helyhez, vagy mentés után rögtön megnyitja a Költségek szerkesztőt a kiadásához.', 'help.guide.create-place.tip.2': 'A keresést minden TREK-en a TREK indexe és az OpenStreetMap válaszolja meg, a Hely részletei pedig a Wikipedia, a Wikivoyage és a Wikimedia forrásaiból tölti fel magát. A Google csak ott kerül sorra, ahol mindkettő üresen marad, és értékelést csak az hoz.', 'help.guide.create-place.tip.3': @@ -3231,7 +3231,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Kattints a fül jobb felső sarkában a Kézi foglalás gombra. Megnyílik az Új foglalás.', 'help.guide.create-booking.step.2': - 'Válaszd ki a Foglalás típusa értéket. A Szálloda, az Étterem, az Esemény, a Túra, a Parkolás és az Egyéb az a hat, amit ez a fül készít, és az űrlap a választással együtt változik: csak a Szálloda cseréli a dátumait egy napokból álló tartományra.', + 'Válaszd ki a Foglalás típusa értéket az űrlap tetején lévő listából, az Utasok mellett. A Szálloda, az Étterem, az Esemény, a Túra, a Parkolás és az Egyéb az a hat, amit ez a fül készít, és az űrlap a választással együtt változik: csak a Szálloda cseréli a dátumait egy napokból álló tartományra.', 'help.guide.create-booking.step.3': 'Írd be a Cím mezőt. Ez az egyetlen mező, amihez az űrlap ragaszkodik, és a Hozzáadás halott marad, amíg nincs benne valami.', 'help.guide.create-booking.step.4': @@ -3289,9 +3289,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Jelöld meg az utasokat, akikre a foglalás vonatkozik, és utána csak az övéiket nézd.', 'help.guide.booking-travelers.step.1': - 'Nyisd meg a foglalást a ceruzával, és menj le az Utasok mezőig. Ott áll az utazás minden tagja, köztük a megnevezett vendégek is.', + 'Nyisd meg a foglalást a ceruzával. Az Utasok az űrlap tetején áll, a Foglalás típusa mellett, és amíg senki sincs a foglaláson, az Utasok hozzárendelése felirat látszik rajta.', 'help.guide.booking-travelers.step.2': - 'Kattints azokra, akiknek ez a foglalás szól. A kiválasztott pipát kap; kattints rá újra, és leveszed.', + 'Kattints rá, és válaszd ki azokat, akiknek ez a foglalás szól; a megnevezett vendégek is ott vannak a listában. A kiválasztott pipát kap, és az avatarja megjelenik a mezőben. Kattints újra a névre, és leveszed.', 'help.guide.booking-travelers.step.3': 'Kattints a Frissítés gombra.', 'help.guide.booking-travelers.step.4': 'Fent az eszköztárban, a típuscímkék mellett kattints egy utas avatarjára, és csak az ő foglalásait látod.', @@ -3325,21 +3325,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Vidd be azt, amibe egy foglalás kerül, a Költségek közé, elosztva a fizető emberek között.', 'help.guide.booking-cost.step.1': - 'Nyisd meg a foglalást, és menj az űrlap aljára. A Costs alatt áll a Create expense, azzal a megjegyzéssel, hogy elmenti a foglalást, majd megnyitja a költségszerkesztőt.', + 'Nyisd meg a foglalást, és menj az űrlap aljára. A Költségek alatt áll a Költség létrehozása és a Meglévő költség csatolása, a következő megjegyzéssel: Menti a foglalást, majd megnyitja a költségszerkesztőt.', 'help.guide.booking-cost.step.2': - 'Kattints a Create expense gombra. A foglalás elmentődik, az űrlapja bezárul, és megnyílik a költségszerkesztő.', + 'Kattints a Költség létrehozása gombra. A foglalás elmentődik, az űrlapja bezárul, és megnyílik a költségszerkesztő.', 'help.guide.booking-cost.step.3': 'A Mire volt? már a foglalás címe. Vidd be a Teljes összeg mezőt, és ellenőrizd a Pénznem meg a Nap értékét.', 'help.guide.booking-cost.step.4': 'A Kategória az, amit a foglalás típusa sugall. Állítsd be a Ki fizetett? mezőt és azt, hogyan oszlik meg az összeg.', 'help.guide.booking-cost.step.5': 'Kattints a Költség hozzáadása gombra.', 'help.guide.booking-cost.result': - 'A foglalás űrlapján most már a Linked expense áll az összeggel, és ugyanaz a költség ott áll a Költségek fülön, ehhez a foglaláshoz kötve.', + 'A foglalás űrlapján a költség most a Kapcsolt költségek alatt áll az összegével, és ugyanaz a költség ott áll a Költségek fülön, ehhez a foglaláshoz kötve.', 'help.guide.booking-cost.tip.1': 'A kategória a típust követi: az Étteremből Étel és ital lesz, a Szállodából Szállás, a Parkolásból Parkolás, az Esemény és a Túra pedig mindkettő az Egyéb kategóriába esik.', - 'help.guide.booking-cost.tip.2': 'A Linked expense melletti kuka a költséget törli, nem a foglalást.', + 'help.guide.booking-cost.tip.2': + 'Egy foglaláshoz több költség is tartozhat. A Meglévő költség csatolása azokat kínálja a Költségekből, amelyek még sehova sem tartoznak. Egy kapcsolt költségnél a Leválasztás, a költség megmarad leválasztja, és a Költségekben hagyja, a kuka viszont törli.', 'help.guide.booking-cost.tip.3': - 'A Costs csak addig van az űrlapon, amíg a Költségek bővítmény be van kapcsolva, amit az adminisztrátor a Bővítmények alatt kapcsolgat.', + 'A Költségek blokk csak addig van az űrlapon, amíg a Költségek bővítmény be van kapcsolva, amit az adminisztrátor a Bővítmények alatt kapcsolgat.', // filter-bookings 'help.guide.filter-bookings.title': 'Foglalás megtalálása', 'help.guide.filter-bookings.goal': @@ -3412,7 +3413,7 @@ const help: TranslationStrings = { 'A csatolt dokumentumok az utazás Fájlok fülén maradnak; csak a foglaláshoz fűződő kapcsolatuk szűnik meg.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Minden megtalált foglalás az Új foglalás űrlapon nyílik meg, egymás után, már kitöltve. Egy szállodánál ez a név a Címben és, ha az utazásban megvan a hely, a Szálloda alatt, a Helyszín / Cím, az Ettől és az Eddig az éjszakáira, a Bejelentkezés és a Kijelentkezés, a Foglalási kód, a visszaigazolás a Fájlok alatt, és bekapcsolt Költségek mellett az ár Linked expense gyanánt. Ellenőrizd, és kattints a Hozzáadás gombra.', + 'Minden megtalált foglalás az Új foglalás űrlapon nyílik meg, egymás után, már kitöltve. Egy szállodánál ez a név a Címben és, ha az utazásban megvan a hely, a Szálloda alatt, a Helyszín / Cím, az Ettől és az Eddig az éjszakáira, a Bejelentkezés és a Kijelentkezés, a Foglalási kód, a visszaigazolás a Fájlok alatt, és bekapcsolt Költségek mellett az ár Kapcsolt költség gyanánt. Ellenőrizd, és kattints a Hozzáadás gombra.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Költségek', @@ -3584,17 +3585,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Nyisd meg a foglalást a Közlekedés vagy a Foglalások fülön, és kattints a ceruzájára.', 'help.guide.expense-from-booking.step.2': - 'Görgess az űrlap alján lévő Costs blokkhoz. Amíg semmi nincs hozzákapcsolva, a Create expense lehetőséget kínálja, és azt mondja, hogy előbb menti a foglalást.', + 'Görgess az űrlap alján lévő Költségek blokkhoz. A Költség létrehozása lehetőséget kínálja, amely előbb menti a foglalást, és a Meglévő költség csatolása lehetőséget egy olyan költséghez, amely már a Költségek fülön van.', 'help.guide.expense-from-booking.step.3': - 'Kattints a Create expense gombra. A foglalás mentődik, az űrlap bezárul, és megnyílik a Költségek szerkesztő a foglalás címével névként, a típusa pedig már egy kategóriához igazítva.', + 'Kattints a Költség létrehozása gombra. A foglalás mentődik, az űrlap bezárul, és megnyílik a Költségek szerkesztő a foglalás címével névként, a típusa pedig már egy kategóriához igazítva.', 'help.guide.expense-from-booking.step.4': - 'Töltsd ki az összeget és a pénznemét, azt, ki fizetett, és a felosztást, mint bármelyik kiadásnál, majd ments. A foglalást újra megnyitva a kiadás a Linked expense alatt látszik, ceruzával a szerkesztéshez és kukával az eltávolításhoz.', + 'Töltsd ki az összeget és a pénznemét, azt, ki fizetett, és a felosztást, mint bármelyik kiadásnál, majd ments. A foglalást újra megnyitva a kiadás a Kapcsolt költségek alatt látszik, ceruzával a szerkesztéshez, a Leválasztás, a költség megmarad gombbal a leválasztáshoz és kukával az eltávolításhoz.', 'help.guide.expense-from-booking.result': 'A foglalás viszi a költségét, a kiadás pedig egy hétköznapi sor a Költségek fülön, fizetővel, felosztással és pénznemmel, mint bármelyik másik.', 'help.guide.expense-from-booking.tip.1': - 'A foglalás törlése a hozzákapcsolt kiadást is törli vele. A foglalás Costs blokkjában a Remove expense az ellenkezőjét teszi: a kiadás eltűnik, a foglalás marad.', + 'A foglalás törlése a hozzákapcsolt kiadásokat is törli vele. A foglalás Költségek blokkjában a Költség eltávolítása az ellenkezőjét teszi: a kiadás eltűnik, a foglalás marad. A Leválasztás, a költség megmarad mindkettőt megtartja.', 'help.guide.expense-from-booking.tip.2': - 'Egy helynek ugyanez a blokk van az űrlapján, ahol a Create expense előbb a helyet menti.', + 'Egy helynek ugyanez a blokk van az űrlapján, ahol a Költség létrehozása előbb a helyet menti.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Közlekedés', @@ -3651,7 +3652,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'A sor oda kerül, ahová a kezdési ideje teszi, az utolsó olyan megálló után, amely korábban kezdődik. A fogantyúja bárhová máshová húzza a napon belül, vagy egy másik napra.', 'help.guide.add-transport.tip.2': - 'A Fájlok alatti Fájl csatolása veszi a jegyet, a Costs alatti Create expense pedig elmenti a foglalást, és megnyitja a Költségek szerkesztőt a viteldíjhoz.', + 'A Fájlok alatti Fájl csatolása veszi a jegyet, a Költségek alatti Költség létrehozása pedig elmenti a foglalást, és megnyitja a Költségek szerkesztőt a viteldíjhoz.', 'help.guide.add-transport.tip.3': 'Az Utasok jelöli, ki van rajta ezen az úton. Amint egy közlekedésnek utasai vannak, a fül eszköztára kinöveszti az avatarjaikat, és szűri szerintük a listát.', // plan-transit diff --git a/shared/src/i18n/hu/reservations.ts b/shared/src/i18n/hu/reservations.ts index 000c5886f..b58dd4651 100644 --- a/shared/src/i18n/hu/reservations.ts +++ b/shared/src/i18n/hu/reservations.ts @@ -168,11 +168,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.', 'reservations.airtrail.importCta': '{count} importálása', 'reservations.airtrail.joinConnection': 'Importálás egyetlen járatként, átszállással itt: {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Költségek', + 'reservations.createExpense': 'Költség létrehozása', + 'reservations.createExpenseHint': 'Menti a foglalást, majd megnyitja a költségszerkesztőt.', + 'reservations.linkedExpense': 'Kapcsolt költség', + 'reservations.removeExpense': 'Költség eltávolítása', + 'reservations.linkedExpenses': 'Kapcsolt költségek', + 'reservations.linkExpense': 'Meglévő költség csatolása', + 'reservations.unlinkExpense': 'Leválasztás, a költség megmarad', + 'reservations.noUnlinkedExpenses': 'Nincs kapcsolatlan költség', 'reservations.urlLabel': 'Hivatkozás', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Utasok', diff --git a/shared/src/i18n/id/help.ts b/shared/src/i18n/id/help.ts index 188b81b4e..77f6515ab 100644 --- a/shared/src/i18n/id/help.ts +++ b/shared/src/i18n/id/help.ts @@ -1882,7 +1882,7 @@ const help: TranslationStrings = { 'help.guide.create-place.result': 'Tempat itu ada di daftar dan di peta, di bawah “Belum direncanakan” sampai ditaruh pada sebuah hari.', 'help.guide.create-place.tip.1': - '“File” dan “Costs” di bagian bawah formulir melampirkan dokumen ke tempat itu, atau membuka editor “Costs” untuk pengeluarannya tepat setelah menyimpan.', + '“File” dan “Biaya” di bagian bawah formulir melampirkan dokumen ke tempat itu, atau membuka editor “Biaya” untuk pengeluarannya tepat setelah menyimpan.', 'help.guide.create-place.tip.2': 'Indeks TREK dan OpenStreetMap yang menjawab pencarian di setiap TREK, dan “Detail tempat” mengisi dirinya dari Wikipedia, Wikivoyage dan Wikimedia. Google baru ditanya di tempat yang keduanya tidak menemukan apa pun, dan hanya Google yang membawa penilaian.', 'help.guide.create-place.tip.3': @@ -3237,7 +3237,7 @@ const help: TranslationStrings = { 'Masukkan sebuah restoran, acara, tur, tempat parkir atau apa pun lainnya ke dalam perjalanan dengan tangan.', 'help.guide.create-booking.step.1': 'Klik “Pemesanan Manual” di kanan atas tab. “Reservasi Baru” terbuka.', 'help.guide.create-booking.step.2': - 'Pilih “Jenis Pemesanan”. “Akomodasi”, “Restoran”, “Acara”, “Tur”, “Parkir” dan “Lainnya” adalah enam jenis yang dibuat tab ini, dan formulirnya berubah mengikuti pilihan: hanya “Akomodasi” yang menukar tanggalnya dengan rentang hari.', + 'Pilih “Jenis Pemesanan” dari daftar di bagian atas formulir, di samping “Peserta”. “Akomodasi”, “Restoran”, “Acara”, “Tur”, “Parkir” dan “Lainnya” adalah enam jenis yang dibuat tab ini, dan formulirnya berubah mengikuti pilihan: hanya “Akomodasi” yang menukar tanggalnya dengan rentang hari.', 'help.guide.create-booking.step.3': 'Ketik “Judul”. Inilah satu-satunya isian yang diharuskan oleh formulir, dan “Tambah” tetap mati sampai ia terisi.', 'help.guide.create-booking.step.4': @@ -3293,9 +3293,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Menyebut untuk siapa sebuah pemesanan dibuat', 'help.guide.booking-travelers.goal': 'Tandai peserta yang dicakup sebuah pemesanan, lalu lihat hanya milik mereka.', 'help.guide.booking-travelers.step.1': - 'Buka pemesanan itu dengan pensil dan turun ke “Peserta”. Setiap anggota perjalanan berdiri di sana, termasuk “Tamu” yang bernama.', + 'Buka pemesanan itu dengan pensil. “Peserta” ada di bagian atas formulir, di samping “Jenis Pemesanan”, dan bertuliskan “Tetapkan peserta” selama belum ada siapa pun di pemesanan itu.', 'help.guide.booking-travelers.step.2': - 'Klik orang-orang yang menjadi tujuan pemesanan ini. Yang terpilih mendapat centang; klik lagi untuk melepasnya.', + 'Klik itu dan pilih orang-orang yang menjadi tujuan pemesanan ini; “Tamu” yang bernama juga ada di daftar. Yang terpilih mendapat centang dan avatarnya muncul di kolom itu. Klik namanya lagi untuk melepasnya.', 'help.guide.booking-travelers.step.3': 'Klik “Perbarui”.', 'help.guide.booking-travelers.step.4': 'Di bilah alat atas, di sebelah chip jenis, klik avatar seorang peserta untuk melihat hanya pemesanan miliknya.', @@ -3328,21 +3328,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Bawa apa yang dihabiskan sebuah pemesanan ke “Biaya”, terbagi di antara orang-orang yang membayarnya.', 'help.guide.booking-cost.step.1': - 'Buka pemesanan itu dan pergi ke kaki formulir. Di bawah “Costs” berdiri “Create expense”, dengan catatan “Saves the booking, then opens the Costs editor.”', + 'Buka pemesanan itu dan pergi ke kaki formulir. Di bawah “Biaya” berdiri “Buat pengeluaran” dan “Tautkan pengeluaran yang ada”, dengan catatan “Menyimpan pemesanan, lalu membuka editor biaya.”', 'help.guide.booking-cost.step.2': - 'Klik “Create expense”. Pemesanan itu tersimpan, formulirnya tertutup dan penyunting biaya terbuka.', + 'Klik “Buat pengeluaran”. Pemesanan itu tersimpan, formulirnya tertutup dan penyunting biaya terbuka.', 'help.guide.booking-cost.step.3': '“Untuk apa?” sudah berisi judul pemesanan itu. Masukkan “Jumlah total” dan periksa “Mata uang” serta “Hari”.', 'help.guide.booking-cost.step.4': '“Kategori” adalah yang tersirat dari jenis pemesanannya. Atur “Siapa yang membayar?” dan bagaimana jumlahnya dibagi lewat “Split”.', 'help.guide.booking-cost.step.5': 'Klik “Tambah pengeluaran”.', 'help.guide.booking-cost.result': - 'Formulir pemesanan itu kini berbunyi “Linked expense” dengan jumlahnya, dan pengeluaran yang sama berdiri di tab “Biaya”, terikat pada pemesanan ini.', + 'Formulir pemesanan itu kini mencantumkan pengeluarannya di bawah “Pengeluaran tertaut” beserta jumlahnya, dan pengeluaran yang sama berdiri di tab “Biaya”, terikat pada pemesanan ini.', 'help.guide.booking-cost.tip.1': 'Kategorinya mengikuti jenisnya: “Restoran” menjadi “Makanan & minuman”, “Akomodasi” menjadi “Akomodasi”, “Parkir” menjadi “Parkir”, dan “Acara” serta “Tur” keduanya mendarat di “Lainnya”.', - 'help.guide.booking-cost.tip.2': 'Tempat sampah pada “Linked expense” menghapus pengeluarannya, bukan pemesanannya.', + 'help.guide.booking-cost.tip.2': + 'Satu pemesanan bisa membawa beberapa pengeluaran. “Tautkan pengeluaran yang ada” menawarkan pengeluaran di “Biaya” yang belum menjadi milik apa pun. Pada pengeluaran yang tertaut, “Lepas tautan, simpan pengeluaran” melepasnya dan membiarkannya di “Biaya”, sementara tempat sampah menghapusnya.', 'help.guide.booking-cost.tip.3': - '“Costs” ada di formulir hanya selama addon “Biaya” menyala, yang dialihkan administrator di bawah “Addon”.', + '“Biaya” ada di formulir hanya selama addon “Biaya” menyala, yang dialihkan administrator di bawah “Addon”.', // filter-bookings 'help.guide.filter-bookings.title': 'Menemukan sebuah pemesanan', 'help.guide.filter-bookings.goal': 'Persempit tab yang panjang ke jenis, orang atau keadaan yang Anda cari.', @@ -3414,7 +3415,7 @@ const help: TranslationStrings = { 'Dokumen yang pernah dilampirkan tetap ada di tab “File” perjalanan; hanya hubungannya dengan pemesanan itu yang pergi.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Setiap pemesanan yang ditemukan terbuka di “Reservasi Baru”, satu demi satu, sudah terisi. Untuk hotel itu berarti nama di “Judul” dan, bila perjalanan memiliki tempatnya, di bawah “Akomodasi”, “Lokasi / Alamat”-nya, “Dari” dan “Sampai” pada malam-malamnya, “Check-in” dan “Check-out”, “Kode Pemesanan”, konfirmasi di bawah “File” dan, dengan “Biaya” menyala, harganya sebagai “Linked expense”. Periksa lalu klik “Tambah”.', + 'Setiap pemesanan yang ditemukan terbuka di “Reservasi Baru”, satu demi satu, sudah terisi. Untuk hotel itu berarti nama di “Judul” dan, bila perjalanan memiliki tempatnya, di bawah “Akomodasi”, “Lokasi / Alamat”-nya, “Dari” dan “Sampai” pada malam-malamnya, “Check-in” dan “Check-out”, “Kode Pemesanan”, konfirmasi di bawah “File” dan, dengan “Biaya” menyala, harganya sebagai “Pengeluaran tertaut”. Periksa lalu klik “Tambah”.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Biaya', @@ -3585,17 +3586,17 @@ const help: TranslationStrings = { 'Lekatkan berapa biaya sebenarnya sebuah penerbangan, hotel atau tempat ke catatan yang memilikinya.', 'help.guide.expense-from-booking.step.1': 'Buka pemesanan itu di tab Transportasi atau Pemesanan dan klik pensilnya.', 'help.guide.expense-from-booking.step.2': - 'Gulir ke blok Costs di bagian bawah formulir. Selama belum ada yang tertaut, ia menawarkan Create expense dan mengatakan bahwa pemesanannya disimpan lebih dulu.', + 'Gulir ke blok “Biaya” di bagian bawah formulir. Ia menawarkan “Buat pengeluaran”, yang menyimpan pemesanannya lebih dulu, dan “Tautkan pengeluaran yang ada” untuk pengeluaran yang sudah ada di “Biaya”.', 'help.guide.expense-from-booking.step.3': - 'Klik Create expense. Pemesanannya disimpan, formulirnya menutup, dan editor Biaya terbuka dengan judul pemesanan sebagai namanya dan jenisnya sudah dicocokkan ke sebuah kategori.', + 'Klik “Buat pengeluaran”. Pemesanannya disimpan, formulirnya menutup, dan editor Biaya terbuka dengan judul pemesanan sebagai namanya dan jenisnya sudah dicocokkan ke sebuah kategori.', 'help.guide.expense-from-booking.step.4': - 'Isi jumlahnya dan mata uangnya, siapa yang membayar dan pembagiannya seperti pengeluaran mana pun, lalu simpan. Membuka lagi pemesanannya kini menunjukkannya di bawah Linked expense, dengan pensil untuk menyuntingnya dan tempat sampah untuk membuangnya.', + 'Isi jumlahnya dan mata uangnya, siapa yang membayar dan pembagiannya seperti pengeluaran mana pun, lalu simpan. Membuka lagi pemesanannya kini menunjukkannya di bawah “Pengeluaran tertaut”, dengan pensil untuk menyuntingnya, “Lepas tautan, simpan pengeluaran” untuk melepasnya dan tempat sampah untuk membuangnya.', 'help.guide.expense-from-booking.result': 'Pemesanannya membawa biayanya, dan pengeluarannya adalah baris biasa di tab Biaya, dengan pembayar, pembagian dan mata uang seperti yang lain.', 'help.guide.expense-from-booking.tip.1': - 'Menghapus pemesanannya ikut menghapus pengeluaran yang tertaut padanya. Remove expense di blok Costs pemesanan itu melakukan kebalikannya: pengeluarannya hilang, pemesanannya tinggal.', + 'Menghapus pemesanannya ikut menghapus pengeluaran yang tertaut padanya. “Hapus pengeluaran” di blok “Biaya” pemesanan itu melakukan kebalikannya: pengeluarannya hilang, pemesanannya tinggal. “Lepas tautan, simpan pengeluaran” mempertahankan keduanya.', 'help.guide.expense-from-booking.tip.2': - 'Sebuah tempat punya blok yang sama di formulirnya, dengan Create expense yang menyimpan tempatnya lebih dulu.', + 'Sebuah tempat punya blok yang sama di formulirnya, dengan “Buat pengeluaran” yang menyimpan tempatnya lebih dulu.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Transportasi', @@ -3652,7 +3653,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'Baris itu mendarat di tempat yang ditentukan waktu mulainya, setelah perhentian terakhir yang mulai lebih awal. Pegangannya menyeretnya ke mana pun di dalam hari itu, atau ke hari lain.', 'help.guide.add-transport.tip.2': - '“Lampirkan file” di bawah “File” menerima tiketnya, dan “Create expense” di bawah “Costs” menyimpan pemesanan dan membuka editor “Biaya” untuk ongkosnya.', + '“Lampirkan file” di bawah “File” menerima tiketnya, dan “Buat pengeluaran” di bawah “Biaya” menyimpan pemesanan dan membuka editor “Biaya” untuk ongkosnya.', 'help.guide.add-transport.tip.3': '“Peserta” menandai siapa yang ikut dalam perjalanan ini. Begitu satu transportasi punya peserta, bilah alat tab menumbuhkan avatar mereka dan menyaring daftar berdasarkan mereka.', // plan-transit diff --git a/shared/src/i18n/id/reservations.ts b/shared/src/i18n/id/reservations.ts index da7d0b381..3757546f5 100644 --- a/shared/src/i18n/id/reservations.ts +++ b/shared/src/i18n/id/reservations.ts @@ -164,11 +164,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'Tidak ada penerbangan ditemukan di akun AirTrail-mu.', 'reservations.airtrail.importCta': 'Impor {count}', 'reservations.airtrail.joinConnection': 'Impor sebagai satu penerbangan dengan transit di {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Biaya', + 'reservations.createExpense': 'Buat pengeluaran', + 'reservations.createExpenseHint': 'Menyimpan pemesanan, lalu membuka editor biaya.', + 'reservations.linkedExpense': 'Pengeluaran tertaut', + 'reservations.removeExpense': 'Hapus pengeluaran', + 'reservations.linkedExpenses': 'Pengeluaran tertaut', + 'reservations.linkExpense': 'Tautkan pengeluaran yang ada', + 'reservations.unlinkExpense': 'Lepas tautan, simpan pengeluaran', + 'reservations.noUnlinkedExpenses': 'Tidak ada pengeluaran yang belum tertaut', 'reservations.urlLabel': 'Tautan', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Peserta', diff --git a/shared/src/i18n/it/help.ts b/shared/src/i18n/it/help.ts index 9ff87ba03..2a7860461 100644 --- a/shared/src/i18n/it/help.ts +++ b/shared/src/i18n/it/help.ts @@ -1867,7 +1867,7 @@ const help: TranslationStrings = { 'help.guide.create-place.result': 'Il luogo è nella lista e sulla mappa, sotto Non pianificati finché non viene messo su un giorno.', 'help.guide.create-place.tip.1': - 'File e Costs in fondo al modulo allegano un documento al luogo, o aprono l’editor Costs per la sua spesa subito dopo il salvataggio.', + 'File e Costi in fondo al modulo allegano un documento al luogo, o aprono l’editor dei Costi per la sua spesa subito dopo il salvataggio.', 'help.guide.create-place.tip.2': 'L’indice di TREK e OpenStreetMap rispondono alla ricerca su ogni TREK, e Dettagli del luogo si riempie da Wikipedia, Wikivoyage e Wikimedia. Google viene interrogato solo dove entrambi restano vuoti, e solo lui porta le valutazioni.', 'help.guide.create-place.tip.3': @@ -3217,7 +3217,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Clicca Prenotazione manuale in alto a destra nella scheda. Si apre Nuova prenotazione.', 'help.guide.create-booking.step.2': - 'Scegli il Tipo di prenotazione. Alloggio, Ristorante, Evento, Tour, Parcheggio e Altro sono i sei che questa scheda crea, e il modulo cambia con la scelta: solo Alloggio scambia le sue date con un intervallo di giorni.', + 'Scegli il Tipo di prenotazione dall’elenco in cima al modulo, accanto a Viaggiatori. Alloggio, Ristorante, Evento, Tour, Parcheggio e Altro sono i sei che questa scheda crea, e il modulo cambia con la scelta: solo Alloggio scambia le sue date con un intervallo di giorni.', 'help.guide.create-booking.step.3': 'Digita il Titolo. È l’unico campo su cui il modulo insiste, e Aggiungi resta morto finché non c’è qualcosa.', 'help.guide.create-booking.step.4': @@ -3273,9 +3273,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Dire per chi è una prenotazione', 'help.guide.booking-travelers.goal': 'Segna i viaggiatori che una prenotazione copre, e poi vedi solo le loro.', 'help.guide.booking-travelers.step.1': - 'Apri la prenotazione con la matita e scendi fino a Viaggiatori. Lì sta ogni membro del viaggio, ospiti con nome compresi.', + 'Apri la prenotazione con la matita. Viaggiatori sta in cima al modulo, accanto a Tipo di prenotazione, e dice Assegna viaggiatori finché nessuno è sulla prenotazione.', 'help.guide.booking-travelers.step.2': - 'Clicca le persone per cui è questa prenotazione. Una scelta riceve una spunta; cliccala di nuovo per toglierla.', + 'Cliccalo e scegli le persone per cui è questa prenotazione; anche gli ospiti con nome sono nell’elenco. Una scelta riceve una spunta e il suo avatar nel campo. Clicca di nuovo il nome per toglierla.', 'help.guide.booking-travelers.step.3': 'Clicca Aggiorna.', 'help.guide.booking-travelers.step.4': 'In alto nella barra degli strumenti, accanto ai chip di genere, clicca l’avatar di un viaggiatore per vedere solo le sue prenotazioni.', @@ -3306,21 +3306,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Trasformare il prezzo di una prenotazione in un costo', 'help.guide.booking-cost.goal': 'Porta ciò che una prenotazione costa nei Costi, diviso fra le persone che pagano.', 'help.guide.booking-cost.step.1': - 'Apri la prenotazione e vai in fondo al modulo. Sotto Costs sta Create expense, con la nota Saves the booking, then opens the Costs editor.', + 'Apri la prenotazione e vai in fondo al modulo. Sotto Costi stanno Crea spesa e Collega spesa esistente, con la nota Salva la prenotazione e poi apre l’editor dei costi.', 'help.guide.booking-cost.step.2': - 'Clicca Create expense. La prenotazione viene salvata, il suo modulo si chiude e l’editor Costs si apre.', + 'Clicca Crea spesa. La prenotazione viene salvata, il suo modulo si chiude e l’editor dei Costi si apre.', 'help.guide.booking-cost.step.3': 'Per cosa era? è già il titolo della prenotazione. Inserisci l’Importo totale e controlla la Valuta e il Giorno.', 'help.guide.booking-cost.step.4': 'Categoria è quella che il tipo di prenotazione implica. Imposta Chi ha pagato? e come l’importo viene diviso.', 'help.guide.booking-cost.step.5': 'Clicca Aggiungi spesa.', 'help.guide.booking-cost.result': - 'Il modulo della prenotazione ora dice Linked expense con l’importo, e la stessa spesa sta nella scheda Costi, legata a questa prenotazione.', + 'Il modulo della prenotazione ora elenca la spesa sotto Spese collegate con il suo importo, e la stessa spesa sta nella scheda Costi, legata a questa prenotazione.', 'help.guide.booking-cost.tip.1': 'La categoria segue il tipo: Ristorante diventa Cibo e bevande, Alloggio diventa Alloggio, Parcheggio diventa Parcheggio, ed Evento e Tour finiscono entrambi in Altro.', - 'help.guide.booking-cost.tip.2': 'Il cestino su Linked expense rimuove la spesa, non la prenotazione.', + 'help.guide.booking-cost.tip.2': + 'Una prenotazione può portare più spese. Collega spesa esistente offre quelle in Costi che non appartengono ancora a nulla. Su una collegata, Scollega, mantieni la spesa la stacca e la lascia in Costi, mentre il cestino la rimuove.', 'help.guide.booking-cost.tip.3': - 'Costs sta nel modulo solo finché l’addon Costi è attivo, che l’amministratore accende sotto Moduli.', + 'Costi sta nel modulo solo finché l’addon Costi è attivo, che l’amministratore accende sotto Moduli.', // filter-bookings 'help.guide.filter-bookings.title': 'Trovare una prenotazione', 'help.guide.filter-bookings.goal': 'Restringi una scheda lunga al genere, alla persona o allo stato che cerchi.', @@ -3374,7 +3375,7 @@ const help: TranslationStrings = { 'help.guide.edit-booking.tip.1': 'Un Codice prenotazione che non riesci a leggere è Nascondi codici di prenotazione nelle Impostazioni, sotto Visualizzazione. Passaci sopra, o cliccalo, ed è leggibile.', 'help.guide.edit-booking.tip.2': - 'Cambia il tipo e la categoria di una spesa collegata lo segue, a meno che tu non avessi scelto una categoria a mano nell’editor Costs.', + 'Cambia il tipo e la categoria di una spesa collegata lo segue, a meno che tu non avessi scelto una categoria a mano nell’editor dei Costi.', 'help.guide.edit-booking.tip.3': 'Un alloggio si modifica anche qui: i suoi giorni Da e A stanno nello stesso modulo.', // delete-booking @@ -3392,7 +3393,7 @@ const help: TranslationStrings = { 'I documenti che erano allegati restano nella scheda File del viaggio; se ne va solo il loro collegamento con la prenotazione.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Ogni prenotazione trovata si apre in Nuova prenotazione, una dopo l’altra, già compilata. Per un hotel è il nome in Titolo e, quando il viaggio ha il luogo, sotto Alloggio, la sua Posizione / Indirizzo, Da e A sulle sue notti, Check-in e Check-out, il Codice prenotazione, la conferma sotto File e, con Costi attivo, il prezzo come Linked expense. Controllala e clicca Aggiungi.', + 'Ogni prenotazione trovata si apre in Nuova prenotazione, una dopo l’altra, già compilata. Per un hotel è il nome in Titolo e, quando il viaggio ha il luogo, sotto Alloggio, la sua Posizione / Indirizzo, Da e A sulle sue notti, Check-in e Check-out, il Codice prenotazione, la conferma sotto File e, con Costi attivo, il prezzo come Spesa collegata. Controllala e clicca Aggiungi.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Costi', @@ -3564,17 +3565,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Apri la prenotazione nella scheda Trasporti o Prenotazioni e clicca la sua matita.', 'help.guide.expense-from-booking.step.2': - 'Scorri fino al blocco Costs in fondo al modulo. Finché non c’è nulla di collegato, offre Create expense e dice che salva prima la prenotazione.', + 'Scorri fino al blocco Costi in fondo al modulo. Offre Crea spesa, che salva prima la prenotazione, e Collega spesa esistente per una che è già nella scheda Costi.', 'help.guide.expense-from-booking.step.3': - 'Clicca Create expense. La prenotazione viene salvata, il modulo si chiude, e l’editor dei Costi si apre con il titolo della prenotazione come nome e il suo tipo già abbinato a una categoria.', + 'Clicca Crea spesa. La prenotazione viene salvata, il modulo si chiude, e l’editor dei Costi si apre con il titolo della prenotazione come nome e il suo tipo già abbinato a una categoria.', 'help.guide.expense-from-booking.step.4': - 'Compila l’importo e la sua valuta, chi ha pagato e la divisione come per qualsiasi spesa, e salva. Riaprendo la prenotazione la si vede sotto Linked expense, con una matita per modificarla e un cestino per rimuoverla.', + 'Compila l’importo e la sua valuta, chi ha pagato e la divisione come per qualsiasi spesa, e salva. Riaprendo la prenotazione la si vede sotto Spese collegate, con una matita per modificarla, Scollega, mantieni la spesa per staccarla e un cestino per rimuoverla.', 'help.guide.expense-from-booking.result': 'La prenotazione porta il suo costo, e la spesa è una riga ordinaria nella scheda Costi, con un pagante, una divisione e una valuta come ogni altra.', 'help.guide.expense-from-booking.tip.1': - 'Eliminare la prenotazione elimina con sé la spesa collegata. Remove expense nel blocco Costs della prenotazione fa il contrario: la spesa va via, la prenotazione resta.', + 'Eliminare la prenotazione elimina con sé le spese collegate. Rimuovi spesa nel blocco Costi della prenotazione fa il contrario: la spesa va via, la prenotazione resta. Scollega, mantieni la spesa le tiene entrambe.', 'help.guide.expense-from-booking.tip.2': - 'Un luogo ha lo stesso blocco nel suo modulo, dove Create expense salva prima il luogo.', + 'Un luogo ha lo stesso blocco nel suo modulo, dove Crea spesa salva prima il luogo.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Trasporti', @@ -3631,7 +3632,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'La riga arriva dove la mette il suo orario di inizio, dopo l’ultima tappa che comincia prima. La sua maniglia la trascina in qualsiasi altro punto del giorno, o su un altro giorno.', 'help.guide.add-transport.tip.2': - 'Allega file sotto File prende il biglietto, e Create expense sotto Costs salva la prenotazione e apre l’editor dei Costi per la tariffa.', + 'Allega file sotto File prende il biglietto, e Crea spesa sotto Costi salva la prenotazione e apre l’editor dei Costi per la tariffa.', 'help.guide.add-transport.tip.3': 'Viaggiatori segna chi è su questo tragitto. Appena un trasporto ha viaggiatori, la barra della scheda fa crescere i loro avatar e filtra l’elenco per loro.', // plan-transit diff --git a/shared/src/i18n/it/reservations.ts b/shared/src/i18n/it/reservations.ts index c0c2c11c0..403577beb 100644 --- a/shared/src/i18n/it/reservations.ts +++ b/shared/src/i18n/it/reservations.ts @@ -165,11 +165,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'Nessun volo trovato nel tuo account AirTrail.', 'reservations.airtrail.importCta': 'Importa {count}', 'reservations.airtrail.joinConnection': 'Importa come un unico volo con scalo a {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Costi', + 'reservations.createExpense': 'Crea spesa', + 'reservations.createExpenseHint': 'Salva la prenotazione e poi apre l’editor dei costi.', + 'reservations.linkedExpense': 'Spesa collegata', + 'reservations.removeExpense': 'Rimuovi spesa', + 'reservations.linkedExpenses': 'Spese collegate', + 'reservations.linkExpense': 'Collega spesa esistente', + 'reservations.unlinkExpense': 'Scollega, mantieni la spesa', + 'reservations.noUnlinkedExpenses': 'Nessuna spesa non collegata', 'reservations.urlLabel': 'Link', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Viaggiatori', diff --git a/shared/src/i18n/ja/help.ts b/shared/src/i18n/ja/help.ts index d7a47a2b5..b6199dd04 100644 --- a/shared/src/i18n/ja/help.ts +++ b/shared/src/i18n/ja/help.ts @@ -1792,7 +1792,7 @@ const help: TranslationStrings = { '「追加」をクリックします。同じ名前の場所がすでに旅行にある場合、フォームがそれを伝え、ボタンは「それでも追加」に変わります。', 'help.guide.create-place.result': '場所はリストと地図にあります。日に置かれるまでは「未計画」の下です。', 'help.guide.create-place.tip.1': - 'フォームの下の「ファイル」と「Costs」は、場所に書類を添付するか、保存の直後にその支出の「Costs」エディターを開きます。', + 'フォームの下の「ファイル」と「費用」は、場所に書類を添付するか、保存の直後にその支出の「費用」エディターを開きます。', 'help.guide.create-place.tip.2': 'どの TREK でも検索に答えるのは TREK のインデックスと OpenStreetMap で、「場所の詳細」は Wikipedia、Wikivoyage、Wikimedia から自分を埋めます。Google が尋ねられるのは、その両方が空のときだけで、評価をもたらすのは Google だけです。', 'help.guide.create-place.tip.3': @@ -3077,7 +3077,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.goal': 'レストラン、イベント、ツアー、駐車場など、何でも手入力で旅行に入れます。', 'help.guide.create-booking.step.1': 'タブの右上の「手動予約」をクリックします。「新しい予約」が開きます。', 'help.guide.create-booking.step.2': - '「予約タイプ」を選びます。このタブが作るのは「宿泊」「レストラン」「イベント」「ツアー」「駐車場」「その他」の6種類で、選択に応じてフォームが変わります。日付を日の範囲に取り替えるのは「宿泊」だけです。', + 'フォーム上部の「同行者」の隣にあるリストから「予約タイプ」を選びます。このタブが作るのは「宿泊」「レストラン」「イベント」「ツアー」「駐車場」「その他」の6種類で、選択に応じてフォームが変わります。日付を日の範囲に取り替えるのは「宿泊」だけです。', 'help.guide.create-booking.step.3': '「タイトル」を入力します。フォームが必ず求めるのはこの項目だけで、何か入るまで「追加」は押せません。', 'help.guide.create-booking.step.4': @@ -3132,9 +3132,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': '予約が誰のためかを示す', 'help.guide.booking-travelers.goal': '予約が対象とする同行者を指定し、そのうえでその人の分だけを表示します。', 'help.guide.booking-travelers.step.1': - '鉛筆で予約を開き、「同行者」まで下がります。旅行のメンバーが全員そこに並び、名前の付いた「ゲスト」も含まれます。', + '鉛筆で予約を開きます。「同行者」はフォームの上部、「予約タイプ」の隣にあり、予約に誰もいない間は「同行者を割り当て」と表示されます。', 'help.guide.booking-travelers.step.2': - 'この予約の対象となる人をクリックします。選ばれた人にはチェックが付き、もう一度クリックすると外れます。', + 'そこをクリックして、この予約の対象となる人を選びます。名前の付いた「ゲスト」もリストに含まれます。選ばれた人にはチェックが付き、フィールドにアバターが表示されます。名前をもう一度クリックすると外れます。', 'help.guide.booking-travelers.step.3': '「更新」をクリックします。', 'help.guide.booking-travelers.step.4': '上のツールバーで、種類のチップの隣にある同行者のアバターをクリックすると、その人の予約だけが表示されます。', @@ -3162,21 +3162,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': '予約の金額を費用にする', 'help.guide.booking-cost.goal': '予約にかかる金額を「費用」に入れ、支払う人たちで分けます。', 'help.guide.booking-cost.step.1': - '予約を開き、フォームの一番下まで進みます。「Costs」の下に「Create expense」があり、「Saves the booking, then opens the Costs editor.」という注記が付いています。', + '予約を開き、フォームの一番下まで進みます。「費用」の下に「支出を作成」と「既存の支出をリンク」があり、「予約を保存してから費用エディタを開きます。」という注記が付いています。', 'help.guide.booking-cost.step.2': - '「Create expense」をクリックします。予約が保存され、そのフォームが閉じて、費用の編集画面が開きます。', + '「支出を作成」をクリックします。予約が保存され、そのフォームが閉じて、費用の編集画面が開きます。', 'help.guide.booking-cost.step.3': '「何の支出ですか?」にはすでに予約のタイトルが入っています。「合計金額」を入れ、「通貨」と「日」を確かめます。', 'help.guide.booking-cost.step.4': '「カテゴリ」は予約タイプから決まるものです。「誰が支払いましたか?」と、金額の分け方である「Split」を設定します。', 'help.guide.booking-cost.step.5': '「支出を追加」をクリックします。', 'help.guide.booking-cost.result': - '予約のフォームには金額とともに「Linked expense」が表示され、同じ支出がこの予約に結び付いた形で「費用」タブにも並びます。', + '予約のフォームでは支出が金額とともに「リンク済みの支出」の下に表示され、同じ支出がこの予約に結び付いた形で「費用」タブにも並びます。', 'help.guide.booking-cost.tip.1': 'カテゴリは種類に従います。「レストラン」は「飲食」に、「宿泊」は「宿泊」に、「駐車場」は「駐車場」になり、「イベント」と「ツアー」はどちらも「その他」に入ります。', - 'help.guide.booking-cost.tip.2': '「Linked expense」のごみ箱が消すのは支出だけで、予約ではありません。', + 'help.guide.booking-cost.tip.2': + '1件の予約に複数の支出を付けられます。「既存の支出をリンク」は、「費用」にあってまだどこにも属していない支出を候補に出します。リンク済みの支出では、「リンクを解除し、支出は残す」が紐づけを外して「費用」に残し、ごみ箱は支出を削除します。', 'help.guide.booking-cost.tip.3': - '「Costs」がフォームにあるのは「費用」アドオンが有効な間だけで、管理者が「アドオン」で切り替えます。', + '「費用」がフォームにあるのは「費用」アドオンが有効な間だけで、管理者が「アドオン」で切り替えます。', // filter-bookings 'help.guide.filter-bookings.title': '予約を見つける', 'help.guide.filter-bookings.goal': '長いタブを、探している種類、人、状態に絞り込みます。', @@ -3245,7 +3246,7 @@ const help: TranslationStrings = { '添付されていた書類は旅行の「ファイル」タブに残ります。消えるのは予約との結び付きだけです。', // import-booking-file 'help.guide.import-booking-file.step.5': - '見つかった予約はそれぞれ「新しい予約」に、内容が入った状態で順に開きます。ホテルなら「タイトル」に名前、旅行にその場所があれば「宿泊先」の下にその場所、「場所/住所」、宿泊日に合わせた「開始」と「終了」、「チェックイン」と「チェックアウト」、「予約コード」、「ファイル」の下に確認書、そして「費用」がオンなら「Linked expense」として料金です。確かめて「追加」をクリックします。', + '見つかった予約はそれぞれ「新しい予約」に、内容が入った状態で順に開きます。ホテルなら「タイトル」に名前、旅行にその場所があれば「宿泊先」の下にその場所、「場所/住所」、宿泊日に合わせた「開始」と「終了」、「チェックイン」と「チェックアウト」、「予約コード」、「ファイル」の下に確認書、そして「費用」がオンなら「リンク済みの支出」として料金です。確かめて「追加」をクリックします。', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': '費用', @@ -3413,17 +3414,17 @@ const help: TranslationStrings = { '航空券やホテル、場所に実際にかかった費用を、それが属する記録そのものに付けます。', 'help.guide.expense-from-booking.step.1': '「移動」タブか「予約」タブで予約を開き、その鉛筆をクリックします。', 'help.guide.expense-from-booking.step.2': - 'フォームの一番下のCostsのブロックまでスクロールします。まだ何も紐づいていなければCreate expenseが出ていて、先に予約を保存すると書かれています。', + 'フォームの一番下の「費用」のブロックまでスクロールします。先に予約を保存する「支出を作成」と、すでに「費用」にある支出のための「既存の支出をリンク」が出ています。', 'help.guide.expense-from-booking.step.3': - 'Create expenseをクリックします。予約が保存され、フォームが閉じ、予約のタイトルを名前として、種類に合うカテゴリがすでに当てられた状態で費用のエディタが開きます。', + '「支出を作成」をクリックします。予約が保存され、フォームが閉じ、予約のタイトルを名前として、種類に合うカテゴリがすでに当てられた状態で費用のエディタが開きます。', 'help.guide.expense-from-booking.step.4': - 'ほかの支出と同じように金額とその通貨、支払った人、分割を入力して保存します。予約を開き直すと、Linked expenseの下にそれが出て、編集する鉛筆と外すゴミ箱が付きます。', + 'ほかの支出と同じように金額とその通貨、支払った人、分割を入力して保存します。予約を開き直すと、「リンク済みの支出」の下にそれが出て、編集する鉛筆、紐づけだけを外す「リンクを解除し、支出は残す」、削除するゴミ箱が付きます。', 'help.guide.expense-from-booking.result': '予約はその費用を持ち、支出は「費用」タブのふつうの行になります。ほかの支出と同じく、支払者と分割と通貨を持ちます。', 'help.guide.expense-from-booking.tip.1': - '予約を削除すると、紐づいた支出も一緒に消えます。予約のCostsブロックにあるRemove expenseはその逆で、支出が消えて予約は残ります。', + '予約を削除すると、紐づいた支出も一緒に消えます。予約の「費用」ブロックにある「支出を削除」はその逆で、支出が消えて予約は残ります。「リンクを解除し、支出は残す」なら両方とも残ります。', 'help.guide.expense-from-booking.tip.2': - '場所のフォームにも同じブロックがあり、Create expenseは先に場所を保存します。', + '場所のフォームにも同じブロックがあり、「支出を作成」は先に場所を保存します。', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': '移動', @@ -3478,7 +3479,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': '行は開始時刻が決める場所、つまりそれより早く始まる最後の立ち寄り先の後に着きます。つまみで日の中の別の場所へも、別の日へもドラッグできます。', 'help.guide.add-transport.tip.2': - '「ファイル」の下の「ファイルを添付」がチケットを受け取り、「Costs」の下の「Create expense」は予約を保存して運賃のために「費用」のエディターを開きます。', + '「ファイル」の下の「ファイルを添付」がチケットを受け取り、「費用」の下の「支出を作成」は予約を保存して運賃のために「費用」のエディターを開きます。', 'help.guide.add-transport.tip.3': '「同行者」はこの移動に誰が乗るかを記します。一つの移動手段に同行者が付くとすぐ、タブのツールバーにその人たちのアバターが増え、一覧をそれで絞り込みます。', // plan-transit diff --git a/shared/src/i18n/ja/reservations.ts b/shared/src/i18n/ja/reservations.ts index 127e2e4d4..e03247da0 100644 --- a/shared/src/i18n/ja/reservations.ts +++ b/shared/src/i18n/ja/reservations.ts @@ -164,11 +164,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'AirTrail アカウントにフライトが見つかりませんでした。', 'reservations.airtrail.importCta': '{count} 件をインポート', 'reservations.airtrail.joinConnection': '{stops}での乗り継ぎを含む1つのフライトとしてインポート', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': '費用', + 'reservations.createExpense': '支出を作成', + 'reservations.createExpenseHint': '予約を保存してから費用エディタを開きます。', + 'reservations.linkedExpense': 'リンク済みの支出', + 'reservations.removeExpense': '支出を削除', + 'reservations.linkedExpenses': 'リンク済みの支出', + 'reservations.linkExpense': '既存の支出をリンク', + 'reservations.unlinkExpense': 'リンクを解除し、支出は残す', + 'reservations.noUnlinkedExpenses': 'リンクされていない支出はありません', 'reservations.urlLabel': 'リンク', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': '同行者', diff --git a/shared/src/i18n/ko/help.ts b/shared/src/i18n/ko/help.ts index 3efe9d324..9c2f2efd5 100644 --- a/shared/src/i18n/ko/help.ts +++ b/shared/src/i18n/ko/help.ts @@ -1717,7 +1717,7 @@ const help: TranslationStrings = { '“추가”를 클릭합니다. 같은 이름의 장소가 이미 여행에 있으면 양식이 그렇게 알려 주고 버튼이 “그래도 추가”로 바뀝니다.', 'help.guide.create-place.result': '장소가 목록과 지도에 있습니다. 날짜에 놓이기 전까지는 “미계획” 아래에 있습니다.', 'help.guide.create-place.tip.1': - '양식 아래의 “파일”과 “Costs”는 장소에 문서를 첨부하거나, 저장한 직후 그 지출의 “Costs” 편집기를 엽니다.', + '양식 아래의 “파일”과 “비용”은 장소에 문서를 첨부하거나, 저장한 직후 그 지출의 “비용” 편집기를 엽니다.', 'help.guide.create-place.tip.2': '어느 TREK에서나 검색에 답하는 것은 TREK 색인과 OpenStreetMap이고, “장소 세부정보”는 Wikipedia, Wikivoyage, Wikimedia에서 스스로를 채웁니다. Google은 둘 다 아무것도 찾지 못한 곳에서만 쓰이고, 평점을 가져오는 것은 Google뿐입니다.', 'help.guide.create-place.tip.3': @@ -2974,7 +2974,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.goal': '식당, 이벤트, 투어, 주차 자리, 그 밖의 무엇이든 손으로 여행에 넣습니다.', 'help.guide.create-booking.step.1': '탭 오른쪽 위의 “직접 예약”을 클릭합니다. “새 예약”이 열립니다.', 'help.guide.create-booking.step.2': - '“예약 유형”을 고릅니다. 이 탭이 만드는 여섯 가지는 “숙박”, “레스토랑”, “이벤트”, “투어”, “주차”, “기타”이고, 고른 것에 따라 양식이 바뀝니다. 날짜를 날의 범위로 바꾸는 것은 “숙박”뿐입니다.', + '양식 위쪽의 “동행자” 옆에 있는 목록에서 “예약 유형”을 고릅니다. 이 탭이 만드는 여섯 가지는 “숙박”, “레스토랑”, “이벤트”, “투어”, “주차”, “기타”이고, 고른 것에 따라 양식이 바뀝니다. 날짜를 날의 범위로 바꾸는 것은 “숙박”뿐입니다.', 'help.guide.create-booking.step.3': '“제목”을 입력합니다. 양식이 꼭 요구하는 항목은 이 하나뿐이고, 무언가 들어가기 전까지 “추가”는 눌리지 않습니다.', 'help.guide.create-booking.step.4': @@ -3026,9 +3026,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': '예약이 누구를 위한 것인지 밝히기', 'help.guide.booking-travelers.goal': '예약이 포함하는 동행자를 표시하고, 그런 다음 그 사람의 것만 봅니다.', 'help.guide.booking-travelers.step.1': - '연필로 예약을 열고 “동행자”까지 내려갑니다. 여행의 모든 구성원이 거기 서 있고, 이름이 있는 “게스트”도 그 안에 있습니다.', + '연필로 예약을 엽니다. “동행자”는 양식 위쪽, “예약 유형” 옆에 있고, 예약에 아무도 없는 동안에는 “동행자 지정”이라고 표시됩니다.', 'help.guide.booking-travelers.step.2': - '이 예약이 해당하는 사람을 클릭합니다. 고른 사람에게는 체크가 붙고, 다시 클릭하면 풀립니다.', + '그곳을 클릭하고 이 예약이 해당하는 사람을 고릅니다. 이름이 있는 “게스트”도 목록에 있습니다. 고른 사람에게는 체크가 붙고 필드에 그 사람의 아바타가 나타납니다. 이름을 다시 클릭하면 풀립니다.', 'help.guide.booking-travelers.step.3': '“업데이트”를 클릭합니다.', 'help.guide.booking-travelers.step.4': '위쪽 도구 모음에서, 유형 칩 옆에 있는 동행자의 아바타를 클릭하면 그 사람의 예약만 보입니다.', @@ -3056,21 +3056,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': '예약 금액을 비용으로 바꾸기', 'help.guide.booking-cost.goal': '예약에 드는 금액을 “비용”에 넣고, 내는 사람들끼리 나눕니다.', 'help.guide.booking-cost.step.1': - '예약을 열고 양식의 맨 아래로 갑니다. “Costs” 아래에 “Create expense”가 있고, 그 아래에 “Saves the booking, then opens the Costs editor.”라는 안내가 붙어 있습니다.', + '예약을 열고 양식의 맨 아래로 갑니다. “비용” 아래에 “지출 만들기”와 “기존 지출 연결”이 있고, 그 아래에 “예약을 저장한 뒤 비용 편집기를 엽니다.”라는 안내가 붙어 있습니다.', 'help.guide.booking-cost.step.2': - '“Create expense”를 클릭합니다. 예약이 저장되고, 그 양식이 닫히며, 비용 편집 창이 열립니다.', + '“지출 만들기”를 클릭합니다. 예약이 저장되고, 그 양식이 닫히며, 비용 편집 창이 열립니다.', 'help.guide.booking-cost.step.3': '“무엇을 위한 것인가요?”에는 이미 예약의 제목이 들어 있습니다. “총 금액”을 넣고 “통화”와 “날짜”를 확인합니다.', 'help.guide.booking-cost.step.4': '“카테고리”는 예약 유형이 정해 주는 것입니다. “누가 지불했나요?”와 금액을 나누는 방식인 “Split”을 설정합니다.', 'help.guide.booking-cost.step.5': '“지출 추가”를 클릭합니다.', 'help.guide.booking-cost.result': - '예약의 양식에는 이제 금액과 함께 “Linked expense”가 보이고, 같은 지출이 이 예약에 묶인 채 “비용” 탭에 섭니다.', + '예약의 양식에는 이제 지출이 금액과 함께 “연결된 지출” 아래에 보이고, 같은 지출이 이 예약에 묶인 채 “비용” 탭에 섭니다.', 'help.guide.booking-cost.tip.1': '카테고리는 유형을 따릅니다. “레스토랑”은 “식음료”가 되고, “숙박”은 “숙박”이 되고, “주차”는 “주차”가 되며, “이벤트”와 “투어”는 둘 다 “기타”로 갑니다.', - 'help.guide.booking-cost.tip.2': '“Linked expense”의 휴지통은 지출을 없애지, 예약을 없애지는 않습니다.', + 'help.guide.booking-cost.tip.2': + '예약 하나에 지출을 여러 개 달 수 있습니다. “기존 지출 연결”은 “비용”에 있으면서 아직 어디에도 속하지 않은 지출을 보여 줍니다. 연결된 지출에서 “연결 해제, 지출은 유지”는 연결만 풀고 지출을 “비용”에 남기며, 휴지통은 지출을 없앱니다.', 'help.guide.booking-cost.tip.3': - '“Costs”가 양식에 있는 것은 “비용” 애드온이 켜져 있는 동안뿐이고, 관리자가 “애드온”에서 그것을 켜고 끕니다.', + '“비용”이 양식에 있는 것은 “비용” 애드온이 켜져 있는 동안뿐이고, 관리자가 “애드온”에서 그것을 켜고 끕니다.', // filter-bookings 'help.guide.filter-bookings.title': '예약 찾기', 'help.guide.filter-bookings.goal': '긴 탭을 찾는 유형, 사람, 상태로 좁힙니다.', @@ -3138,7 +3139,7 @@ const help: TranslationStrings = { '첨부되어 있던 서류는 여행의 “파일” 탭에 남습니다. 사라지는 것은 예약과의 연결뿐입니다.', // import-booking-file 'help.guide.import-booking-file.step.5': - '찾아낸 예약이 하나씩 “새 예약”에 이미 채워진 채로 차례로 열립니다. 호텔이라면 “제목”에 이름, 여행에 그 장소가 있으면 “숙박” 아래에 그 장소, “위치 / 주소”, 숙박일에 맞춘 “부터”와 “까지”, “체크인”과 “체크아웃”, “예약 코드”, “파일” 아래의 확인서, 그리고 “비용”이 켜져 있으면 “Linked expense”로 가격입니다. 확인하고 “추가”를 클릭합니다.', + '찾아낸 예약이 하나씩 “새 예약”에 이미 채워진 채로 차례로 열립니다. 호텔이라면 “제목”에 이름, 여행에 그 장소가 있으면 “숙박” 아래에 그 장소, “위치 / 주소”, 숙박일에 맞춘 “부터”와 “까지”, “체크인”과 “체크아웃”, “예약 코드”, “파일” 아래의 확인서, 그리고 “비용”이 켜져 있으면 “연결된 지출”로 가격입니다. 확인하고 “추가”를 클릭합니다.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': '비용', @@ -3303,16 +3304,16 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.goal': '항공편이나 숙소, 장소에 실제로 든 비용을 그것이 속한 기록에 붙입니다.', 'help.guide.expense-from-booking.step.1': '교통 탭이나 예약 탭에서 예약을 열고 연필을 클릭합니다.', 'help.guide.expense-from-booking.step.2': - '양식 맨 아래의 Costs 블록까지 스크롤합니다. 아직 연결된 것이 없으면 Create expense를 내주고, 예약을 먼저 저장한다고 알려줍니다.', + '양식 맨 아래의 “비용” 블록까지 스크롤합니다. 예약을 먼저 저장하는 “지출 만들기”와, 이미 “비용”에 있는 지출을 위한 “기존 지출 연결”을 내줍니다.', 'help.guide.expense-from-booking.step.3': - 'Create expense를 클릭합니다. 예약이 저장되고 양식이 닫히며, 예약의 제목이 이름으로 들어가고 종류에 맞는 카테고리가 이미 붙은 채로 비용 편집기가 열립니다.', + '“지출 만들기”를 클릭합니다. 예약이 저장되고 양식이 닫히며, 예약의 제목이 이름으로 들어가고 종류에 맞는 카테고리가 이미 붙은 채로 비용 편집기가 열립니다.', 'help.guide.expense-from-booking.step.4': - '다른 지출과 마찬가지로 금액과 그 통화, 누가 냈는지와 분할을 채우고 저장합니다. 예약을 다시 열면 Linked expense 아래에 그것이 보이고, 편집할 연필과 떼어낼 휴지통이 함께 있습니다.', + '다른 지출과 마찬가지로 금액과 그 통화, 누가 냈는지와 분할을 채우고 저장합니다. 예약을 다시 열면 “연결된 지출” 아래에 그것이 보이고, 편집할 연필, 연결만 풀 “연결 해제, 지출은 유지”, 삭제할 휴지통이 함께 있습니다.', 'help.guide.expense-from-booking.result': '예약은 자기 비용을 지니게 되고, 지출은 비용 탭의 평범한 한 줄이 됩니다. 다른 지출처럼 지불자와 분할과 통화를 가집니다.', 'help.guide.expense-from-booking.tip.1': - '예약을 삭제하면 연결된 지출도 함께 삭제됩니다. 예약의 Costs 블록에 있는 Remove expense는 그 반대로, 지출은 사라지고 예약은 남습니다.', - 'help.guide.expense-from-booking.tip.2': '장소의 양식에도 같은 블록이 있고, Create expense가 장소를 먼저 저장합니다.', + '예약을 삭제하면 연결된 지출도 함께 삭제됩니다. 예약의 “비용” 블록에 있는 “지출 삭제”는 그 반대로, 지출은 사라지고 예약은 남습니다. “연결 해제, 지출은 유지”는 둘 다 남깁니다.', + 'help.guide.expense-from-booking.tip.2': '장소의 양식에도 같은 블록이 있고, “지출 만들기”가 장소를 먼저 저장합니다.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': '교통', @@ -3368,7 +3369,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': '행은 시작 시간이 정하는 자리, 곧 그보다 일찍 시작하는 마지막 장소 뒤에 놓입니다. 손잡이로 그 날짜 안 어디로든, 또는 다른 날짜로 끌 수 있습니다.', 'help.guide.add-transport.tip.2': - '“파일” 아래의 “파일 첨부”가 승차권을 받고, “Costs” 아래의 “Create expense”는 예약을 저장하고 운임을 위해 “비용” 편집기를 엽니다.', + '“파일” 아래의 “파일 첨부”가 승차권을 받고, “비용” 아래의 “지출 만들기”는 예약을 저장하고 운임을 위해 “비용” 편집기를 엽니다.', 'help.guide.add-transport.tip.3': '“동행자”는 이 이동에 누가 타는지를 표시합니다. 교통 하나에 동행자가 생기자마자 탭의 도구 모음에 그들의 아바타가 늘어나고, 그것으로 목록을 거릅니다.', // plan-transit diff --git a/shared/src/i18n/ko/reservations.ts b/shared/src/i18n/ko/reservations.ts index e5a1362ff..8a09546e1 100644 --- a/shared/src/i18n/ko/reservations.ts +++ b/shared/src/i18n/ko/reservations.ts @@ -164,11 +164,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'AirTrail 계정에서 항공편을 찾을 수 없습니다.', 'reservations.airtrail.importCta': '{count}개 가져오기', 'reservations.airtrail.joinConnection': '{stops} 경유 항공편 하나로 가져오기', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': '비용', + 'reservations.createExpense': '지출 만들기', + 'reservations.createExpenseHint': '예약을 저장한 뒤 비용 편집기를 엽니다.', + 'reservations.linkedExpense': '연결된 지출', + 'reservations.removeExpense': '지출 삭제', + 'reservations.linkedExpenses': '연결된 지출', + 'reservations.linkExpense': '기존 지출 연결', + 'reservations.unlinkExpense': '연결 해제, 지출은 유지', + 'reservations.noUnlinkedExpenses': '연결되지 않은 지출이 없습니다', 'reservations.urlLabel': '링크', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': '동행자', diff --git a/shared/src/i18n/nl/help.ts b/shared/src/i18n/nl/help.ts index 353615220..e8fb4c158 100644 --- a/shared/src/i18n/nl/help.ts +++ b/shared/src/i18n/nl/help.ts @@ -3235,7 +3235,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Klik rechtsboven op het tabblad op Handmatige boeking. Nieuwe reservering gaat open.', 'help.guide.create-booking.step.2': - 'Kies het Boekingstype. Accommodatie, Restaurant, Evenement, Rondleiding, Parkeren en Overig zijn de zes die dit tabblad maakt, en het formulier verandert met de keuze: alleen Accommodatie ruilt haar data in voor een reeks dagen.', + 'Kies het Boekingstype uit de lijst bovenaan het formulier, naast Reizigers. Accommodatie, Restaurant, Evenement, Rondleiding, Parkeren en Overig zijn de zes die dit tabblad maakt, en het formulier verandert met de keuze: alleen Accommodatie ruilt haar data in voor een reeks dagen.', 'help.guide.create-booking.step.3': 'Typ de Titel. Het is het enige veld waar het formulier op staat, en Toevoegen blijft dood tot er iets in staat.', 'help.guide.create-booking.step.4': @@ -3292,9 +3292,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Zeggen voor wie een boeking is', 'help.guide.booking-travelers.goal': 'Markeer de reizigers die een boeking dekt, en zie daarna alleen die van hen.', 'help.guide.booking-travelers.step.1': - 'Open de boeking met het potlood en ga omlaag naar Reizigers. Daar staat elk lid van de reis, genoemde gasten erbij.', + 'Open de boeking met het potlood. Reizigers staat bovenaan het formulier, naast Boekingstype, en toont Reizigers toewijzen zolang er niemand op de boeking staat.', 'help.guide.booking-travelers.step.2': - 'Klik de mensen aan voor wie deze boeking is. Een gekozen persoon krijgt een vinkje; klik nog eens om het eraf te halen.', + 'Klik erop en kies de mensen voor wie deze boeking is; genoemde gasten staan ook in de lijst. Een gekozen persoon krijgt een vinkje en zijn avatar in het veld. Klik nog eens op de naam om hem eraf te halen.', 'help.guide.booking-travelers.step.3': 'Klik op Bijwerken.', 'help.guide.booking-travelers.step.4': 'Boven in de werkbalk, naast de chips voor de soort, klik je op de avatar van een reiziger om alleen zijn boekingen te zien.', @@ -3326,7 +3326,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'De prijs van een boeking in kosten omzetten', 'help.guide.booking-cost.goal': 'Breng wat een boeking kost in Onkosten, verdeeld over de mensen die ervoor betalen.', 'help.guide.booking-cost.step.1': - 'Open de boeking en ga naar de voet van het formulier. Onder Kosten staat Kostenpost aanmaken, met de notitie Boeking opslaan en daarna de Onkosteneditor openen.', + 'Open de boeking en ga naar de voet van het formulier. Onder Kosten staan Kostenpost aanmaken en Bestaande kostenpost koppelen, met de notitie Boeking opslaan en daarna de Onkosteneditor openen.', 'help.guide.booking-cost.step.2': 'Klik op Kostenpost aanmaken. De boeking wordt opgeslagen, het formulier gaat dicht en de Kosten-editor gaat open.', 'help.guide.booking-cost.step.3': @@ -3335,10 +3335,11 @@ const help: TranslationStrings = { 'Categorie is die welke het boekingstype impliceert. Zet Wie heeft betaald? en hoe het bedrag verdeeld wordt.', 'help.guide.booking-cost.step.5': 'Klik op Uitgave toevoegen.', 'help.guide.booking-cost.result': - 'Het formulier van de boeking leest nu Gekoppelde kostenpost met het bedrag, en dezelfde uitgave staat in het tabblad Onkosten, vast aan deze boeking.', + 'Het formulier van de boeking toont de uitgave nu onder Gekoppelde kostenposten met het bedrag, en dezelfde uitgave staat in het tabblad Onkosten, vast aan deze boeking.', 'help.guide.booking-cost.tip.1': 'De categorie volgt het type: Restaurant wordt Eten & drinken, Accommodatie wordt Accommodatie, Parkeren wordt Parkeren, en Evenement en Rondleiding landen allebei in Overig.', - 'help.guide.booking-cost.tip.2': 'De prullenbak bij Gekoppelde kostenpost haalt de uitgave weg, niet de boeking.', + 'help.guide.booking-cost.tip.2': + 'Een boeking kan meerdere kostenposten dragen. Bestaande kostenpost koppelen biedt de posten in Onkosten aan die nog nergens bij horen. Bij een gekoppelde maakt Ontkoppelen, kostenpost behouden hem los en laat hem in Onkosten staan, terwijl de prullenbak hem weghaalt.', 'help.guide.booking-cost.tip.3': 'Kosten staat alleen in het formulier zolang de add-on Onkosten aan staat, die de beheerder onder Add-ons omzet.', // filter-bookings @@ -3583,15 +3584,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Open de boeking op het tabblad Transport of Boekingen en klik op zijn potlood.', 'help.guide.expense-from-booking.step.2': - 'Scroll naar het blok Kosten onderaan het formulier. Zolang er niets gekoppeld is, biedt het Kostenpost aanmaken aan en zegt het dat het eerst de boeking opslaat.', + 'Scroll naar het blok Kosten onderaan het formulier. Het biedt Kostenpost aanmaken aan, dat eerst de boeking opslaat, en Bestaande kostenpost koppelen voor een post die al in Onkosten staat.', 'help.guide.expense-from-booking.step.3': 'Klik op Kostenpost aanmaken. De boeking wordt opgeslagen, het formulier gaat dicht, en de Onkosteneditor gaat open met de titel van de boeking als naam en zijn type al gekoppeld aan een categorie.', 'help.guide.expense-from-booking.step.4': - 'Vul het bedrag en de valuta ervan, wie heeft betaald en de verdeling in zoals bij elke uitgave, en sla op. Als je de boeking opnieuw opent, staat hij onder Gekoppelde kostenpost, met een potlood om hem te bewerken en een prullenbak om hem te verwijderen.', + 'Vul het bedrag en de valuta ervan, wie heeft betaald en de verdeling in zoals bij elke uitgave, en sla op. Als je de boeking opnieuw opent, staat hij onder Gekoppelde kostenposten, met een potlood om hem te bewerken, Ontkoppelen, kostenpost behouden om hem los te maken en een prullenbak om hem te verwijderen.', 'help.guide.expense-from-booking.result': 'De boeking draagt zijn kosten, en de uitgave is een gewone rij op het tabblad Onkosten, met een betaler, een verdeling en een valuta zoals elke andere.', 'help.guide.expense-from-booking.tip.1': - 'De boeking verwijderen verwijdert ook zijn gekoppelde kostenpost. Kostenpost verwijderen in het blok Kosten van de boeking doet het omgekeerde: de kostenpost gaat weg, de boeking blijft.', + 'De boeking verwijderen verwijdert ook zijn gekoppelde kostenposten. Kostenpost verwijderen in het blok Kosten van de boeking doet het omgekeerde: de kostenpost gaat weg, de boeking blijft. Ontkoppelen, kostenpost behouden houdt ze allebei.', 'help.guide.expense-from-booking.tip.2': 'Een plek heeft hetzelfde blok in zijn formulier, waar Kostenpost aanmaken eerst de plek opslaat.', diff --git a/shared/src/i18n/nl/reservations.ts b/shared/src/i18n/nl/reservations.ts index b35047643..3703ff469 100644 --- a/shared/src/i18n/nl/reservations.ts +++ b/shared/src/i18n/nl/reservations.ts @@ -171,6 +171,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': 'Boeking opslaan en daarna de Onkosteneditor openen.', 'reservations.linkedExpense': 'Gekoppelde kostenpost', 'reservations.removeExpense': 'Kostenpost verwijderen', + 'reservations.linkedExpenses': 'Gekoppelde kostenposten', + 'reservations.linkExpense': 'Bestaande kostenpost koppelen', + 'reservations.unlinkExpense': 'Ontkoppelen, kostenpost behouden', + 'reservations.noUnlinkedExpenses': 'Geen ongekoppelde kostenposten', 'reservations.urlLabel': 'Link', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Reizigers', diff --git a/shared/src/i18n/pl/help.ts b/shared/src/i18n/pl/help.ts index fed2b008a..0ee35672f 100644 --- a/shared/src/i18n/pl/help.ts +++ b/shared/src/i18n/pl/help.ts @@ -1836,7 +1836,7 @@ const help: TranslationStrings = { 'Kliknij Dodaj. Jeśli miejsce o tej samej nazwie już jest w podróży, formularz to mówi, a przycisk zmienia się w Dodaj mimo to.', 'help.guide.create-place.result': 'Miejsce jest na liście i na mapie, pod Niezaplanowane, dopóki nie trafi do dnia.', 'help.guide.create-place.tip.1': - 'Pliki i Costs na dole formularza dołączają do miejsca dokument albo otwierają edytor Costs dla jego wydatku zaraz po zapisaniu.', + 'Pliki i Koszty na dole formularza dołączają do miejsca dokument albo otwierają edytor Koszty dla jego wydatku zaraz po zapisaniu.', 'help.guide.create-place.tip.2': 'Wyszukiwanie na każdym TREK-u obsługują indeks TREK i OpenStreetMap, a Szczegóły miejsca uzupełniają się z Wikipedii, Wikivoyage i Wikimedia. Google jest pytany tylko tam, gdzie oba nic nie znajdą, i tylko on przynosi oceny.', 'help.guide.create-place.tip.3': @@ -3159,7 +3159,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Kliknij Rezerwacja ręczna u góry po prawej stronie zakładki. Otwiera się Nowa rezerwacja.', 'help.guide.create-booking.step.2': - 'Wybierz Rodzaj rezerwacji. Zakwaterowanie, Restauracja, Wydarzenie, Wycieczka, Parking i Inne to te sześć, które ta zakładka tworzy, a formularz zmienia się razem z wyborem: tylko Zakwaterowanie wymienia swoje daty na zakres dni.', + 'Wybierz Rodzaj rezerwacji z listy u góry formularza, obok pola Podróżni. Zakwaterowanie, Restauracja, Wydarzenie, Wycieczka, Parking i Inne to te sześć, które ta zakładka tworzy, a formularz zmienia się razem z wyborem: tylko Zakwaterowanie wymienia swoje daty na zakres dni.', 'help.guide.create-booking.step.3': 'Wpisz Tytuł. To jedyne pole, przy którym formularz się upiera, a Dodaj pozostaje martwy, dopóki nic w nim nie ma.', 'help.guide.create-booking.step.4': @@ -3216,9 +3216,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Zaznacz podróżnych, których rezerwacja obejmuje, a potem oglądaj tylko ich rezerwacje.', 'help.guide.booking-travelers.step.1': - 'Otwórz rezerwację ołówkiem i zjedź w dół do Podróżni. Stoi tam każdy uczestnik podróży, a wśród nich nazwani goście.', + 'Otwórz rezerwację ołówkiem. Pole Podróżni jest u góry formularza, obok pola Rodzaj rezerwacji, i dopóki nikogo nie ma w rezerwacji, pokazuje Przypisz podróżnych.', 'help.guide.booking-travelers.step.2': - 'Kliknij ludzi, dla których ta rezerwacja jest. Wybrany dostaje ptaszka; kliknij go jeszcze raz, żeby go zdjąć.', + 'Kliknij je i wybierz ludzi, dla których ta rezerwacja jest; nazwani goście też są na liście. Wybrany dostaje ptaszka i swój awatar w polu. Kliknij imię jeszcze raz, żeby go zdjąć.', 'help.guide.booking-travelers.step.3': 'Kliknij Aktualizuj.', 'help.guide.booking-travelers.step.4': 'Na górze, w pasku obok plakietek rodzajów, kliknij awatar podróżnego, żeby zobaczyć tylko jego rezerwacje.', @@ -3250,21 +3250,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Przenieś to, ile rezerwacja kosztuje, do Kosztów, podzielone między ludzi, którzy za nią płacą.', 'help.guide.booking-cost.step.1': - 'Otwórz rezerwację i zjedź na sam dół formularza. Pod Costs stoi Create expense, z notką, że zapisuje rezerwację, a potem otwiera edytor kosztów.', + 'Otwórz rezerwację i zjedź na sam dół formularza. Pod Koszty stoją Utwórz wydatek i Powiąż istniejący wydatek, z notką Zapisuje rezerwację i otwiera edytor kosztów.', 'help.guide.booking-cost.step.2': - 'Kliknij Create expense. Rezerwacja zostaje zapisana, jej formularz się zamyka i otwiera się edytor kosztów.', + 'Kliknij Utwórz wydatek. Rezerwacja zostaje zapisana, jej formularz się zamyka i otwiera się edytor kosztów.', 'help.guide.booking-cost.step.3': 'Na co to było? to już tytuł rezerwacji. Wstaw Łączną kwotę i sprawdź Walutę oraz Dzień.', 'help.guide.booking-cost.step.4': 'Kategoria jest tą, którą podpowiada rodzaj rezerwacji. Ustaw Kto zapłacił? i to, jak kwota się dzieli.', 'help.guide.booking-cost.step.5': 'Kliknij Dodaj wydatek.', 'help.guide.booking-cost.result': - 'W formularzu rezerwacji stoi teraz Linked expense z kwotą, a ten sam wydatek stoi w zakładce Koszty, związany z tą rezerwacją.', + 'Formularz rezerwacji pokazuje teraz wydatek pod Powiązane wydatki z jego kwotą, a ten sam wydatek stoi w zakładce Koszty, związany z tą rezerwacją.', 'help.guide.booking-cost.tip.1': 'Kategoria idzie za rodzajem: z Restauracji robi się Jedzenie i napoje, z Zakwaterowania Nocleg, z Parkingu Parking, a Wydarzenie i Wycieczka lądują oba w Inne.', - 'help.guide.booking-cost.tip.2': 'Kosz przy Linked expense usuwa wydatek, nie rezerwację.', + 'help.guide.booking-cost.tip.2': + 'Rezerwacja może nieść kilka wydatków. Powiąż istniejący wydatek proponuje te z Kosztów, które jeszcze do niczego nie należą. Przy powiązanym Odłącz, zachowaj wydatek go odpina i zostawia w Kosztach, a kosz go usuwa.', 'help.guide.booking-cost.tip.3': - 'Costs jest w formularzu tylko wtedy, gdy dodatek Koszty jest włączony, a przełącza go administrator pod Dodatki.', + 'Blok Koszty jest w formularzu tylko wtedy, gdy dodatek Koszty jest włączony, a przełącza go administrator pod Dodatki.', // filter-bookings 'help.guide.filter-bookings.title': 'Znaleźć rezerwację', 'help.guide.filter-bookings.goal': 'Zawęź długą zakładkę do rodzaju, osoby albo stanu, którego szukasz.', @@ -3334,7 +3335,7 @@ const help: TranslationStrings = { 'Dokumenty, które były dołączone, zostają w zakładce Pliki podróży; odchodzi tylko ich powiązanie z rezerwacją.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Każda znaleziona rezerwacja otwiera się w Nowa rezerwacja, jedna po drugiej, już wypełniona. Dla hotelu to nazwa w Tytuł i, gdy podróż ma to miejsce, pod Zakwaterowanie, jego Lokalizacja / Adres, Od i Do na jego nocach, Zameldowanie i Wymeldowanie, Kod rezerwacji, potwierdzenie pod Pliki i, przy włączonych Kosztach, cena jako Linked expense. Sprawdź to i kliknij Dodaj.', + 'Każda znaleziona rezerwacja otwiera się w Nowa rezerwacja, jedna po drugiej, już wypełniona. Dla hotelu to nazwa w Tytuł i, gdy podróż ma to miejsce, pod Zakwaterowanie, jego Lokalizacja / Adres, Od i Do na jego nocach, Zameldowanie i Wymeldowanie, Kod rezerwacji, potwierdzenie pod Pliki i, przy włączonych Kosztach, cena jako Powiązany wydatek. Sprawdź to i kliknij Dodaj.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Koszty', @@ -3503,17 +3504,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Otwórz rezerwację na zakładce Transport albo Rezerwacje i kliknij jej ołówek.', 'help.guide.expense-from-booking.step.2': - 'Przewiń do bloku Costs na dole formularza. Dopóki nic nie jest powiązane, oferuje Create expense i mówi, że najpierw zapisuje rezerwację.', + 'Przewiń do bloku Koszty na dole formularza. Oferuje Utwórz wydatek, który najpierw zapisuje rezerwację, i Powiąż istniejący wydatek dla takiego, który już jest w Kosztach.', 'help.guide.expense-from-booking.step.3': - 'Kliknij Create expense. Rezerwacja zostaje zapisana, formularz się zamyka, a edytor Koszty otwiera się z tytułem rezerwacji jako nazwą i jej typem już dopasowanym do kategorii.', + 'Kliknij Utwórz wydatek. Rezerwacja zostaje zapisana, formularz się zamyka, a edytor Koszty otwiera się z tytułem rezerwacji jako nazwą i jej typem już dopasowanym do kategorii.', 'help.guide.expense-from-booking.step.4': - 'Wypełnij kwotę i jej walutę, kto zapłacił i podział jak przy każdym wydatku, i zapisz. Ponowne otwarcie rezerwacji pokazuje go teraz pod Linked expense, z ołówkiem do edycji i koszem do usunięcia.', + 'Wypełnij kwotę i jej walutę, kto zapłacił i podział jak przy każdym wydatku, i zapisz. Ponowne otwarcie rezerwacji pokazuje go teraz pod Powiązane wydatki, z ołówkiem do edycji, Odłącz, zachowaj wydatek do odpięcia i koszem do usunięcia.', 'help.guide.expense-from-booking.result': 'Rezerwacja niesie swój koszt, a wydatek jest zwykłym wierszem na zakładce Koszty, z płacącym, podziałem i walutą jak każdy inny.', 'help.guide.expense-from-booking.tip.1': - 'Usunięcie rezerwacji usuwa razem z nią powiązany wydatek. Remove expense w bloku Costs rezerwacji robi odwrotnie: wydatek znika, rezerwacja zostaje.', + 'Usunięcie rezerwacji usuwa razem z nią powiązane wydatki. Usuń wydatek w bloku Koszty rezerwacji robi odwrotnie: wydatek znika, rezerwacja zostaje. Odłącz, zachowaj wydatek zachowuje jedno i drugie.', 'help.guide.expense-from-booking.tip.2': - 'Miejsce ma ten sam blok w swoim formularzu, gdzie Create expense najpierw zapisuje miejsce.', + 'Miejsce ma ten sam blok w swoim formularzu, gdzie Utwórz wydatek najpierw zapisuje miejsce.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Transport', @@ -3570,7 +3571,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'Wiersz ląduje tam, gdzie kładzie go jego godzina rozpoczęcia, za ostatnim przystankiem, który zaczyna się wcześniej. Jego uchwyt przeciąga go gdziekolwiek indziej w dniu albo na inny dzień.', 'help.guide.add-transport.tip.2': - 'Załącz plik pod Pliki bierze bilet, a Create expense pod Costs zapisuje rezerwację i otwiera edytor Koszty dla ceny przejazdu.', + 'Załącz plik pod Pliki bierze bilet, a Utwórz wydatek pod Koszty zapisuje rezerwację i otwiera edytor Koszty dla ceny przejazdu.', 'help.guide.add-transport.tip.3': 'Podróżni zaznacza, kto jedzie. Gdy tylko jeden transport ma podróżnych, pasek narzędzi zakładki wypuszcza ich awatary i filtruje po nich listę.', // plan-transit diff --git a/shared/src/i18n/pl/reservations.ts b/shared/src/i18n/pl/reservations.ts index 11c4c09ca..cec29211b 100644 --- a/shared/src/i18n/pl/reservations.ts +++ b/shared/src/i18n/pl/reservations.ts @@ -165,11 +165,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'Nie znaleziono lotów na Twoim koncie AirTrail.', 'reservations.airtrail.importCta': 'Importuj {count}', 'reservations.airtrail.joinConnection': 'Importuj jako jeden lot z przesiadką w {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Koszty', + 'reservations.createExpense': 'Utwórz wydatek', + 'reservations.createExpenseHint': 'Zapisuje rezerwację i otwiera edytor kosztów.', + 'reservations.linkedExpense': 'Powiązany wydatek', + 'reservations.removeExpense': 'Usuń wydatek', + 'reservations.linkedExpenses': 'Powiązane wydatki', + 'reservations.linkExpense': 'Powiąż istniejący wydatek', + 'reservations.unlinkExpense': 'Odłącz, zachowaj wydatek', + 'reservations.noUnlinkedExpenses': 'Brak niepowiązanych wydatków', 'reservations.urlLabel': 'Link', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Podróżni', diff --git a/shared/src/i18n/ru/help.ts b/shared/src/i18n/ru/help.ts index 5132c70b8..386a13cc9 100644 --- a/shared/src/i18n/ru/help.ts +++ b/shared/src/i18n/ru/help.ts @@ -1850,7 +1850,7 @@ const help: TranslationStrings = { 'Нажмите Добавить. Если место с таким же названием уже есть в поездке, форма это говорит, а кнопка превращается в Всё равно добавить.', 'help.guide.create-place.result': 'Место в списке и на карте, под Незапланированные, пока его не положат на день.', 'help.guide.create-place.tip.1': - 'Файлы и Costs внизу формы прикрепляют к месту документ или открывают редактор Costs для его расхода сразу после сохранения.', + 'Файлы и Расходы внизу формы прикрепляют к месту документ или открывают редактор Расходы для его расхода сразу после сохранения.', 'help.guide.create-place.tip.2': 'Поиск на любом TREK обслуживают индекс TREK и OpenStreetMap, а Сведения о месте заполняются из источников Wikipedia, Wikivoyage и Wikimedia. К Google обращаются только там, где оба остались пустыми, и только он приносит рейтинг.', 'help.guide.create-place.tip.3': @@ -3173,7 +3173,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Нажмите Ручное бронирование справа вверху вкладки. Открывается Новое бронирование.', 'help.guide.create-booking.step.2': - 'Выберите Тип бронирования. Жильё, Ресторан, Мероприятие, Экскурсия, Парковка и Другое это те шесть, которые делает эта вкладка, и форма меняется вместе с выбором: только Жильё меняет свои даты на диапазон дней.', + 'Выберите Тип бронирования в списке вверху формы, рядом с полем Путешественники. Жильё, Ресторан, Мероприятие, Экскурсия, Парковка и Другое это те шесть, которые делает эта вкладка, и форма меняется вместе с выбором: только Жильё меняет свои даты на диапазон дней.', 'help.guide.create-booking.step.3': 'Введите Название. Это единственное поле, на котором форма настаивает, и Добавить остаётся мёртвой, пока в нём ничего нет.', 'help.guide.create-booking.step.4': @@ -3231,9 +3231,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Отметьте путешественников, которых бронирование покрывает, и потом смотрите только их бронирования.', 'help.guide.booking-travelers.step.1': - 'Откройте бронирование карандашом и спуститесь вниз к полю Путешественники. Там стоит каждый участник поездки, названные гости среди них.', + 'Откройте бронирование карандашом. Поле Путешественники стоит вверху формы, рядом с полем Тип бронирования, и показывает Назначить путешественников, пока в бронировании никого нет.', 'help.guide.booking-travelers.step.2': - 'Нажмите на людей, для которых это бронирование. Выбранный получает галочку; нажмите ещё раз, чтобы её снять.', + 'Нажмите на него и выберите людей, для которых это бронирование; названные гости тоже есть в списке. Выбранный получает галочку и свой аватар в поле. Нажмите на имя ещё раз, чтобы его снять.', 'help.guide.booking-travelers.step.3': 'Нажмите Обновить.', 'help.guide.booking-travelers.step.4': 'Наверху, в панели рядом с фишками типов, нажмите на аватар путешественника, чтобы увидеть только его бронирования.', @@ -3267,21 +3267,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Перенесите то, во сколько обходится бронирование, в Расходы, поделённое между людьми, которые за него платят.', 'help.guide.booking-cost.step.1': - 'Откройте бронирование и спуститесь к концу формы. Под Costs стоит Create expense, с пометкой, что он сохраняет бронирование, а потом открывает редактор расходов.', + 'Откройте бронирование и спуститесь к концу формы. Под Расходы стоят Создать расход и Привязать существующий расход, с пометкой Сохраняет бронирование и открывает редактор расходов.', 'help.guide.booking-cost.step.2': - 'Нажмите Create expense. Бронирование сохраняется, его форма закрывается, и открывается редактор расходов.', + 'Нажмите Создать расход. Бронирование сохраняется, его форма закрывается, и открывается редактор расходов.', 'help.guide.booking-cost.step.3': 'За что это было? это уже название бронирования. Вставьте Общую сумму и проверьте Валюту и День.', 'help.guide.booking-cost.step.4': 'Категория это та, на которую указывает тип бронирования. Задайте Кто заплатил? и то, как сумма делится.', 'help.guide.booking-cost.step.5': 'Нажмите Добавить расход.', 'help.guide.booking-cost.result': - 'В форме бронирования теперь стоит Linked expense с суммой, и тот же расход стоит на вкладке Расходы, связанный с этим бронированием.', + 'Форма бронирования теперь показывает расход под Привязанные расходы с его суммой, и тот же расход стоит на вкладке Расходы, связанный с этим бронированием.', 'help.guide.booking-cost.tip.1': 'Категория идёт за типом: из Ресторана получается Еда и напитки, из Жилья Проживание, из Парковки Парковка, а Мероприятие и Экскурсия оба попадают в Прочее.', - 'help.guide.booking-cost.tip.2': 'Корзина у Linked expense убирает расход, а не бронирование.', + 'help.guide.booking-cost.tip.2': + 'Бронирование может нести несколько расходов. Привязать существующий расход предлагает те расходы с вкладки Расходы, что ещё ни к чему не привязаны. У привязанного Отвязать, сохранить расход отпускает его и оставляет в Расходах, а корзина его убирает.', 'help.guide.booking-cost.tip.3': - 'Costs есть в форме только тогда, когда включено дополнение Расходы, которое администратор переключает под Дополнения.', + 'Блок Расходы есть в форме только тогда, когда включено дополнение Расходы, которое администратор переключает под Дополнения.', // filter-bookings 'help.guide.filter-bookings.title': 'Найти бронирование', 'help.guide.filter-bookings.goal': 'Сузьте длинную вкладку до типа, человека или состояния, которые вам нужны.', @@ -3351,7 +3352,7 @@ const help: TranslationStrings = { 'Документы, которые были прикреплены, остаются на вкладке Файлы этой поездки; уходит только их связь с бронированием.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Каждое найденное бронирование открывается в Новое бронирование, одно за другим, уже заполненное. Для отеля это название в Название и, когда у поездки есть это место, под Жильё, его Местоположение / Адрес, С и По на его ночи, Заезд и Выезд, Код бронирования, подтверждение под Файлы и, при включённых Расходах, цена как Linked expense. Проверьте и нажмите Добавить.', + 'Каждое найденное бронирование открывается в Новое бронирование, одно за другим, уже заполненное. Для отеля это название в Название и, когда у поездки есть это место, под Жильё, его Местоположение / Адрес, С и По на его ночи, Заезд и Выезд, Код бронирования, подтверждение под Файлы и, при включённых Расходах, цена как Привязанный расход. Проверьте и нажмите Добавить.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Расходы', @@ -3522,17 +3523,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Откройте бронирование на вкладке Транспорт или Бронирования и нажмите его карандаш.', 'help.guide.expense-from-booking.step.2': - 'Прокрутите до блока Costs внизу формы. Пока ничего не привязано, он предлагает Create expense и говорит, что сначала сохраняет бронирование.', + 'Прокрутите до блока Расходы внизу формы. Он предлагает Создать расход, который сначала сохраняет бронирование, и Привязать существующий расход для того, что уже есть в Расходах.', 'help.guide.expense-from-booking.step.3': - 'Нажмите Create expense. Бронирование сохраняется, форма закрывается, и открывается редактор Расходы с названием бронирования в качестве имени и его типом, уже сопоставленным с категорией.', + 'Нажмите Создать расход. Бронирование сохраняется, форма закрывается, и открывается редактор Расходы с названием бронирования в качестве имени и его типом, уже сопоставленным с категорией.', 'help.guide.expense-from-booking.step.4': - 'Заполните сумму и её валюту, кто заплатил, и разделение как для любого расхода, и сохраните. Если открыть бронирование снова, оно покажет расход под Linked expense, с карандашом, чтобы его изменить, и корзиной, чтобы его убрать.', + 'Заполните сумму и её валюту, кто заплатил, и разделение как для любого расхода, и сохраните. Если открыть бронирование снова, оно покажет расход под Привязанные расходы, с карандашом, чтобы его изменить, Отвязать, сохранить расход, чтобы его отпустить, и корзиной, чтобы его убрать.', 'help.guide.expense-from-booking.result': 'Бронирование несёт свою стоимость, а расход это обычная строка на вкладке Расходы, с плательщиком, разделением и валютой, как у любого другого.', 'help.guide.expense-from-booking.tip.1': - 'Удаление бронирования удаляет вместе с ним и привязанный расход. Remove expense в блоке Costs бронирования делает обратное: расход уходит, бронирование остаётся.', + 'Удаление бронирования удаляет вместе с ним и привязанные расходы. Убрать расход в блоке Расходы бронирования делает обратное: расход уходит, бронирование остаётся. Отвязать, сохранить расход оставляет и то и другое.', 'help.guide.expense-from-booking.tip.2': - 'У места такой же блок в его форме, где Create expense сначала сохраняет место.', + 'У места такой же блок в его форме, где Создать расход сначала сохраняет место.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Транспорт', @@ -3589,7 +3590,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'Строка приземляется там, куда её кладёт её время начала, после последней остановки, которая начинается раньше. Её ручка перетаскивает её куда угодно ещё в дне или на другой день.', 'help.guide.add-transport.tip.2': - 'Прикрепить файл в разделе Файлы принимает билет, а Create expense в разделе Costs сохраняет бронирование и открывает редактор Расходы для стоимости проезда.', + 'Прикрепить файл в разделе Файлы принимает билет, а Создать расход в разделе Расходы сохраняет бронирование и открывает редактор Расходы для стоимости проезда.', 'help.guide.add-transport.tip.3': 'Путешественники отмечает, кто едет. Как только у одного транспорта есть путешественники, на панели вкладки вырастают их аватары и фильтруют список по ним.', // plan-transit diff --git a/shared/src/i18n/ru/reservations.ts b/shared/src/i18n/ru/reservations.ts index 70b8a628d..4b2292d56 100644 --- a/shared/src/i18n/ru/reservations.ts +++ b/shared/src/i18n/ru/reservations.ts @@ -164,11 +164,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'В вашей учётной записи AirTrail не найдено рейсов.', 'reservations.airtrail.importCta': 'Импортировать {count}', 'reservations.airtrail.joinConnection': 'Импортировать как один рейс с пересадкой в {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Расходы', + 'reservations.createExpense': 'Создать расход', + 'reservations.createExpenseHint': 'Сохраняет бронирование и открывает редактор расходов.', + 'reservations.linkedExpense': 'Привязанный расход', + 'reservations.removeExpense': 'Убрать расход', + 'reservations.linkedExpenses': 'Привязанные расходы', + 'reservations.linkExpense': 'Привязать существующий расход', + 'reservations.unlinkExpense': 'Отвязать, сохранить расход', + 'reservations.noUnlinkedExpenses': 'Нет непривязанных расходов', 'reservations.urlLabel': 'Ссылка', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Путешественники', diff --git a/shared/src/i18n/sk/help.ts b/shared/src/i18n/sk/help.ts index 02b0a5fde..5463b6944 100644 --- a/shared/src/i18n/sk/help.ts +++ b/shared/src/i18n/sk/help.ts @@ -2867,7 +2867,7 @@ const help: TranslationStrings = { 'Zadajte do cesty ručne reštauráciu, udalosť, prehliadku, parkovacie miesto alebo čokoľvek iné.', 'help.guide.create-booking.step.1': 'Kliknite na Ručná rezervácia vpravo hore na záložke. Otvorí sa Nová rezervácia.', 'help.guide.create-booking.step.2': - 'Vyberte Typ rezervácie. Ubytovanie, Reštaurácia, Udalosť, Prehliadka, Parkovanie a Ostatné je šesť typov, ktoré táto záložka vytvára, a formulár sa mení podľa voľby: len Ubytovanie vymení dátumy za rozsah dní.', + 'Vyberte Typ rezervácie zo zoznamu v hornej časti formulára, vedľa poľa Cestujúci. Ubytovanie, Reštaurácia, Udalosť, Prehliadka, Parkovanie a Ostatné je šesť typov, ktoré táto záložka vytvára, a formulár sa mení podľa voľby: len Ubytovanie vymení dátumy za rozsah dní.', 'help.guide.create-booking.step.3': 'Zadajte Názov. Je to jediné pole, na ktorom formulár trvá, a Pridať zostane neaktívne, kým v ňom niečo nie je.', 'help.guide.create-booking.step.4': @@ -2924,9 +2924,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Označte cestujúcich, na ktorých sa rezervácia vzťahuje, a potom zobrazte len ich rezervácie.', 'help.guide.booking-travelers.step.1': - 'Otvorte rezerváciu ceruzkou a prejdite nadol na Cestujúci. Nachádza sa tam každý člen cesty vrátane pomenovaných hostí.', + 'Otvorte rezerváciu ceruzkou. Pole Cestujúci je v hornej časti formulára, vedľa poľa Typ rezervácie, a kým na rezervácii nikto nie je, ukazuje Priradiť cestujúcich.', 'help.guide.booking-travelers.step.2': - 'Kliknite na ľudí, pre ktorých je rezervácia. Vybraný dostane fajku; ďalším kliknutím ju odstránite.', + 'Kliknite naň a vyberte ľudí, pre ktorých je rezervácia; pomenovaní hostia sú v zozname tiež. Vybraný dostane fajku a svoj avatar v poli. Ďalším kliknutím na meno ho odstránite.', 'help.guide.booking-travelers.step.3': 'Kliknite na Aktualizovať.', 'help.guide.booking-travelers.step.4': 'Hore na paneli nástrojov vedľa štítkov typov kliknite na avatar cestujúceho, aby ste videli len jeho rezervácie.', @@ -2957,7 +2957,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Premeňte cenu rezervácie na náklad', 'help.guide.booking-cost.goal': 'Dostaňte cenu rezervácie do Nákladov, rozdelenú medzi ľudí, ktorí za ňu platia.', 'help.guide.booking-cost.step.1': - 'Otvorte rezerváciu a prejdite do spodnej časti formulára. V časti Náklady je Vytvoriť výdavok s poznámkou Uloží rezerváciu a potom otvorí editor nákladov.', + 'Otvorte rezerváciu a prejdite do spodnej časti formulára. V časti Náklady sú Vytvoriť výdavok a Prepojiť existujúci výdavok s poznámkou Uloží rezerváciu a potom otvorí editor nákladov.', 'help.guide.booking-cost.step.2': 'Kliknite na Vytvoriť výdavok. Rezervácia sa uloží, jej formulár sa zatvorí a otvorí sa editor nákladov.', 'help.guide.booking-cost.step.3': @@ -2966,10 +2966,11 @@ const help: TranslationStrings = { 'Kategória je tá, ktorú naznačuje typ rezervácie. Nastavte Kto zaplatil? a spôsob rozdelenia sumy.', 'help.guide.booking-cost.step.5': 'Kliknite na Pridať výdavok.', 'help.guide.booking-cost.result': - 'Formulár rezervácie teraz ukazuje Prepojený výdavok so sumou a ten istý výdavok je na záložke Náklady, prepojený s touto rezerváciou.', + 'Formulár rezervácie teraz uvádza výdavok v časti Prepojené výdavky s jeho sumou a ten istý výdavok je na záložke Náklady, prepojený s touto rezerváciou.', 'help.guide.booking-cost.tip.1': 'Kategória sa riadi typom: z Reštaurácie je Jedlo a pitie, z Ubytovania Ubytovanie, z Parkovania Parkovanie a Udalosť aj Prehliadka skončia v Ostatné.', - 'help.guide.booking-cost.tip.2': 'Kôš pri Prepojený výdavok odstráni výdavok, nie rezerváciu.', + 'help.guide.booking-cost.tip.2': + 'Rezervácia môže niesť viac výdavkov. Prepojiť existujúci výdavok ponúka tie z Nákladov, ktoré zatiaľ nikam nepatria. Pri prepojenom ho Odpojiť, výdavok ponechať uvoľní a nechá v Nákladoch, kým kôš ho odstráni.', 'help.guide.booking-cost.tip.3': 'Náklady sú vo formulári len vtedy, keď je zapnutý doplnok Náklady, ktorý administrátor zapína v časti Doplnky.', // filter-bookings @@ -3395,15 +3396,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Otvorte rezerváciu na karte Doprava alebo Rezervácie a kliknite na jej ceruzku.', 'help.guide.expense-from-booking.step.2': - 'Prejdite na blok Náklady v spodnej časti formulára. Kým nie je nič prepojené, ponúka Vytvoriť výdavok a uvádza, že najprv uloží rezerváciu.', + 'Prejdite na blok Náklady v spodnej časti formulára. Ponúka Vytvoriť výdavok, ktorý najprv uloží rezerváciu, a Prepojiť existujúci výdavok pre výdavok, ktorý už v Nákladoch je.', 'help.guide.expense-from-booking.step.3': 'Kliknite na Vytvoriť výdavok. Rezervácia sa uloží, formulár sa zatvorí a otvorí sa editor nákladov s názvom rezervácie ako názvom výdavku a s kategóriou priradenou podľa jej typu.', 'help.guide.expense-from-booking.step.4': - 'Vyplňte sumu a jej menu, kto zaplatil a rozdelenie ako pri každom výdavku a uložte. Po opätovnom otvorení rezervácie je výdavok v časti Prepojený výdavok, s ceruzkou na úpravu a košom na odstránenie.', + 'Vyplňte sumu a jej menu, kto zaplatil a rozdelenie ako pri každom výdavku a uložte. Po opätovnom otvorení rezervácie je výdavok v časti Prepojené výdavky, s ceruzkou na úpravu, Odpojiť, výdavok ponechať na uvoľnenie a košom na odstránenie.', 'help.guide.expense-from-booking.result': 'Rezervácia nesie svoju cenu a výdavok je bežný riadok na karte Náklady s platiteľom, rozdelením a menou ako ktorýkoľvek iný.', 'help.guide.expense-from-booking.tip.1': - 'Vymazaním rezervácie sa vymaže aj jej prepojený výdavok. Odstrániť výdavok v bloku Náklady rezervácie robí opak: výdavok zmizne, rezervácia zostane.', + 'Vymazaním rezervácie sa vymažú aj jej prepojené výdavky. Odstrániť výdavok v bloku Náklady rezervácie robí opak: výdavok zmizne, rezervácia zostane. Odpojiť, výdavok ponechať zachová oboje.', 'help.guide.expense-from-booking.tip.2': 'Miesto má vo svojom formulári rovnaký blok a Vytvoriť výdavok v ňom najprv uloží miesto.', diff --git a/shared/src/i18n/sk/reservations.ts b/shared/src/i18n/sk/reservations.ts index d5c40e9a8..bd000dd3a 100644 --- a/shared/src/i18n/sk/reservations.ts +++ b/shared/src/i18n/sk/reservations.ts @@ -174,6 +174,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': 'Uloží rezerváciu a potom otvorí editor nákladov.', 'reservations.linkedExpense': 'Prepojený výdavok', 'reservations.removeExpense': 'Odstrániť výdavok', + 'reservations.linkedExpenses': 'Prepojené výdavky', + 'reservations.linkExpense': 'Prepojiť existujúci výdavok', + 'reservations.unlinkExpense': 'Odpojiť, výdavok ponechať', + 'reservations.noUnlinkedExpenses': 'Žiadne neprepojené výdavky', 'reservations.travelers.label': 'Cestujúci', 'reservations.travelers.assign': 'Priradiť cestujúcich', 'reservations.travelers.none': 'Zatiaľ žiadni účastníci cesty.', diff --git a/shared/src/i18n/sv/help.ts b/shared/src/i18n/sv/help.ts index 7e042cf08..f1016b9bd 100644 --- a/shared/src/i18n/sv/help.ts +++ b/shared/src/i18n/sv/help.ts @@ -3122,7 +3122,7 @@ const help: TranslationStrings = { 'Lägg in en restaurang, ett evenemang, en rundtur, en parkeringsplats eller vad som helst annat i resan för hand.', 'help.guide.create-booking.step.1': 'Klicka på Manuell bokning högst upp till höger på fliken. Ny bokning öppnas.', 'help.guide.create-booking.step.2': - 'Välj Bokningstyp. Boende, Restaurang, Evenemang, Rundtur, Parkering och Övrigt är de sex som den här fliken gör, och formuläret ändras med valet: bara Boende byter sina datum mot ett spann av dagar.', + 'Välj Bokningstyp i listan överst i formuläret, bredvid Resenärer. Boende, Restaurang, Evenemang, Rundtur, Parkering och Övrigt är de sex som den här fliken gör, och formuläret ändras med valet: bara Boende byter sina datum mot ett spann av dagar.', 'help.guide.create-booking.step.3': 'Skriv in Titel. Det är det enda fältet formuläret kräver, och Lägg till är död tills det står något i det.', 'help.guide.create-booking.step.4': @@ -3178,9 +3178,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Säg vem en bokning gäller', 'help.guide.booking-travelers.goal': 'Märk ut resenärerna en bokning täcker, och se sedan bara deras.', 'help.guide.booking-travelers.step.1': - 'Öppna bokningen med pennan och gå ner till Resenärer. Varje medlem i resan står där, namngivna gäster bland dem.', + 'Öppna bokningen med pennan. Resenärer sitter överst i formuläret, bredvid Bokningstyp, och visar Tilldela resenärer så länge ingen är med på bokningen.', 'help.guide.booking-travelers.step.2': - 'Klicka på personerna den här bokningen gäller. En vald får en bock; klicka igen för att ta bort den.', + 'Klicka på det och välj personerna den här bokningen gäller; namngivna gäster finns också i listan. En vald får en bock och sin avatar i fältet. Klicka på namnet igen för att ta bort den.', 'help.guide.booking-travelers.step.3': 'Klicka på Uppdatera.', 'help.guide.booking-travelers.step.4': 'Uppe i verktygsraden, bredvid typbrickorna, klicka på en resenärs avatar för att bara se dennes bokningar.', @@ -3211,7 +3211,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Gör en boknings pris till en kostnad', 'help.guide.booking-cost.goal': 'Få in det en bokning kostar i Kostnader, fördelat mellan dem som betalar för den.', 'help.guide.booking-cost.step.1': - 'Öppna bokningen och gå till formulärets fot. Under Kostnader står Skapa utgift, med noteringen Sparar bokningen och öppnar sedan kostnadsredigeraren.', + 'Öppna bokningen och gå till formulärets fot. Under Kostnader står Skapa utgift och Länka befintlig utgift, med noteringen Sparar bokningen och öppnar sedan kostnadsredigeraren.', 'help.guide.booking-cost.step.2': 'Klicka på Skapa utgift. Bokningen sparas, dess formulär stängs och kostnadsredigeraren öppnas.', 'help.guide.booking-cost.step.3': @@ -3220,10 +3220,11 @@ const help: TranslationStrings = { 'Kategori är den som bokningstypen antyder. Sätt Vem betalade? och hur beloppet fördelas.', 'help.guide.booking-cost.step.5': 'Klicka på Lägg till utgift.', 'help.guide.booking-cost.result': - 'Bokningens formulär läser nu Relaterade kostnader med beloppet, och samma utgift står på fliken Kostnader, knuten till den här bokningen.', + 'Bokningens formulär listar nu utgiften under Länkade utgifter med dess belopp, och samma utgift står på fliken Kostnader, knuten till den här bokningen.', 'help.guide.booking-cost.tip.1': 'Kategorin följer typen: Restaurang blir Mat och dryck, Boende blir Boende, Parkering blir Parkering, och Evenemang och Rundtur hamnar båda i Annat.', - 'help.guide.booking-cost.tip.2': 'Papperskorgen på Relaterade kostnader tar bort utgiften, inte bokningen.', + 'help.guide.booking-cost.tip.2': + 'En bokning kan bära flera utgifter. Länka befintlig utgift erbjuder de i Kostnader som ännu inte hör till något. På en länkad kopplar Koppla ifrån, behåll utgiften loss den och lämnar den i Kostnader, medan papperskorgen tar bort den.', 'help.guide.booking-cost.tip.3': 'Kostnader finns i formuläret bara medan tillägget Kostnader är på, vilket administratören slår om under Tillägg.', // filter-bookings @@ -3468,15 +3469,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Öppna bokningen på fliken Transporter eller Bokningar och klicka på dess penna.', 'help.guide.expense-from-booking.step.2': - 'Bläddra till blocket Kostnader längst ned i formuläret. Med inget länkat ännu erbjuder det Skapa utgift och säger att det sparar bokningen först.', + 'Bläddra till blocket Kostnader längst ned i formuläret. Det erbjuder Skapa utgift, som sparar bokningen först, och Länka befintlig utgift för en som redan finns i Kostnader.', 'help.guide.expense-from-booking.step.3': 'Klicka på Skapa utgift. Bokningen sparas, formuläret stängs, och Kostnader-redigeraren öppnas med bokningens titel som namn och dess typ redan matchad mot en kategori.', 'help.guide.expense-from-booking.step.4': - 'Fyll i beloppet och dess valuta, vem som betalade och fördelningen som för vilken utgift som helst, och spara. Öppnar du bokningen igen visas den nu under Relaterade kostnader, med en penna för att redigera den och en papperskorg för att ta bort den.', + 'Fyll i beloppet och dess valuta, vem som betalade och fördelningen som för vilken utgift som helst, och spara. Öppnar du bokningen igen visas den nu under Länkade utgifter, med en penna för att redigera den, Koppla ifrån, behåll utgiften för att koppla loss den och en papperskorg för att ta bort den.', 'help.guide.expense-from-booking.result': 'Bokningen bär sin kostnad, och utgiften är en vanlig rad på fliken Kostnader, med en betalare, en fördelning och en valuta som vilken annan som helst.', 'help.guide.expense-from-booking.tip.1': - 'Att ta bort bokningen tar bort dess länkade utgift med den. Ta bort utgiften i bokningens Kostnader-block gör det motsatta: utgiften försvinner, bokningen stannar.', + 'Att ta bort bokningen tar bort dess länkade utgifter med den. Ta bort utgiften i bokningens Kostnader-block gör det motsatta: utgiften försvinner, bokningen stannar. Koppla ifrån, behåll utgiften behåller båda.', 'help.guide.expense-from-booking.tip.2': 'En plats har samma block i sitt formulär, där Skapa utgift sparar platsen först.', diff --git a/shared/src/i18n/sv/reservations.ts b/shared/src/i18n/sv/reservations.ts index 870b2f9ce..da7bd5ad3 100644 --- a/shared/src/i18n/sv/reservations.ts +++ b/shared/src/i18n/sv/reservations.ts @@ -170,6 +170,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': 'Sparar bokningen och öppnar sedan kostnadsredigeraren.', 'reservations.linkedExpense': 'Relaterade kostnader', 'reservations.removeExpense': 'Ta bort utgiften', + 'reservations.linkedExpenses': 'Länkade utgifter', + 'reservations.linkExpense': 'Länka befintlig utgift', + 'reservations.unlinkExpense': 'Koppla ifrån, behåll utgiften', + 'reservations.noUnlinkedExpenses': 'Inga olänkade utgifter', 'reservations.urlLabel': 'Länk', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Resenärer', diff --git a/shared/src/i18n/th/help.ts b/shared/src/i18n/th/help.ts index 3c4da65bd..551ff3b50 100644 --- a/shared/src/i18n/th/help.ts +++ b/shared/src/i18n/th/help.ts @@ -2408,7 +2408,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.goal': 'ใส่ร้านอาหาร อีเวนต์ ทัวร์ ที่จอดรถ หรืออย่างอื่นลงในทริปด้วยตนเอง', 'help.guide.create-booking.step.1': 'คลิก การจองด้วยตนเอง ที่มุมขวาบนของแท็บ การจองใหม่ จะเปิดขึ้น', 'help.guide.create-booking.step.2': - 'เลือก ประเภทการจอง ที่พัก, ร้านอาหาร, เหตุการณ์, ทัวร์, ที่จอดรถ และ อื่นๆ คือหกประเภทที่แท็บนี้สร้างได้ และแบบฟอร์มจะเปลี่ยนตามตัวเลือก: มีเพียง ที่พัก ที่ใช้ช่วงวันแทนวันที่', + 'เลือก ประเภทการจอง จากรายการที่ด้านบนของแบบฟอร์ม ข้าง นักเดินทาง ที่พัก, ร้านอาหาร, เหตุการณ์, ทัวร์, ที่จอดรถ และ อื่นๆ คือหกประเภทที่แท็บนี้สร้างได้ และแบบฟอร์มจะเปลี่ยนตามตัวเลือก: มีเพียง ที่พัก ที่ใช้ช่วงวันแทนวันที่', 'help.guide.create-booking.step.3': 'พิมพ์ ชื่อเรื่อง ซึ่งเป็นช่องเดียวที่แบบฟอร์มบังคับ และปุ่ม เพิ่ม จะกดไม่ได้จนกว่าจะมีข้อความ', 'help.guide.create-booking.step.4': 'ตั้ง วันที่ และ เวลาเริ่มต้น รวมถึง วันที่สิ้นสุด และ เวลาสิ้นสุด หากการจองมีเวลาสิ้นสุด ปฏิทินจะแสดงเฉพาะวันในช่วงทริป และเวลาสิ้นสุดที่ไม่ได้อยู่หลังเวลาเริ่มจะแจ้งเป็นสีแดงและปิดปุ่ม เพิ่ม', @@ -2451,8 +2451,10 @@ const help: TranslationStrings = { // booking-travelers 'help.guide.booking-travelers.title': 'ระบุว่าการจองนี้สำหรับใคร', 'help.guide.booking-travelers.goal': 'ระบุนักเดินทางที่การจองครอบคลุม แล้วดูเฉพาะการจองของพวกเขา', - 'help.guide.booking-travelers.step.1': 'เปิดการจองด้วยไอคอนดินสอแล้วเลื่อนลงไปที่ นักเดินทาง สมาชิกทุกคนของทริปอยู่ที่นั่น รวมถึงแขกที่มีชื่อ', - 'help.guide.booking-travelers.step.2': 'คลิกคนที่การจองนี้เป็นของ คนที่เลือกจะมีเครื่องหมายถูก คลิกอีกครั้งเพื่อเอาออก', + 'help.guide.booking-travelers.step.1': + 'เปิดการจองด้วยไอคอนดินสอ นักเดินทาง อยู่ที่ด้านบนของแบบฟอร์ม ข้าง ประเภทการจอง และจะแสดง มอบหมายให้นักเดินทาง ตราบใดที่ยังไม่มีใครอยู่ในการจอง', + 'help.guide.booking-travelers.step.2': + 'คลิกที่ช่องนั้นแล้วเลือกคนที่การจองนี้เป็นของ แขกที่มีชื่อก็อยู่ในรายการด้วย คนที่เลือกจะมีเครื่องหมายถูกและรูปอวตารของเขาในช่อง คลิกที่ชื่ออีกครั้งเพื่อเอาออก', 'help.guide.booking-travelers.step.3': 'คลิก อัปเดต', 'help.guide.booking-travelers.step.4': 'ในแถบเครื่องมือด้านบน ข้างป้ายประเภท คลิกอวาตาร์ของนักเดินทางเพื่อดูเฉพาะการจองของคนนั้น', 'help.guide.booking-travelers.result': 'การ์ดแสดงรายชื่อคนที่การจองเป็นของ และแถวอวาตาร์จะกรองแท็บให้เหลือคนใดคนหนึ่ง', @@ -2476,16 +2478,17 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'เปลี่ยนราคาการจองเป็นค่าใช้จ่าย', 'help.guide.booking-cost.goal': 'นำค่าใช้จ่ายของการจองเข้าสู่ ค่าใช้จ่าย โดยแบ่งระหว่างคนที่จ่าย', 'help.guide.booking-cost.step.1': - 'เปิดการจองแล้วไปที่ด้านล่างของแบบฟอร์ม ใต้ ค่าใช้จ่าย มี สร้างรายจ่าย พร้อมหมายเหตุ บันทึกการจอง จากนั้นเปิดตัวแก้ไขต้นทุน', + 'เปิดการจองแล้วไปที่ด้านล่างของแบบฟอร์ม ใต้ ค่าใช้จ่าย มี สร้างรายจ่าย และ เชื่อมโยงค่าใช้จ่ายที่มีอยู่ พร้อมหมายเหตุ บันทึกการจอง จากนั้นเปิดตัวแก้ไขต้นทุน', 'help.guide.booking-cost.step.2': 'คลิก สร้างรายจ่าย การจองจะถูกบันทึก แบบฟอร์มปิดลง และตัวแก้ไขค่าใช้จ่ายจะเปิดขึ้น', 'help.guide.booking-cost.step.3': 'มันมีไว้เพื่ออะไร? ใส่ชื่อของการจองไว้แล้ว กรอก จำนวนเงินทั้งหมด และตรวจสอบ สกุลเงิน และ วัน', 'help.guide.booking-cost.step.4': 'หมวดหมู่ เป็นหมวดที่ตรงกับประเภทการจอง ตั้ง ใครเป็นคนจ่าย? และวิธีแบ่งจำนวนเงิน', 'help.guide.booking-cost.step.5': 'คลิก เพิ่มค่าใช้จ่าย', 'help.guide.booking-cost.result': - 'แบบฟอร์มของการจองตอนนี้แสดง ค่าใช้จ่ายที่เชื่อมโยง พร้อมจำนวนเงิน และค่าใช้จ่ายเดียวกันอยู่ในแท็บ ค่าใช้จ่าย โดยผูกกับการจองนี้', + 'แบบฟอร์มของการจองตอนนี้แสดงค่าใช้จ่ายนั้นใต้ รายการค่าใช้จ่ายที่เชื่อมโยง พร้อมจำนวนเงิน และค่าใช้จ่ายเดียวกันอยู่ในแท็บ ค่าใช้จ่าย โดยผูกกับการจองนี้', 'help.guide.booking-cost.tip.1': 'หมวดหมู่เป็นไปตามประเภท: ร้านอาหาร เป็น อาหารและเครื่องดื่ม, ที่พัก เป็น ที่พัก, ที่จอดรถ เป็น ที่จอดรถ ส่วน เหตุการณ์ และ ทัวร์ อยู่ใน อื่นๆ', - 'help.guide.booking-cost.tip.2': 'ถังขยะบน ค่าใช้จ่ายที่เชื่อมโยง จะลบค่าใช้จ่าย ไม่ใช่การจอง', + 'help.guide.booking-cost.tip.2': + 'การจองหนึ่งรายการมีค่าใช้จ่ายได้หลายรายการ เชื่อมโยงค่าใช้จ่ายที่มีอยู่ จะเสนอค่าใช้จ่ายในแท็บ ค่าใช้จ่าย ที่ยังไม่ได้ผูกกับสิ่งใด สำหรับรายการที่เชื่อมโยงแล้ว ยกเลิกการเชื่อมโยง แต่เก็บค่าใช้จ่ายไว้ จะปลดรายการออกและเก็บไว้ในแท็บ ค่าใช้จ่าย ส่วนถังขยะจะลบรายการนั้น', 'help.guide.booking-cost.tip.3': 'ค่าใช้จ่าย จะอยู่ในแบบฟอร์มเฉพาะเมื่อเปิดส่วนเสริม ค่าใช้จ่าย ซึ่งผู้ดูแลระบบเปิดได้ใน ส่วนเสริม', // filter-bookings 'help.guide.filter-bookings.title': 'ค้นหาการจอง', @@ -2853,15 +2856,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.goal': 'ผูกค่าใช้จ่ายจริงของเที่ยวบิน โรงแรม หรือสถานที่ เข้ากับรายการที่เกี่ยวข้อง', 'help.guide.expense-from-booking.step.1': 'เปิดการจองในแท็บ การขนส่ง หรือ การจอง แล้วคลิกรูปดินสอ', 'help.guide.expense-from-booking.step.2': - 'เลื่อนไปที่ส่วน ค่าใช้จ่าย ด้านล่างของแบบฟอร์ม หากยังไม่มีอะไรเชื่อมโยง จะมี สร้างรายจ่าย พร้อมแจ้งว่าจะบันทึกการจองก่อน', + 'เลื่อนไปที่ส่วน ค่าใช้จ่าย ด้านล่างของแบบฟอร์ม ซึ่งมี สร้างรายจ่าย ที่จะบันทึกการจองก่อน และ เชื่อมโยงค่าใช้จ่ายที่มีอยู่ สำหรับค่าใช้จ่ายที่อยู่ในแท็บ ค่าใช้จ่าย แล้ว', 'help.guide.expense-from-booking.step.3': 'คลิก สร้างรายจ่าย การจองจะถูกบันทึก แบบฟอร์มจะปิด และตัวแก้ไขค่าใช้จ่ายจะเปิดขึ้นโดยใช้ชื่อการจองเป็นชื่อ และจับคู่ประเภทกับหมวดหมู่ไว้แล้ว', 'help.guide.expense-from-booking.step.4': - 'กรอกจำนวนเงินและสกุลเงิน ผู้จ่าย และการแบ่ง เหมือนค่าใช้จ่ายทั่วไป แล้วบันทึก เมื่อเปิดการจองอีกครั้งจะเห็นค่าใช้จ่ายนั้นใต้ ค่าใช้จ่ายที่เชื่อมโยง พร้อมรูปดินสอสำหรับแก้ไขและถังขยะสำหรับลบ', + 'กรอกจำนวนเงินและสกุลเงิน ผู้จ่าย และการแบ่ง เหมือนค่าใช้จ่ายทั่วไป แล้วบันทึก เมื่อเปิดการจองอีกครั้งจะเห็นค่าใช้จ่ายนั้นใต้ รายการค่าใช้จ่ายที่เชื่อมโยง พร้อมรูปดินสอสำหรับแก้ไข ยกเลิกการเชื่อมโยง แต่เก็บค่าใช้จ่ายไว้ สำหรับปลดออก และถังขยะสำหรับลบ', 'help.guide.expense-from-booking.result': 'การจองมีค่าใช้จ่ายของตัวเอง และค่าใช้จ่ายนั้นเป็นแถวปกติในแท็บ ค่าใช้จ่าย มีผู้จ่าย การแบ่ง และสกุลเงินเหมือนรายการอื่นๆ', 'help.guide.expense-from-booking.tip.1': - 'การลบการจองจะลบค่าใช้จ่ายที่เชื่อมโยงไปด้วย ส่วน ลบค่าใช้จ่าย ในส่วน ค่าใช้จ่าย ของการจองจะทำตรงกันข้าม: ค่าใช้จ่ายหายไป แต่การจองยังอยู่', + 'การลบการจองจะลบค่าใช้จ่ายที่เชื่อมโยงไปด้วย ส่วน ลบค่าใช้จ่าย ในส่วน ค่าใช้จ่าย ของการจองจะทำตรงกันข้าม: ค่าใช้จ่ายหายไป แต่การจองยังอยู่ ส่วน ยกเลิกการเชื่อมโยง แต่เก็บค่าใช้จ่ายไว้ จะเก็บไว้ทั้งสองอย่าง', 'help.guide.expense-from-booking.tip.2': 'สถานที่ก็มีส่วนเดียวกันในแบบฟอร์ม โดย สร้างรายจ่าย จะบันทึกสถานที่ก่อน', // ── Screen: trip-files ──────────────────────────────────────────────────────────────── diff --git a/shared/src/i18n/th/reservations.ts b/shared/src/i18n/th/reservations.ts index d6628402d..5febfde82 100644 --- a/shared/src/i18n/th/reservations.ts +++ b/shared/src/i18n/th/reservations.ts @@ -173,6 +173,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': "บันทึกการจอง จากนั้นเปิดตัวแก้ไขต้นทุน", 'reservations.linkedExpense': "ค่าใช้จ่ายที่เชื่อมโยง", 'reservations.removeExpense': "ลบค่าใช้จ่าย", + 'reservations.linkedExpenses': 'รายการค่าใช้จ่ายที่เชื่อมโยง', + 'reservations.linkExpense': 'เชื่อมโยงค่าใช้จ่ายที่มีอยู่', + 'reservations.unlinkExpense': 'ยกเลิกการเชื่อมโยง แต่เก็บค่าใช้จ่ายไว้', + 'reservations.noUnlinkedExpenses': 'ไม่มีค่าใช้จ่ายที่ยังไม่ได้เชื่อมโยง', 'reservations.travelers.label': "นักเดินทาง", 'reservations.travelers.assign': "มอบหมายให้นักเดินทาง", 'reservations.travelers.none': "ยังไม่มีสมาชิกทริป", diff --git a/shared/src/i18n/tr/help.ts b/shared/src/i18n/tr/help.ts index 9154970a5..fcc83f869 100644 --- a/shared/src/i18n/tr/help.ts +++ b/shared/src/i18n/tr/help.ts @@ -1845,7 +1845,7 @@ const help: TranslationStrings = { 'Ekle düğmesine tıklayın. Aynı adlı bir yer gezide zaten varsa form bunu söyler ve düğme Yine de ekle olur.', 'help.guide.create-place.result': 'Yer listede ve haritadadır, bir güne konana kadar Planlanmamış altında.', 'help.guide.create-place.tip.1': - 'Formun altındaki Dosyalar ve Costs, yere bir belge ekler ya da kaydettikten hemen sonra gideri için Costs düzenleyicisini açar.', + 'Formun altındaki Dosyalar ve Maliyetler, yere bir belge ekler ya da kaydettikten hemen sonra gideri için Maliyetler düzenleyicisini açar.', 'help.guide.create-place.tip.2': 'Her TREK’te aramayı TREK dizini ve OpenStreetMap yanıtlar, Yer ayrıntıları ise kendini Wikipedia, Wikivoyage ve Wikimedia üzerinden doldurur. Google yalnızca ikisi de boş döndüğünde sorulur ve puanı yalnızca o getirir.', 'help.guide.create-place.tip.3': @@ -3164,7 +3164,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Sekmenin sağ üstündeki Manuel Rezervasyon düğmesine tıklayın. Yeni Rezervasyon açılır.', 'help.guide.create-booking.step.2': - 'Rezervasyon Türü seçin. Konaklama, Restoran, Etkinlik, Tur, Otopark ve Diğer bu sekmenin yaptığı altı türdür ve form seçimle birlikte değişir: yalnızca Konaklama tarihlerini bir gün aralığıyla değiştirir.', + 'Formun üstündeki listeden, Yolcular alanının yanında Rezervasyon Türü seçin. Konaklama, Restoran, Etkinlik, Tur, Otopark ve Diğer bu sekmenin yaptığı altı türdür ve form seçimle birlikte değişir: yalnızca Konaklama tarihlerini bir gün aralığıyla değiştirir.', 'help.guide.create-booking.step.3': 'Başlık yazın. Formun ısrar ettiği tek alan budur ve içinde bir şey olana kadar Ekle ölü kalır.', 'help.guide.create-booking.step.4': @@ -3221,9 +3221,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Bir rezervasyonun kapsadığı yolcuları işaretleyin ve sonra yalnızca onlarınkini görün.', 'help.guide.booking-travelers.step.1': - 'Rezervasyonu kalemle açın ve Yolcular alanına kadar inin. Gezinin her üyesi orada durur, adı verilmiş misafirler de aralarında.', + 'Rezervasyonu kalemle açın. Yolcular formun üstünde, Rezervasyon Türü alanının yanında durur ve rezervasyonda kimse yokken Yolcu ata yazar.', 'help.guide.booking-travelers.step.2': - 'Bu rezervasyonun kimler için olduğuna tıklayın. Seçilen bir onay işareti alır; kaldırmak için yeniden tıklayın.', + 'Ona tıklayın ve bu rezervasyonun kimler için olduğunu seçin; adı verilmiş misafirler de listededir. Seçilen bir onay işareti alır ve avatarı alanda görünür. Kaldırmak için ada yeniden tıklayın.', 'help.guide.booking-travelers.step.3': 'Güncelle düğmesine tıklayın.', 'help.guide.booking-travelers.step.4': 'Yukarıda araç çubuğunda, tür rozetlerinin yanında, yalnızca o kişinin rezervasyonlarını görmek için bir yolcunun avatarına tıklayın.', @@ -3254,21 +3254,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Bir rezervasyonun tuttuğu parayı, onu ödeyen kişiler arasında bölünmüş olarak Maliyetler bölümüne taşıyın.', 'help.guide.booking-cost.step.1': - 'Rezervasyonu açın ve formun en altına inin. Costs altında Create expense durur, rezervasyonu kaydedip sonra maliyet düzenleyicisini açtığını söyleyen notla birlikte.', + 'Rezervasyonu açın ve formun en altına inin. Maliyetler altında Harcama oluştur ve Mevcut harcamayı bağla durur, Rezervasyonu kaydeder, sonra masraf düzenleyicisini açar notuyla birlikte.', 'help.guide.booking-cost.step.2': - 'Create expense düğmesine tıklayın. Rezervasyon kaydedilir, formu kapanır ve maliyet düzenleyicisi açılır.', + 'Harcama oluştur düğmesine tıklayın. Rezervasyon kaydedilir, formu kapanır ve maliyet düzenleyicisi açılır.', 'help.guide.booking-cost.step.3': 'Ne içindi? alanı zaten rezervasyonun başlığıdır. Toplam tutar girin ve Para birimi ile Gün alanlarını denetleyin.', 'help.guide.booking-cost.step.4': 'Kategori, rezervasyon türünün işaret ettiği kategoridir. Kim ödedi? alanını ve tutarın nasıl bölündüğünü ayarlayın.', 'help.guide.booking-cost.step.5': 'Harcama ekle düğmesine tıklayın.', 'help.guide.booking-cost.result': - 'Rezervasyonun formunda artık tutarıyla birlikte Linked expense yazar ve aynı harcama bu rezervasyona bağlı olarak Maliyetler sekmesinde durur.', + 'Rezervasyonun formu artık harcamayı tutarıyla birlikte Bağlı harcamalar altında listeler ve aynı harcama bu rezervasyona bağlı olarak Maliyetler sekmesinde durur.', 'help.guide.booking-cost.tip.1': 'Kategori türü izler: Restoran Yiyecek & içecek olur, Konaklama Konaklama olur, Otopark Otopark olur, Etkinlik ile Tur ise ikisi de Diğer içine düşer.', - 'help.guide.booking-cost.tip.2': 'Linked expense üzerindeki çöp kutusu harcamayı kaldırır, rezervasyonu değil.', + 'help.guide.booking-cost.tip.2': + 'Bir rezervasyon birden çok harcama taşıyabilir. Mevcut harcamayı bağla, Maliyetler içinde henüz hiçbir yere ait olmayanları sunar. Bağlı bir harcamada Bağlantıyı kaldır, harcamayı tut onu serbest bırakır ve Maliyetler içinde tutar, çöp kutusu ise onu kaldırır.', 'help.guide.booking-cost.tip.3': - 'Costs formda yalnızca Maliyetler eklentisi açıkken bulunur, onu da yönetici Eklentiler altından açıp kapatır.', + 'Maliyetler formda yalnızca Maliyetler eklentisi açıkken bulunur, onu da yönetici Eklentiler altından açıp kapatır.', // filter-bookings 'help.guide.filter-bookings.title': 'Bir rezervasyon bulun', 'help.guide.filter-bookings.goal': 'Uzun bir sekmeyi aradığınız türe, kişiye ya da duruma daraltın.', @@ -3339,7 +3340,7 @@ const help: TranslationStrings = { 'Eklenmiş olan belgeler gezinin Dosyalar sekmesinde kalır; yalnızca rezervasyona olan bağları gider.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Bulunan her rezervasyon Yeni Rezervasyon içinde, art arda ve çoktan doldurulmuş olarak açılır. Bir otel için bu, Başlık içindeki ad ve gezide yer varsa Konaklama altında yer, Konum / Adres bilgisi, geceleri üzerinde İtibaren ve İle, Giriş ve Çıkış yapmak, Rezervasyon Kodu, Dosyalar altında onay ve Maliyetler açıkken Linked expense olarak fiyattır. Denetleyin ve Ekle düğmesine tıklayın.', + 'Bulunan her rezervasyon Yeni Rezervasyon içinde, art arda ve çoktan doldurulmuş olarak açılır. Bir otel için bu, Başlık içindeki ad ve gezide yer varsa Konaklama altında yer, Konum / Adres bilgisi, geceleri üzerinde İtibaren ve İle, Giriş ve Çıkış yapmak, Rezervasyon Kodu, Dosyalar altında onay ve Maliyetler açıkken Bağlı harcama olarak fiyattır. Denetleyin ve Ekle düğmesine tıklayın.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Maliyetler', @@ -3511,17 +3512,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Rezervasyonu Ulaşım ya da Rezervasyonlar sekmesinde açın ve kalemine tıklayın.', 'help.guide.expense-from-booking.step.2': - 'Formun altındaki Costs bloğuna inin. Henüz hiçbir şey bağlı değilken Create expense sunar ve önce rezervasyonu kaydettiğini söyler.', + 'Formun altındaki Maliyetler bloğuna inin. Önce rezervasyonu kaydeden Harcama oluştur ile zaten Maliyetler içinde olan bir harcama için Mevcut harcamayı bağla sunar.', 'help.guide.expense-from-booking.step.3': - 'Create expense düğmesine tıklayın. Rezervasyon kaydedilir, form kapanır ve Maliyetler düzenleyicisi rezervasyonun başlığını ad olarak, türünü de bir kategoriyle eşleştirilmiş halde açar.', + 'Harcama oluştur düğmesine tıklayın. Rezervasyon kaydedilir, form kapanır ve Maliyetler düzenleyicisi rezervasyonun başlığını ad olarak, türünü de bir kategoriyle eşleştirilmiş halde açar.', 'help.guide.expense-from-booking.step.4': - 'Tutarı ve para birimini, kimin ödediğini ve bölüşümü her harcamada olduğu gibi doldurup kaydedin. Rezervasyonu yeniden açtığınızda harcama Linked expense altında görünür, düzenlemek için bir kalem ve kaldırmak için bir çöp kutusuyla.', + 'Tutarı ve para birimini, kimin ödediğini ve bölüşümü her harcamada olduğu gibi doldurup kaydedin. Rezervasyonu yeniden açtığınızda harcama Bağlı harcamalar altında görünür; düzenlemek için bir kalem, serbest bırakmak için Bağlantıyı kaldır, harcamayı tut ve kaldırmak için bir çöp kutusuyla.', 'help.guide.expense-from-booking.result': 'Rezervasyon maliyetini taşır ve harcama, Maliyetler sekmesinde ödeyeni, bölüşümü ve para birimiyle diğerleri gibi sıradan bir satırdır.', 'help.guide.expense-from-booking.tip.1': - 'Rezervasyonu silmek bağlı harcamasını da onunla birlikte siler. Rezervasyonun Costs bloğundaki Remove expense bunun tersini yapar: harcama gider, rezervasyon kalır.', + 'Rezervasyonu silmek bağlı harcamalarını da onunla birlikte siler. Rezervasyonun Maliyetler bloğundaki Harcamayı kaldır bunun tersini yapar: harcama gider, rezervasyon kalır. Bağlantıyı kaldır, harcamayı tut ikisini de tutar.', 'help.guide.expense-from-booking.tip.2': - 'Bir yerin formunda da aynı blok vardır, Create expense orada önce yeri kaydeder.', + 'Bir yerin formunda da aynı blok vardır, Harcama oluştur orada önce yeri kaydeder.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Ulaşım', @@ -3577,7 +3578,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'Satır, başlangıç saatinin koyduğu yere, daha erken başlayan son duraktan sonra iner. Tutamağı onu gün içinde başka herhangi bir yere ya da başka bir güne sürükler.', 'help.guide.add-transport.tip.2': - 'Dosyalar altındaki Dosya ekle bileti alır, Costs altındaki Create expense ise rezervasyonu kaydeder ve bilet ücreti için Maliyetler düzenleyicisini açar.', + 'Dosyalar altındaki Dosya ekle bileti alır, Maliyetler altındaki Harcama oluştur ise rezervasyonu kaydeder ve bilet ücreti için Maliyetler düzenleyicisini açar.', 'help.guide.add-transport.tip.3': 'Yolcular bu yolculukta kimin olduğunu işaretler. Bir ulaşımın yolcuları olur olmaz sekmenin araç çubuğunda avatarları belirir ve listeyi onlara göre süzer.', // plan-transit diff --git a/shared/src/i18n/tr/reservations.ts b/shared/src/i18n/tr/reservations.ts index c1423e7d6..2a75016d5 100644 --- a/shared/src/i18n/tr/reservations.ts +++ b/shared/src/i18n/tr/reservations.ts @@ -166,11 +166,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'AirTrail hesabınızda uçuş bulunamadı.', 'reservations.airtrail.importCta': '{count} içe aktar', 'reservations.airtrail.joinConnection': '{stops} aktarmalı tek uçuş olarak içe aktar', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Maliyetler', + 'reservations.createExpense': 'Harcama oluştur', + 'reservations.createExpenseHint': 'Rezervasyonu kaydeder, sonra masraf düzenleyicisini açar.', + 'reservations.linkedExpense': 'Bağlı harcama', + 'reservations.removeExpense': 'Harcamayı kaldır', + 'reservations.linkedExpenses': 'Bağlı harcamalar', + 'reservations.linkExpense': 'Mevcut harcamayı bağla', + 'reservations.unlinkExpense': 'Bağlantıyı kaldır, harcamayı tut', + 'reservations.noUnlinkedExpenses': 'Bağlanmamış harcama yok', 'reservations.urlLabel': 'Bağlantı', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Yolcular', diff --git a/shared/src/i18n/uk/help.ts b/shared/src/i18n/uk/help.ts index d5a7488eb..8ee07a2cb 100644 --- a/shared/src/i18n/uk/help.ts +++ b/shared/src/i18n/uk/help.ts @@ -1824,7 +1824,7 @@ const help: TranslationStrings = { 'Натисніть Додати. Якщо місце з такою ж назвою вже є в поїздці, форма про це каже, а кнопка перетворюється на Все одно додати.', 'help.guide.create-place.result': 'Місце в списку й на карті, під Незаплановані, доки його не покладуть на день.', 'help.guide.create-place.tip.1': - 'Файли й Costs унизу форми прикріплюють до місця документ або відкривають редактор Costs для його витрати одразу після збереження.', + 'Файли й Витрати унизу форми прикріплюють до місця документ або відкривають редактор Витрати для його витрати одразу після збереження.', 'help.guide.create-place.tip.2': 'Пошук на будь-якому TREK обслуговують індекс TREK і OpenStreetMap, а Відомості про місце заповнюються з джерел Wikipedia, Wikivoyage і Wikimedia. До Google звертаються лише там, де обидва лишилися порожніми, і лише він приносить рейтинг.', 'help.guide.create-place.tip.3': @@ -3137,7 +3137,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.step.1': 'Натисніть Ручне бронювання праворуч угорі вкладки. Відкривається Нове бронювання.', 'help.guide.create-booking.step.2': - 'Виберіть Тип бронювання. Житло, Ресторан, Заходи, Екскурсія, Паркування та Інше це ті шість, які робить ця вкладка, і форма змінюється разом із вибором: лише Житло міняє свої дати на діапазон днів.', + 'Виберіть Тип бронювання у списку вгорі форми, поруч із полем Мандрівники. Житло, Ресторан, Заходи, Екскурсія, Паркування та Інше це ті шість, які робить ця вкладка, і форма змінюється разом із вибором: лише Житло міняє свої дати на діапазон днів.', 'help.guide.create-booking.step.3': 'Введіть Назву. Це єдине поле, на якому форма наполягає, і Додати залишається мертвою, доки в ньому нічого немає.', 'help.guide.create-booking.step.4': @@ -3194,9 +3194,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.goal': 'Позначте мандрівників, яких бронювання охоплює, а потім дивіться лише їхні бронювання.', 'help.guide.booking-travelers.step.1': - 'Відкрийте бронювання олівцем і спустіться вниз до поля Мандрівники. Там стоїть кожен учасник подорожі, названі гості серед них.', + 'Відкрийте бронювання олівцем. Поле Мандрівники стоїть угорі форми, поруч із полем Тип бронювання, і показує Призначити мандрівників, поки в бронюванні нікого немає.', 'help.guide.booking-travelers.step.2': - 'Натисніть на людей, для яких це бронювання. Обраний отримує галочку; натисніть ще раз, щоб її зняти.', + 'Натисніть на нього й виберіть людей, для яких це бронювання; названі гості теж є у списку. Обраний отримує галочку і свій аватар у полі. Натисніть на ім’я ще раз, щоб його зняти.', 'help.guide.booking-travelers.step.3': 'Натисніть Оновити.', 'help.guide.booking-travelers.step.4': 'Угорі, на панелі поруч із фішками типів, натисніть на аватар мандрівника, щоб побачити лише його бронювання.', @@ -3230,21 +3230,22 @@ const help: TranslationStrings = { 'help.guide.booking-cost.goal': 'Перенесіть те, у скільки обходиться бронювання, у Витрати, поділене між людьми, які за нього платять.', 'help.guide.booking-cost.step.1': - 'Відкрийте бронювання й спустіться до кінця форми. Під Costs стоїть Create expense, з приміткою, що він зберігає бронювання, а потім відкриває редактор витрат.', + 'Відкрийте бронювання й спустіться до кінця форми. Під Витрати стоять Створити витрату і Прив’язати існуючу витрату, з приміткою Зберігає бронювання й відкриває редактор витрат.', 'help.guide.booking-cost.step.2': - 'Натисніть Create expense. Бронювання зберігається, його форма закривається, і відкривається редактор витрат.', + 'Натисніть Створити витрату. Бронювання зберігається, його форма закривається, і відкривається редактор витрат.', 'help.guide.booking-cost.step.3': 'За що це було? це вже назва бронювання. Вставте Загальну суму й перевірте Валюту та День.', 'help.guide.booking-cost.step.4': 'Категорія це та, на яку вказує тип бронювання. Задайте Хто заплатив? і те, як сума ділиться.', 'help.guide.booking-cost.step.5': 'Натисніть Додати витрату.', 'help.guide.booking-cost.result': - 'У формі бронювання тепер стоїть Linked expense із сумою, і та сама витрата стоїть на вкладці Витрати, пов’язана з цим бронюванням.', + 'Форма бронювання тепер показує витрату під Прив’язані витрати з її сумою, і та сама витрата стоїть на вкладці Витрати, пов’язана з цим бронюванням.', 'help.guide.booking-cost.tip.1': 'Категорія йде за типом: з Ресторану виходить Їжа та напої, з Житла Проживання, з Паркування Паркування, а Заходи й Екскурсія обидва потрапляють в Інше.', - 'help.guide.booking-cost.tip.2': 'Кошик біля Linked expense прибирає витрату, а не бронювання.', + 'help.guide.booking-cost.tip.2': + 'Бронювання може нести кілька витрат. Прив’язати існуючу витрату пропонує ті витрати з вкладки Витрати, що ще ні до чого не прив’язані. На прив’язаній Відв’язати, зберегти витрату відпускає її і лишає у Витратах, а кошик її прибирає.', 'help.guide.booking-cost.tip.3': - 'Costs є у формі лише тоді, коли ввімкнено доповнення Витрати, яке адміністратор перемикає під Доповнення.', + 'Блок Витрати є у формі лише тоді, коли ввімкнено доповнення Витрати, яке адміністратор перемикає під Доповнення.', // filter-bookings 'help.guide.filter-bookings.title': 'Знайти бронювання', 'help.guide.filter-bookings.goal': 'Звузьте довгу вкладку до типу, людини або стану, які вам потрібні.', @@ -3313,7 +3314,7 @@ const help: TranslationStrings = { 'Документи, які були прикріплені, лишаються на вкладці Файли цієї подорожі; іде лише їхній зв’язок із бронюванням.', // import-booking-file 'help.guide.import-booking-file.step.5': - 'Кожне знайдене бронювання відкривається в Нове бронювання, одне за одним, уже заповнене. Для готелю це назва в Назва і, коли в поїздки є це місце, під Житло, його Місцезнаходження / Адреса, З і По на його ночі, Заїзд і Виїзд, Код бронювання, підтвердження під Файли і, з увімкненими Витратами, ціна як Linked expense. Перевірте й натисніть Додати.', + 'Кожне знайдене бронювання відкривається в Нове бронювання, одне за одним, уже заповнене. Для готелю це назва в Назва і, коли в поїздки є це місце, під Житло, його Місцезнаходження / Адреса, З і По на його ночі, Заїзд і Виїзд, Код бронювання, підтвердження під Файли і, з увімкненими Витратами, ціна як Прив’язана витрата. Перевірте й натисніть Додати.', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': 'Витрати', @@ -3482,17 +3483,17 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.step.1': 'Відкрийте бронювання на вкладці Транспорт або Бронювання й натисніть його олівець.', 'help.guide.expense-from-booking.step.2': - 'Прокрутіть до блоку Costs унизу форми. Поки нічого не прив’язано, він пропонує Create expense і каже, що спершу зберігає бронювання.', + 'Прокрутіть до блоку Витрати унизу форми. Він пропонує Створити витрату, що спершу зберігає бронювання, і Прив’язати існуючу витрату для тієї, що вже є у Витратах.', 'help.guide.expense-from-booking.step.3': - 'Натисніть Create expense. Бронювання зберігається, форма закривається, і відкривається редактор Витрати з назвою бронювання як назвою і його типом, уже зіставленим із категорією.', + 'Натисніть Створити витрату. Бронювання зберігається, форма закривається, і відкривається редактор Витрати з назвою бронювання як назвою і його типом, уже зіставленим із категорією.', 'help.guide.expense-from-booking.step.4': - 'Заповніть суму та її валюту, хто заплатив, і розподіл як для будь-якої витрати, і збережіть. Якщо відкрити бронювання знову, воно покаже витрату під Linked expense, з олівцем, щоб її змінити, і кошиком, щоб її прибрати.', + 'Заповніть суму та її валюту, хто заплатив, і розподіл як для будь-якої витрати, і збережіть. Якщо відкрити бронювання знову, воно покаже витрату під Прив’язані витрати, з олівцем, щоб її змінити, Відв’язати, зберегти витрату, щоб її відпустити, і кошиком, щоб її прибрати.', 'help.guide.expense-from-booking.result': 'Бронювання несе свою вартість, а витрата це звичайний рядок на вкладці Витрати, з платником, розподілом і валютою, як у будь-якої іншої.', 'help.guide.expense-from-booking.tip.1': - 'Видалення бронювання видаляє разом із ним і прив’язану витрату. Remove expense у блоці Costs бронювання робить навпаки: витрата зникає, бронювання лишається.', + 'Видалення бронювання видаляє разом із ним і прив’язані витрати. Прибрати витрату у блоці Витрати бронювання робить навпаки: витрата зникає, бронювання лишається. Відв’язати, зберегти витрату лишає і те, й інше.', 'help.guide.expense-from-booking.tip.2': - 'У місця такий самий блок у його формі, де Create expense спершу зберігає місце.', + 'У місця такий самий блок у його формі, де Створити витрату спершу зберігає місце.', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': 'Транспорт', @@ -3549,7 +3550,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': 'Рядок приземляється там, куди його кладе його час початку, після останньої зупинки, яка починається раніше. Його ручка перетягує його будь-куди інде в дні або на інший день.', 'help.guide.add-transport.tip.2': - 'Прикріпити файл у розділі Файли приймає квиток, а Create expense у розділі Costs зберігає бронювання й відкриває редактор Витрати для вартості проїзду.', + 'Прикріпити файл у розділі Файли приймає квиток, а Створити витрату у розділі Витрати зберігає бронювання й відкриває редактор Витрати для вартості проїзду.', 'help.guide.add-transport.tip.3': 'Мандрівники позначає, хто їде. Щойно один транспорт має мандрівників, на панелі вкладки виростають їхні аватари й фільтрують список за ними.', // plan-transit diff --git a/shared/src/i18n/uk/reservations.ts b/shared/src/i18n/uk/reservations.ts index 489e3fa86..9c85e856f 100644 --- a/shared/src/i18n/uk/reservations.ts +++ b/shared/src/i18n/uk/reservations.ts @@ -164,11 +164,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': 'У вашому акаунті AirTrail не знайдено рейсів.', 'reservations.airtrail.importCta': 'Імпортувати {count}', 'reservations.airtrail.joinConnection': 'Імпортувати як один рейс із пересадкою в {stops}', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Витрати', + 'reservations.createExpense': 'Створити витрату', + 'reservations.createExpenseHint': 'Зберігає бронювання й відкриває редактор витрат.', + 'reservations.linkedExpense': 'Прив’язана витрата', + 'reservations.removeExpense': 'Прибрати витрату', + 'reservations.linkedExpenses': 'Прив’язані витрати', + 'reservations.linkExpense': 'Прив’язати існуючу витрату', + 'reservations.unlinkExpense': 'Відв’язати, зберегти витрату', + 'reservations.noUnlinkedExpenses': 'Немає неприв’язаних витрат', 'reservations.urlLabel': 'Посилання', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Мандрівники', diff --git a/shared/src/i18n/vi/help.ts b/shared/src/i18n/vi/help.ts index 1cf73ba12..c1a6a8837 100644 --- a/shared/src/i18n/vi/help.ts +++ b/shared/src/i18n/vi/help.ts @@ -3147,7 +3147,7 @@ const help: TranslationStrings = { 'Đưa một nhà hàng, một sự kiện, một chuyến du lịch, một chỗ đỗ xe hay bất cứ thứ gì khác vào chuyến đi bằng tay.', 'help.guide.create-booking.step.1': 'Nhấp “Thêm đặt chỗ” ở trên cùng bên phải của thẻ. “Đặt chỗ mới” mở ra.', 'help.guide.create-booking.step.2': - 'Chọn “Loại đặt chỗ”. “Chỗ ở”, “Nhà hàng”, “Sự kiện”, “Chuyến du lịch”, “Bãi đỗ xe” và “Khác” là sáu loại thẻ này tạo ra, và biểu mẫu thay đổi theo lựa chọn: chỉ “Chỗ ở” mới đổi ngày của nó lấy một khoảng các ngày.', + 'Chọn “Loại đặt chỗ” từ danh sách ở đầu biểu mẫu, cạnh “Người đi”. “Chỗ ở”, “Nhà hàng”, “Sự kiện”, “Chuyến du lịch”, “Bãi đỗ xe” và “Khác” là sáu loại thẻ này tạo ra, và biểu mẫu thay đổi theo lựa chọn: chỉ “Chỗ ở” mới đổi ngày của nó lấy một khoảng các ngày.', 'help.guide.create-booking.step.3': 'Gõ “Tiêu đề”. Đó là ô duy nhất biểu mẫu nhất định đòi, và “Thêm” vẫn nằm im cho tới khi nó có nội dung.', 'help.guide.create-booking.step.4': @@ -3203,9 +3203,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': 'Nói một đặt chỗ là dành cho ai', 'help.guide.booking-travelers.goal': 'Đánh dấu những người đi mà một đặt chỗ bao gồm, rồi chỉ xem phần của họ.', 'help.guide.booking-travelers.step.1': - 'Mở đặt chỗ bằng cây bút chì và đi xuống “Người đi”. Mọi thành viên của chuyến đi đều đứng đó, kể cả những “Khách” đã có tên.', + 'Mở đặt chỗ bằng cây bút chì. “Người đi” nằm ở đầu biểu mẫu, cạnh “Loại đặt chỗ”, và hiện “Chỉ định người đi” khi chưa có ai trong đặt chỗ.', 'help.guide.booking-travelers.step.2': - 'Nhấp vào những người mà đặt chỗ này dành cho. Người được chọn sẽ có một dấu tích; nhấp lại để bỏ ra.', + 'Nhấp vào đó và chọn những người mà đặt chỗ này dành cho; những “Khách” đã có tên cũng có trong danh sách. Người được chọn sẽ có một dấu tích và ảnh đại diện của họ trong ô. Nhấp lại vào tên để bỏ ra.', 'help.guide.booking-travelers.step.3': 'Nhấp “Cập nhật”.', 'help.guide.booking-travelers.step.4': 'Ở thanh công cụ phía trên, cạnh các chip loại, nhấp ảnh đại diện của một người đi để chỉ xem các đặt chỗ của họ.', @@ -3236,7 +3236,7 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': 'Biến giá của một đặt chỗ thành chi phí', 'help.guide.booking-cost.goal': 'Đưa số tiền một đặt chỗ tốn vào “Chi phí”, chia cho những người trả tiền cho nó.', 'help.guide.booking-cost.step.1': - 'Mở đặt chỗ và đi xuống chân biểu mẫu. Dưới “Chi phí” là “Tạo chi phí”, kèm ghi chú “Lưu đặt chỗ, sau đó mở trình chỉnh sửa Chi phí.”', + 'Mở đặt chỗ và đi xuống chân biểu mẫu. Dưới “Chi phí” là “Tạo chi phí” và “Liên kết chi phí hiện có”, kèm ghi chú “Lưu đặt chỗ, sau đó mở trình chỉnh sửa Chi phí.”', 'help.guide.booking-cost.step.2': 'Nhấp “Tạo chi phí”. Đặt chỗ được lưu, biểu mẫu của nó đóng lại và trình chỉnh sửa chi phí mở ra.', 'help.guide.booking-cost.step.3': @@ -3245,10 +3245,11 @@ const help: TranslationStrings = { '“Loại” là loại mà kiểu đặt chỗ gợi ra. Đặt “Ai đã trả tiền?” và cách số tiền được “Chia tiền”.', 'help.guide.booking-cost.step.5': 'Nhấp “Thêm chi phí”.', 'help.guide.booking-cost.result': - 'Biểu mẫu của đặt chỗ giờ ghi “Chi phí liên kết” kèm số tiền, và chính khoản chi ấy đứng trong thẻ “Chi phí”, buộc vào đặt chỗ này.', + 'Biểu mẫu của đặt chỗ giờ liệt kê khoản chi dưới “Các chi phí liên kết” kèm số tiền của nó, và chính khoản chi ấy đứng trong thẻ “Chi phí”, buộc vào đặt chỗ này.', 'help.guide.booking-cost.tip.1': 'Loại đi theo kiểu: “Nhà hàng” thành “Thức ăn và đồ uống”, “Chỗ ở” thành “Chỗ ở”, “Bãi đỗ xe” thành “Bãi đỗ xe”, còn “Sự kiện” và “Chuyến du lịch” đều rơi vào “Khác”.', - 'help.guide.booking-cost.tip.2': 'Thùng rác trên “Chi phí liên kết” gỡ khoản chi, không gỡ đặt chỗ.', + 'help.guide.booking-cost.tip.2': + 'Một đặt chỗ có thể mang nhiều khoản chi. “Liên kết chi phí hiện có” đưa ra các khoản trong “Chi phí” chưa thuộc về đâu. Trên một khoản đã liên kết, “Hủy liên kết, giữ chi phí” thả nó ra và để nó lại trong “Chi phí”, còn thùng rác thì gỡ nó.', 'help.guide.booking-cost.tip.3': '“Chi phí” chỉ có trong biểu mẫu khi tiện ích “Chi phí” đang bật, thứ mà quản trị viên bật tắt dưới “Tiện ích bổ sung”.', // filter-bookings @@ -3489,15 +3490,15 @@ const help: TranslationStrings = { 'Gắn chi phí thật của một chuyến bay, một khách sạn hay một địa điểm vào chính bản ghi của nó.', 'help.guide.expense-from-booking.step.1': 'Mở đặt chỗ ở tab Di chuyển hoặc Đặt chỗ và nhấp vào cây bút chì của nó.', 'help.guide.expense-from-booking.step.2': - 'Cuộn tới khối “Chi phí” ở cuối biểu mẫu. Khi chưa có gì được liên kết, nó đưa ra “Tạo chi phí” và nói rằng đặt chỗ sẽ được lưu trước.', + 'Cuộn tới khối “Chi phí” ở cuối biểu mẫu. Nó đưa ra “Tạo chi phí”, vốn lưu đặt chỗ trước, và “Liên kết chi phí hiện có” cho một khoản đã có trong “Chi phí”.', 'help.guide.expense-from-booking.step.3': 'Nhấp “Tạo chi phí”. Đặt chỗ được lưu, biểu mẫu đóng lại, và trình chỉnh sửa Chi phí mở ra với tiêu đề của đặt chỗ làm tên và loại của nó đã được khớp sẵn với một danh mục.', 'help.guide.expense-from-booking.step.4': - 'Điền số tiền và đơn vị tiền tệ của nó, ai đã trả và cách chia như với mọi khoản chi, rồi lưu. Mở lại đặt chỗ giờ sẽ thấy nó dưới “Chi phí liên kết”, với một cây bút chì để sửa và một thùng rác để bỏ nó ra.', + 'Điền số tiền và đơn vị tiền tệ của nó, ai đã trả và cách chia như với mọi khoản chi, rồi lưu. Mở lại đặt chỗ giờ sẽ thấy nó dưới “Các chi phí liên kết”, với một cây bút chì để sửa, “Hủy liên kết, giữ chi phí” để thả nó ra và một thùng rác để bỏ nó đi.', 'help.guide.expense-from-booking.result': 'Đặt chỗ mang theo chi phí của nó, và khoản chi là một dòng bình thường trên tab Chi phí, có người trả, cách chia và loại tiền như mọi khoản khác.', 'help.guide.expense-from-booking.tip.1': - 'Xóa đặt chỗ sẽ xóa luôn khoản chi liên kết với nó. “Xóa chi phí” trong khối “Chi phí” của đặt chỗ làm điều ngược lại: khoản chi biến mất, đặt chỗ ở lại.', + 'Xóa đặt chỗ sẽ xóa luôn các khoản chi liên kết với nó. “Xóa chi phí” trong khối “Chi phí” của đặt chỗ làm điều ngược lại: khoản chi biến mất, đặt chỗ ở lại. “Hủy liên kết, giữ chi phí” giữ lại cả hai.', 'help.guide.expense-from-booking.tip.2': 'Một địa điểm cũng có khối như vậy trong biểu mẫu của nó, với “Tạo chi phí” lưu địa điểm trước.', diff --git a/shared/src/i18n/vi/reservations.ts b/shared/src/i18n/vi/reservations.ts index 2ff0cc621..631369a7c 100644 --- a/shared/src/i18n/vi/reservations.ts +++ b/shared/src/i18n/vi/reservations.ts @@ -172,6 +172,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': 'Lưu đặt chỗ, sau đó mở trình chỉnh sửa Chi phí.', 'reservations.linkedExpense': 'Chi phí liên kết', 'reservations.removeExpense': 'Xóa chi phí', + 'reservations.linkedExpenses': 'Các chi phí liên kết', + 'reservations.linkExpense': 'Liên kết chi phí hiện có', + 'reservations.unlinkExpense': 'Hủy liên kết, giữ chi phí', + 'reservations.noUnlinkedExpenses': 'Không có chi phí chưa liên kết', 'reservations.urlLabel': 'Liên kết', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': 'Người đi', diff --git a/shared/src/i18n/zh-TW/help.ts b/shared/src/i18n/zh-TW/help.ts index 043270559..839798f77 100644 --- a/shared/src/i18n/zh-TW/help.ts +++ b/shared/src/i18n/zh-TW/help.ts @@ -1526,7 +1526,7 @@ const help: TranslationStrings = { 'help.guide.create-place.step.5': '點選「新增」。如果旅行裡已經有同名的地點,表單會說明,按鈕變成「仍要新增」。', 'help.guide.create-place.result': '地點已經在清單裡和地圖上,在被放到某一天之前位於「未規劃」下。', 'help.guide.create-place.tip.1': - '表單底部的「檔案」和「Costs」可以給地點附上一份文件,或者在儲存之後立刻開啟它那筆支出的「Costs」編輯器。', + '表單底部的「檔案」和「費用」可以給地點附上一份文件,或者在儲存之後立刻開啟它那筆支出的「費用」編輯器。', 'help.guide.create-place.tip.2': '每個 TREK 上回答搜尋的都是 TREK 索引和 OpenStreetMap,「地點詳細資料」則自己從 Wikipedia、Wikivoyage 和 Wikimedia 取內容。只有在這兩者都查不到時才會去問 Google,而且只有 Google 會帶來評分。', 'help.guide.create-place.tip.3': '地點也可以從地圖開始:在那個位置按右鍵,表單就會帶著座標和地址開啟。', @@ -2639,7 +2639,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.goal': '把一家餐廳、一場活動、一個旅遊團、一個車位或別的什麼,手動放進這次旅行。', 'help.guide.create-booking.step.1': '點選這一欄右上角的「手動新增」。「新建預訂」開啟。', 'help.guide.create-booking.step.2': - '選「預訂型別」。這一欄做的六類是「住宿」「餐廳」「活動」「旅遊團」「停車」和「其他」,表單隨選擇而變:只有「住宿」會把日期換成一段日子的範圍。', + '從表單頂部「出行成員」旁邊的清單裡選「預訂型別」。這一欄做的六類是「住宿」「餐廳」「活動」「旅遊團」「停車」和「其他」,表單隨選擇而變:只有「住宿」會把日期換成一段日子的範圍。', 'help.guide.create-booking.step.3': '填「標題」。這是表單唯一堅持要的欄位,在它有內容之前「新增」一直是灰的。', 'help.guide.create-booking.step.4': '設定「日期」和「開始時間」,如果這筆預訂有結束,再設「結束日期」和「結束時間」。日曆只給出旅行之內的日子;結束不晚於開始時,會用紅色這樣提示並擋住「新增」。', @@ -2685,8 +2685,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': '說明一筆預訂是給誰的', 'help.guide.booking-travelers.goal': '標出一筆預訂涵蓋的出行成員,然後只看他們的。', 'help.guide.booking-travelers.step.1': - '用鉛筆開啟這筆預訂,往下到「出行成員」。旅行的每位成員都在那裡,其中也包括有名字的「訪客」。', - 'help.guide.booking-travelers.step.2': '點選這筆預訂是給誰的那些人。被選中的人會得到一個勾;再點一次就取消。', + '用鉛筆開啟這筆預訂。「出行成員」在表單頂部,「預訂型別」旁邊,只要這筆預訂上還沒有人,它就顯示「指派成員」。', + 'help.guide.booking-travelers.step.2': + '點它,選出這筆預訂是給誰的那些人;有名字的「訪客」也在清單裡。被選中的人會得到一個勾,頭像也會出現在這個欄位中。再點一次名字就取消。', 'help.guide.booking-travelers.step.3': '點選「更新」。', 'help.guide.booking-travelers.step.4': '在上方工具列裡,型別篩選片旁邊,點選某位出行成員的頭像,就只看他的預訂。', 'help.guide.booking-travelers.result': '卡片列出它是給誰的那些人,而頭像那一排把這一欄收窄到其中一個人。', @@ -2711,18 +2712,19 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': '把一筆預訂的金額變成費用', 'help.guide.booking-cost.goal': '把一筆預訂花掉的錢記進「費用」,並在付錢的人之間分攤。', 'help.guide.booking-cost.step.1': - '開啟這筆預訂,走到表單底部。「Costs」下面是「Create expense」,附帶那行說明「Saves the booking, then opens the Costs editor.」', - 'help.guide.booking-cost.step.2': '點選「Create expense」。預訂被儲存,它的表單關上,費用編輯器開啟。', + '開啟這筆預訂,走到表單底部。「費用」下面是「建立支出」和「關聯已有支出」,附帶那行說明「先儲存預訂,然後開啟費用編輯器。」', + 'help.guide.booking-cost.step.2': '點選「建立支出」。預訂被儲存,它的表單關上,費用編輯器開啟。', 'help.guide.booking-cost.step.3': '「這筆是什麼支出?」已經是這筆預訂的標題。填入「總金額」,並核對「貨幣」和「日期」。', 'help.guide.booking-cost.step.4': '「分類」是預訂型別推出來的那一個。設定「誰付的款?」以及金額怎麼「Split」。', 'help.guide.booking-cost.step.5': '點選「新增支出」。', 'help.guide.booking-cost.result': - '這筆預訂的表單現在顯示「Linked expense」和金額,同一筆支出也立在「費用」欄裡,繫在這筆預訂上。', + '這筆預訂的表單現在在「已關聯支出」下列出這筆支出和它的金額,同一筆支出也立在「費用」欄裡,繫在這筆預訂上。', 'help.guide.booking-cost.tip.1': '分類跟著型別走:「餐廳」變成「餐飲」,「住宿」變成「住宿」,「停車」變成「停車」,「活動」和「旅遊團」都落進「其他」。', - 'help.guide.booking-cost.tip.2': '「Linked expense」上的垃圾桶移除的是這筆支出,不是這筆預訂。', - 'help.guide.booking-cost.tip.3': '只有在「費用」擴充套件開著時,「Costs」才在表單裡,管理員在「擴充套件」下切換它。', + 'help.guide.booking-cost.tip.2': + '一筆預訂可以帶多筆支出。「關聯已有支出」會列出「費用」裡還不屬於任何地方的支出。在已關聯的一筆上,「取消關聯,保留支出」會解開關聯並把它留在「費用」裡,而垃圾桶會把它移除。', + 'help.guide.booking-cost.tip.3': '只有在「費用」擴充套件開著時,「費用」才在表單裡,管理員在「擴充套件」下切換它。', // filter-bookings 'help.guide.filter-bookings.title': '找到一筆預訂', 'help.guide.filter-bookings.goal': '把一長欄收窄到你要的型別、人或狀態。', @@ -2779,7 +2781,7 @@ const help: TranslationStrings = { 'help.guide.delete-booking.tip.2': '附上過的單據仍留在這次旅行的「檔案」欄裡;走掉的只是它們與這筆預訂的關聯。', // import-booking-file 'help.guide.import-booking-file.step.5': - '找到的每筆預訂都會在「新建預訂」裡逐筆開啟,內容已填好。對飯店來說,那是「標題」裡的名稱,旅行裡有這個地點時還有「住宿」下的地點,它的「地點 / 地址」,按住宿的夜數填好的「從」和「到」,「入住」和「退房」,「預訂碼」,「檔案」下的確認單,以及在「費用」開著時作為「Linked expense」的價格。檢查一遍,點選「新增」。', + '找到的每筆預訂都會在「新建預訂」裡逐筆開啟,內容已填好。對飯店來說,那是「標題」裡的名稱,旅行裡有這個地點時還有「住宿」下的地點,它的「地點 / 地址」,按住宿的夜數填好的「從」和「到」,「入住」和「退房」,「預訂碼」,「檔案」下的確認單,以及在「費用」開著時作為「關聯支出」的價格。檢查一遍,點選「新增」。', // ── Screen: trip-costs ──────────────────────────────────────────────────────────────── 'help.ctx.trip-costs.title': '費用', @@ -2925,16 +2927,16 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.goal': '把一趟航班、一家飯店或一個地點真正花掉的錢,附到它所屬的那條記錄上。', 'help.guide.expense-from-booking.step.1': '在「交通」或「預訂」分頁打開該預訂,點選它的鉛筆。', 'help.guide.expense-from-booking.step.2': - '捲動到表單底部的 Costs 區塊。在還沒有任何關聯時,它會提供 Create expense,並說明會先儲存預訂。', + '捲動到表單底部的「費用」區塊。它提供會先儲存預訂的「建立支出」,以及用於已在「費用」裡的支出的「關聯已有支出」。', 'help.guide.expense-from-booking.step.3': - '點選 Create expense。預訂被儲存,表單關閉,費用編輯器隨即打開,預訂的標題作為名稱,它的類型也已經對應到了一個分類。', + '點選「建立支出」。預訂被儲存,表單關閉,費用編輯器隨即打開,預訂的標題作為名稱,它的類型也已經對應到了一個分類。', 'help.guide.expense-from-booking.step.4': - '像填任何一筆支出那樣填好金額和它的幣別、誰付的錢和分攤,然後儲存。此時再打開該預訂,就能在 Linked expense 下看到它,旁邊有鉛筆可以編輯,有垃圾桶可以移除。', + '像填任何一筆支出那樣填好金額和它的幣別、誰付的錢和分攤,然後儲存。此時再打開該預訂,就能在「已關聯支出」下看到它,旁邊有鉛筆可以編輯,有「取消關聯,保留支出」可以解開關聯,有垃圾桶可以移除。', 'help.guide.expense-from-booking.result': '預訂帶上了它的花費,而這筆支出就是「費用」分頁上一條普通的列,和別的支出一樣有付款人、分攤和貨幣。', 'help.guide.expense-from-booking.tip.1': - '刪除預訂會連同它關聯的支出一起刪除。預訂的 Costs 區塊裡的 Remove expense 則相反:支出沒了,預訂還在。', - 'help.guide.expense-from-booking.tip.2': '地點的表單裡有同樣的區塊,其中的 Create expense 會先儲存地點。', + '刪除預訂會連同它關聯的支出一起刪除。預訂的「費用」區塊裡的「移除支出」則相反:支出沒了,預訂還在。「取消關聯,保留支出」則兩者都保留。', + 'help.guide.expense-from-booking.tip.2': '地點的表單裡有同樣的區塊,其中的「建立支出」會先儲存地點。', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── 'help.ctx.trip-transports.title': '交通', @@ -2988,7 +2990,7 @@ const help: TranslationStrings = { 'help.guide.add-transport.tip.1': '這一列落在它的開始時間決定的位置,也就是最後一個開始得更早的地點之後。它的拖曳把手能把它拖到當天任何別的位置,或者拖到另一天。', 'help.guide.add-transport.tip.2': - '「檔案」下的「附加檔案」接住車票,「Costs」下的「Create expense」儲存這筆預訂,並為票價開啟「費用」編輯器。', + '「檔案」下的「附加檔案」接住車票,「費用」下的「建立支出」儲存這筆預訂,並為票價開啟「費用」編輯器。', 'help.guide.add-transport.tip.3': '「出行成員」標出誰在這趟交通上。只要有一筆交通帶了出行成員,標籤頁的工具列就長出他們的頭像,並按他們篩選清單。', // plan-transit diff --git a/shared/src/i18n/zh-TW/reservations.ts b/shared/src/i18n/zh-TW/reservations.ts index 2a63c760a..4e1b2d0b5 100644 --- a/shared/src/i18n/zh-TW/reservations.ts +++ b/shared/src/i18n/zh-TW/reservations.ts @@ -163,11 +163,15 @@ const reservations: TranslationStrings = { 'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。', 'reservations.airtrail.importCta': '匯入 {count}', 'reservations.airtrail.joinConnection': '匯入為一個航班(在{stops}轉機)', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': '費用', + 'reservations.createExpense': '建立支出', + 'reservations.createExpenseHint': '先儲存預訂,然後開啟費用編輯器。', + 'reservations.linkedExpense': '關聯支出', + 'reservations.removeExpense': '移除支出', + 'reservations.linkedExpenses': '已關聯支出', + 'reservations.linkExpense': '關聯已有支出', + 'reservations.unlinkExpense': '取消關聯,保留支出', + 'reservations.noUnlinkedExpenses': '沒有未關聯的支出', 'reservations.urlLabel': '連結', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': '出行成員', diff --git a/shared/src/i18n/zh/help.ts b/shared/src/i18n/zh/help.ts index 9d75fb3a8..a961b0b19 100644 --- a/shared/src/i18n/zh/help.ts +++ b/shared/src/i18n/zh/help.ts @@ -2593,7 +2593,7 @@ const help: TranslationStrings = { 'help.guide.create-booking.goal': '把一家餐厅、一场活动、一个旅游团、一个车位或别的什么,手动放进这次旅行。', 'help.guide.create-booking.step.1': '点击这一栏右上角的“手动添加”。“新建预订”打开。', 'help.guide.create-booking.step.2': - '选“预订类型”。这一栏做的六类是“住宿”“餐厅”“活动”“旅游团”“停车”和“其他”,表单随选择而变:只有“住宿”会把日期换成一段日子的范围。', + '从表单顶部“出行人”旁边的列表里选“预订类型”。这一栏做的六类是“住宿”“餐厅”“活动”“旅游团”“停车”和“其他”,表单随选择而变:只有“住宿”会把日期换成一段日子的范围。', 'help.guide.create-booking.step.3': '填“标题”。这是表单唯一坚持要的字段,在它有内容之前“添加”一直是灰的。', 'help.guide.create-booking.step.4': '设置“日期”和“开始时间”,如果这条预订有结束,再设“结束日期”和“结束时间”。日历只给出旅行之内的日子;结束不晚于开始时,会用红色这样提示并挡住“添加”。', @@ -2639,8 +2639,9 @@ const help: TranslationStrings = { 'help.guide.booking-travelers.title': '说明一条预订是给谁的', 'help.guide.booking-travelers.goal': '标出一条预订涵盖的出行人,然后只看他们的。', 'help.guide.booking-travelers.step.1': - '用铅笔打开这条预订,往下到“出行人”。旅行的每位成员都在那里,其中也包括有名字的“访客”。', - 'help.guide.booking-travelers.step.2': '点击这条预订是给谁的那些人。被选中的人会得到一个勾;再点一次就取消。', + '用铅笔打开这条预订。“出行人”在表单顶部,“预订类型”旁边,只要这条预订上还没有人,它就显示“分配出行人”。', + 'help.guide.booking-travelers.step.2': + '点它,选出这条预订是给谁的那些人;有名字的“访客”也在列表里。被选中的人会得到一个勾,头像也会出现在这个字段中。再点一次名字就取消。', 'help.guide.booking-travelers.step.3': '点击“更新”。', 'help.guide.booking-travelers.step.4': '在上方工具栏里,类型筛选片旁边,点击某位出行人的头像,就只看他的预订。', 'help.guide.booking-travelers.result': '卡片列出它是给谁的那些人,而头像那一排把这一栏收窄到其中一个人。', @@ -2665,16 +2666,17 @@ const help: TranslationStrings = { 'help.guide.booking-cost.title': '把一条预订的金额变成费用', 'help.guide.booking-cost.goal': '把一条预订花掉的钱记进“费用”,并在付钱的人之间分摊。', 'help.guide.booking-cost.step.1': - '打开这条预订,走到表单底部。“费用”下面是“创建支出”,附带那行说明“先保存预订,然后打开费用编辑器。”', + '打开这条预订,走到表单底部。“费用”下面是“创建支出”和“关联已有支出”,附带那行说明“先保存预订,然后打开费用编辑器。”', 'help.guide.booking-cost.step.2': '点击“创建支出”。预订被保存,它的表单关上,费用编辑器打开。', 'help.guide.booking-cost.step.3': '“这笔花在哪了?”已经是这条预订的标题。填入“总金额”,并核对“货币”和“日期”。', 'help.guide.booking-cost.step.4': '“分类”是预订类型推出来的那一个。设置“谁支付的?”以及金额怎么“分摊”。', 'help.guide.booking-cost.step.5': '点击“添加支出”。', 'help.guide.booking-cost.result': - '这条预订的表单现在显示“关联支出”和金额,同一笔支出也立在“费用”栏里,系在这条预订上。', + '这条预订的表单现在在“已关联支出”下列出这笔支出和它的金额,同一笔支出也立在“费用”栏里,系在这条预订上。', 'help.guide.booking-cost.tip.1': '分类跟着类型走:“餐厅”变成“餐饮”,“住宿”变成“住宿”,“停车”变成“停车”,“活动”和“旅游团”都落进“其他”。', - 'help.guide.booking-cost.tip.2': '“关联支出”上的垃圾桶移除的是这笔支出,不是这条预订。', + 'help.guide.booking-cost.tip.2': + '一条预订可以带多笔支出。“关联已有支出”会列出“费用”里还不属于任何地方的支出。在已关联的一笔上,“取消关联,保留支出”会解开关联并把它留在“费用”里,而垃圾桶会把它移除。', 'help.guide.booking-cost.tip.3': '只有在“费用”扩展开着时,“费用”才在表单里,管理员在“扩展”下切换它。', // filter-bookings 'help.guide.filter-bookings.title': '找到一条预订', @@ -2875,15 +2877,15 @@ const help: TranslationStrings = { 'help.guide.expense-from-booking.goal': '把一趟航班、一家酒店或一个地点真正花掉的钱,附到它所属的那条记录上。', 'help.guide.expense-from-booking.step.1': '在“交通”或“预订”标签页打开该预订,点击它的铅笔。', 'help.guide.expense-from-booking.step.2': - '滚动到表单底部的“费用”区块。在还没有任何关联时,它会提供“创建支出”,并说明会先保存预订。', + '滚动到表单底部的“费用”区块。它提供会先保存预订的“创建支出”,以及用于已在“费用”里的支出的“关联已有支出”。', 'help.guide.expense-from-booking.step.3': '点击“创建支出”。预订被保存,表单关闭,费用编辑器随即打开,预订的标题作为名称,它的类型也已经匹配到了一个分类。', 'help.guide.expense-from-booking.step.4': - '像填任何一笔支出那样填好金额和它的币种、谁付的钱和分摊,然后保存。此时再打开该预订,就能在“关联支出”下看到它,旁边有铅笔可以编辑,有垃圾桶可以移除。', + '像填任何一笔支出那样填好金额和它的币种、谁付的钱和分摊,然后保存。此时再打开该预订,就能在“已关联支出”下看到它,旁边有铅笔可以编辑,有“取消关联,保留支出”可以解开关联,有垃圾桶可以移除。', 'help.guide.expense-from-booking.result': '预订带上了它的花费,而这笔支出就是“费用”标签页上一条普通的行,和别的支出一样有付款人、分摊和货币。', 'help.guide.expense-from-booking.tip.1': - '删除预订会连同它关联的支出一起删除。预订的“费用”区块里的“移除关联支出”则相反:支出没了,预订还在。', + '删除预订会连同它关联的支出一起删除。预订的“费用”区块里的“移除关联支出”则相反:支出没了,预订还在。“取消关联,保留支出”则两者都保留。', 'help.guide.expense-from-booking.tip.2': '地点的表单里有同样的区块,其中的“创建支出”会先保存地点。', // ── Screen: trip-transports ─────────────────────────────────────────────────────────── diff --git a/shared/src/i18n/zh/reservations.ts b/shared/src/i18n/zh/reservations.ts index 1a9c7147c..67c0199e0 100644 --- a/shared/src/i18n/zh/reservations.ts +++ b/shared/src/i18n/zh/reservations.ts @@ -168,6 +168,10 @@ const reservations: TranslationStrings = { 'reservations.createExpenseHint': '先保存预订,然后打开费用编辑器。', 'reservations.linkedExpense': '关联支出', 'reservations.removeExpense': '移除关联支出', + 'reservations.linkedExpenses': '已关联支出', + 'reservations.linkExpense': '关联已有支出', + 'reservations.unlinkExpense': '取消关联,保留支出', + 'reservations.noUnlinkedExpenses': '没有未关联的支出', 'reservations.urlLabel': '链接', 'reservations.urlPlaceholder': 'https://...', 'reservations.travelers.label': '出行人', diff --git a/wiki/Accommodations.md b/wiki/Accommodations.md index eb960fc81..7aee2be31 100644 --- a/wiki/Accommodations.md +++ b/wiki/Accommodations.md @@ -6,15 +6,15 @@ Link an accommodation to specific check-in and check-out days so it appears for There are two ways to create an accommodation: -**From the Reservations panel:** Click **Manual Booking** and select **Hotel** as the booking type. When the type is set to Hotel, the date/time and location fields are replaced by accommodation-specific inputs (see below). Saving the form creates both the hotel reservation and the underlying accommodation record at the same time. +**From the Reservations panel:** Click **Manual Booking** and choose **Accommodation** under **Booking Type** at the top of the form. With that type the date and time fields are replaced by accommodation-specific inputs (see below). Saving the form creates both the hotel reservation and the underlying accommodation record at the same time. **From the Day Detail panel:** Click the hotel icon or the **Add accommodation** button in the Day Detail overlay. A picker appears that lets you select a place from the trip, choose the day range, and optionally fill in check-in/check-out times and a confirmation code. This creates the accommodation record and its linked Hotel reservation together. -![Accommodation reservation card showing check-in details](assets/Hotel-ReservationCard.png) +![Edit Reservation dialog of an accommodation: Booking Type and Travelers at the top, the stay with its place, nights and check-in and check-out times, the link beside the files, and the linked expense](assets/Hotel-ReservationCard.png) ## Accommodation-specific fields -When creating or editing via the Reservations panel with type set to **Hotel**, the date/time and location fields are replaced by accommodation-specific inputs: +When creating or editing via the Reservations panel with the type set to **Accommodation**, the date/time and location fields are replaced by accommodation-specific inputs: | Field | Description | |-------|-------------| @@ -91,7 +91,7 @@ planned before 4.3.1 are seated the same way on upgrade, see A stay is tied to its check-in and check-out day, so it cannot outlive either of them. What happens to its booking depends on how the day goes: -- **Deleting the day** in the **Reorder days** dialog cancels the stay cleanly, together with its Hotel booking and the expense of that booking. The question before the delete shows this line in red. See [Deleting a day](Day-Plans-and-Notes#deleting-a-day). +- **Deleting the day** in the **Reorder days** dialog cancels the stay cleanly, together with its Hotel booking and the expenses of that booking. The question before the delete shows this line in red. See [Deleting a day](Day-Plans-and-Notes#deleting-a-day). - **Shortening the trip** removes the whole stay, also its nights that are still part of the trip, but leaves its booking under Bookings and its expense under Costs. The trip dialog shows the stay in red before it saves. See [Shortening a trip](Day-Plans-and-Notes#shortening-a-trip). A stay that only runs across a deleted day, with its check-in before and its check-out after it, is kept, one night shorter: its check-out day moves up with the days after the deleted one. The question before the delete names the stay and its new check-out date. Its Hotel booking is not changed, so a booking made with the hotel itself may need the same change there. diff --git a/wiki/Budget-Tracking.md b/wiki/Budget-Tracking.md index c35dc89ec..030b3eb2b 100644 --- a/wiki/Budget-Tracking.md +++ b/wiki/Budget-Tracking.md @@ -60,9 +60,15 @@ Click **Add expense**, or the pencil beside a row, to open the expense editor: ### Expenses linked to a booking or a place -An expense can hang off a **booking** (reservation or transport) or off a **place** — both offer a **Create expense** button in their form, which saves the record first and then opens the expense editor for it. A linked expense is an ordinary expense: it takes a payer, a split, a date and a currency like any other, and it shows up in the settlement. +An expense can hang off a **booking** (reservation or transport) or off a **place**. Both forms carry a Costs block: **Create expense** saves the record first and then opens the expense editor for it, and on a saved record **Link existing expense** ties an expense that is already in Costs and belongs nowhere yet. A record can carry several expenses, the flight and the seat upgrade bought for it later. A linked expense is an ordinary expense: it takes a payer, a split, a date and a currency like any other, and it shows up in the settlement. -Deleting the booking or the place deletes its linked expense with it. Removing the expense from the record's Costs block deletes only the expense and leaves the record standing. +Every linked expense is listed in the record's Costs block with three actions. The pencil edits it, **Unlink, keep the expense** lets go of it and leaves it in Costs, and the bin deletes only the expense. The record stands either way. Deleting the booking or the place deletes all of its linked expenses with it. + +A booking's price follows its expenses: it shows their sum when they share one currency, otherwise the first one, and it is cleared once nothing is linked any more. + +On the phone the booking, transport and place sheets carry the same block. Tap a linked expense to edit it, and **Link** opens the unlinked expenses right under the buttons, with a search once there are more than a handful. + +> **AI / MCP:** `update_budget_item` takes `reservation_id` and `place_id`; `null` unlinks. An id from another trip is refused. See [MCP-Tools-and-Resources](MCP-Tools-and-Resources). ### Receipts and invoices diff --git a/wiki/Documents-and-Files.md b/wiki/Documents-and-Files.md index 17c2b300d..236cdbfbe 100644 --- a/wiki/Documents-and-Files.md +++ b/wiki/Documents-and-Files.md @@ -55,7 +55,7 @@ Restore and delete operations require the `file_delete` permission. A file can be attached to multiple places and reservations at the same time (many-to-many). From the file manager, click the **link (pencil) icon** on a file to open the assign modal. From there you can toggle links to any trip place or reservation. You can also add a descriptive note to the file in the same modal. -From a reservation modal, use the "link existing file" picker to attach files directly. +From a booking or transport form, **Link existing file** next to **Attach file** picks a file the trip already has. On the phone it is the **Link** pill in the Files row of the booking and transport sheets. ## Downloading diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 95bea775e..b45427982 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -153,7 +153,7 @@ Requires `budget:read` or `budget:write` scope. The Budget addon must be enabled | Tool | Description | |---|---| | `create_budget_item` | Add an expense with name, category, and price. | -| `update_budget_item` | Update an expense's details, split (persons/days), or notes. | +| `update_budget_item` | Update an expense's details, split (persons/days), or notes. `reservation_id` / `place_id` link it to a booking or place of the same trip; `null` unlinks it. | | `delete_budget_item` | Remove a budget item. | | `set_budget_item_members` | Set which members are splitting a budget item (replaces current list). | | `toggle_budget_member_paid` | Mark or unmark a member as having paid their share. | diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index 4afa1a3b8..69746916a 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -215,11 +215,11 @@ Two inline warnings are shown when editing times: one if the end time is set to ## Costs for a place -With the [Costs/Budget addon](Budget-Tracking) enabled, the place form carries the same **Costs** block that bookings and transports have. **Create expense** saves the place and then opens the Costs editor for a new expense linked to it: the museum ticket, the guided tour, the entry fee. Once linked, the block shows that expense with edit and remove actions. +With the [Costs/Budget addon](Budget-Tracking) enabled, the place form carries the same **Costs** block that bookings and transports have. **Create expense** saves the place and then opens the Costs editor for a new expense linked to it: the museum ticket, the guided tour, the entry fee. On a saved place, **Link existing expense** ties one that is already in Costs. Every linked expense is listed with edit, unlink and remove actions, and a place can carry several. The expense belongs to the **place**, not to a day. Putting the same place on several days does not multiply it: you bought the ticket once. If you really pay each time, add a second expense from the Costs tab. -Deleting the place deletes its linked expense too, the same way deleting a booking does. +Deleting the place deletes its linked expenses too, the same way deleting a booking does. ## Rating a place diff --git a/wiki/Reservations-and-Bookings.md b/wiki/Reservations-and-Bookings.md index 38649d1f6..00004ce57 100644 --- a/wiki/Reservations-and-Bookings.md +++ b/wiki/Reservations-and-Bookings.md @@ -76,7 +76,7 @@ Each card displays: Click **Add** (or the + button) in the Bookings panel. Fill in the form: -1. **Type** — choose Hotel, Restaurant, Event, Tour, Parking, or Other +1. **Type** and **Travelers**, side by side at the top of the form. Type is a dropdown: Hotel, Restaurant, Event, Tour, Parking, or Other. Travelers opens the trip roster, members and named guests alike, and every person ticked there is on this booking 2. **Title** — required 3. **Link to day-plan assignment** — optional; search across all days and places, grouped by day. Not available for Hotel type 4. **Start date and time** — not shown for Hotel type @@ -86,11 +86,9 @@ Click **Add** (or the + button) in the Bookings panel. Fill in the form: 8. **Confirmation code** 9. **Status** — Pending or Confirmed 10. **Hotel-specific fields** — shown only for Hotel type, immediately after status: hotel place, check-in day, check-out day, location / address, check-in time (window start and end), and check-out time. The address is pre-filled from the picked hotel place, and a hand-typed one is kept if that place has none. See [Accommodations](Accommodations) -11. **Link** — an optional booking URL, shown for every type +11. **Link** and **Files**, side by side. Link is an optional booking URL, shown for every type. Files attaches from your device (PDF, Word documents, text files, images) or, on a saved booking, links a file the trip already has with **Link existing file**. Files added before saving are uploaded automatically after the reservation is created 12. **Notes** -13. **Travelers** — assign trip members and named guests to this booking. Guests appear in the same picker as members -14. **Files** — attach from your device (PDF, Word documents, text files, images) or link an existing trip file. Files added before saving are uploaded automatically after the reservation is created -15. **Costs** — shown only when the Budget addon is enabled. Instead of a price field, the form carries a **Create expense** button: it saves the booking and then opens the Costs editor for a new expense linked to it, so the expense gets a payer, a split and a date like any other. Once linked, the block shows that expense with edit and remove actions. See [Budget-Tracking](Budget-Tracking) +13. **Costs**, shown only when the Budget addon is enabled. Instead of a price field, the form carries **Create expense**: it saves the booking and then opens the Costs editor for a new expense linked to it, so the expense gets a payer, a split and a date like any other. On a saved booking, **Link existing expense** next to it ties an expense that is already in Costs and belongs nowhere yet. A booking can carry several expenses; each is listed with edit, unlink (the expense stays in Costs) and remove actions, and the price on the card is their sum when they share one currency. See [Budget-Tracking](Budget-Tracking) diff --git a/wiki/Transport-Flights-Trains-Cars.md b/wiki/Transport-Flights-Trains-Cars.md index efa791515..49f3f415b 100644 --- a/wiki/Transport-Flights-Trains-Cars.md +++ b/wiki/Transport-Flights-Trains-Cars.md @@ -44,7 +44,11 @@ All transport types share these fields: | Arrival time | Optional | | Booking / confirmation code | Optional | | Status | Pending or Confirmed | +| Link | Optional booking URL, beside Files | | Notes | Optional free text | +| Travelers | Next to the type at the top of the form: who is on this ride, members and named guests | +| Files | Attach a ticket, or link a file the trip already has | +| Costs | With the Budget addon: create an expense, or link one already in Costs. See [Budget-Tracking](Budget-Tracking) | ## Endpoints diff --git a/wiki/assets/Hotel-ReservationCard.png b/wiki/assets/Hotel-ReservationCard.png index 03c5154f9..c91de7c43 100644 Binary files a/wiki/assets/Hotel-ReservationCard.png and b/wiki/assets/Hotel-ReservationCard.png differ