diff --git a/.claude/skills/agent-device/flows/macros/android/send-message.ad b/.claude/skills/agent-device/flows/macros/android/send-message.ad index 62039cd67e13..bdc93542173c 100644 --- a/.claude/skills/agent-device/flows/macros/android/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/android/send-message.ad @@ -1,5 +1,5 @@ context platform=android -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessage Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. # @pre label="Write something..." editable=true # @post label="Write something..." editable=true # @tag chat diff --git a/.claude/skills/agent-device/flows/macros/send-message.ad b/.claude/skills/agent-device/flows/macros/send-message.ad index 516e64a08e1b..98f869fbdc79 100644 --- a/.claude/skills/agent-device/flows/macros/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/send-message.ad @@ -1,4 +1,4 @@ -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessage Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. # @pre label="Write something..." editable=true # @post label="Write something..." editable=true # @tag chat diff --git a/.claude/skills/agent-device/flows/macros/web/send-message.ad b/.claude/skills/agent-device/flows/macros/web/send-message.ad index bcf386f0f49c..d34d111b9e7a 100644 --- a/.claude/skills/agent-device/flows/macros/web/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/web/send-message.ad @@ -1,5 +1,5 @@ context platform=web -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessage Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. # @pre role="textbox" label="Write something..." # @post role="textbox" label="Write something..." # @tag chat diff --git a/.claude/skills/agent-device/flows/tests/send-message.ad b/.claude/skills/agent-device/flows/tests/send-message.ad index 841e749b9c29..97c8a8db6100 100644 --- a/.claude/skills/agent-device/flows/tests/send-message.ad +++ b/.claude/skills/agent-device/flows/tests/send-message.ad @@ -1,11 +1,11 @@ -# @desc From the Inbox tab, open a chat matching TARGET_CHAT in the LHN, type a message and send it. Triggers the ManualSendMessage Sentry span (Send press -> optimistic message fragment rendered). Assumes the user is signed in with at least one chat in the Inbox. +# @desc From the Inbox tab, open a chat matching TARGET_CHAT in the LHN, type a message and send it. Triggers the ManualSendMessageVisible Sentry span (Send press -> optimistic message fragment rendered). Assumes the user is signed in with at least one chat in the Inbox. # @pre text="Inbox" # @pre role="button" label="Search" # @post label="Write something..." editable=true # @tag chat # @tag navigation # @tag perf -# @tag sentry-ManualSendMessage +# @tag sentry-ManualSendMessageVisible # @param MESSAGE Message text to send in the opened chat. # @param TARGET_CHAT Visible report/chat title to open from Inbox LHN. diff --git a/Mobile-Expensify b/Mobile-Expensify index 89db19cb689b..9586fa869bca 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 89db19cb689bcdc0bcff014d9a7e634b8fa0c4cc +Subproject commit 9586fa869bca239f26d7dbf3d2da5d3012be193d diff --git a/android/app/build.gradle b/android/app/build.gradle index 2bd08a4a8626..d696f795edea 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046100 - versionName "9.4.61-0" + versionCode 1009046300 + versionName "9.4.63-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/product-illustrations/emptystate__searchexpenses.svg b/assets/images/product-illustrations/emptystate__searchexpenses.svg new file mode 100644 index 000000000000..2470f68d9f63 --- /dev/null +++ b/assets/images/product-illustrations/emptystate__searchexpenses.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index b5c752a618ae..b02d18dfd999 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -57,7 +57,7 @@ "../../src/Expensify.tsx" "react-hooks/set-state-in-effect" 1 "../../src/GlobalModals.tsx" "no-restricted-syntax" 2 "../../src/ONYXKEYS.ts" "no-restricted-syntax" 2 -"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 59 +"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 54 "../../src/ROUTES.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/TIMEZONES.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AccountingConnectionConfirmationModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 @@ -658,10 +658,10 @@ "../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/ReportPrimaryActionUtils.ts" "no-restricted-imports" 1 "../../src/libs/ReportTitleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 5 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 3 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getPolicy" 27 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 8 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 34 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 33 "../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 16 "../../src/libs/SearchAutocompleteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 @@ -830,7 +830,6 @@ "../../src/libs/actions/Wallet.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Welcome/OnboardingFlow.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/Welcome/index.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/Workflow.ts" "no-restricted-syntax" 1 "../../src/libs/actions/__mocks__/App.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/__mocks__/App.ts" "no-restricted-syntax" 2 "../../src/libs/actions/__mocks__/OnyxUpdates.ts" "no-restricted-syntax" 1 @@ -968,7 +967,7 @@ "../../src/pages/TransactionMerge/MergeTransactionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/TransactionMerge/MergeTransactionsListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/Travel/DynamicTravelUpgrade.tsx" "no-restricted-imports" 1 -"../../src/pages/Travel/WorkspaceConfirmationForTravelPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/UnreportedExpenseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ValidateLoginPage/index.web.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/domain/Groups/PreferredWorkspaceToggle.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 @@ -1039,6 +1038,7 @@ "../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepAmount.tsx" "react-hooks/set-state-in-effect" 1 @@ -1056,7 +1056,6 @@ "../../src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/request/step/IOURequestStepWaypoint.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/StepScreenWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/withFullTransactionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/withWritableReportOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1104,9 +1103,7 @@ "../../src/pages/settings/Wallet/InternationalDepositAccount/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/PaymentMethodList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/pages/settings/Wallet/PersonalCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/useFixPersonalCardConnection.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1235,7 +1232,6 @@ "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 -"../../src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/preserve-manual-memoization" 2 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/set-state-in-effect" 1 diff --git a/contributingGuides/OBSERVABILITY.md b/contributingGuides/OBSERVABILITY.md index 9bcaca413aab..f844d3a26a06 100644 --- a/contributingGuides/OBSERVABILITY.md +++ b/contributingGuides/OBSERVABILITY.md @@ -58,7 +58,7 @@ In development, Sentry is always initialized but does not send data by default. ### Constants Defined in `src/CONST/index.ts` under `CONST.TELEMETRY`: -- Span names: `SPAN_OPEN_REPORT`, `SPAN_SEND_MESSAGE` +- Span names: `SPAN_OPEN_REPORT`, `SPAN_SEND_MESSAGE_VISIBLE` - Tag names: `TAGS.ACTIVE_POLICY`, `TAGS.AUTHENTICATION_ERROR_TYPE` - Attribute names: `ATTRIBUTE_REPORT_ID`, `ATTRIBUTE_MESSAGE_LENGTH` - Configuration: `CONFIG.MEMORY_TRACKING_INTERVAL` diff --git a/contributingGuides/OBSERVABILITY_METRICS.md b/contributingGuides/OBSERVABILITY_METRICS.md index 75aa7aab3860..604bdeebbfa0 100644 --- a/contributingGuides/OBSERVABILITY_METRICS.md +++ b/contributingGuides/OBSERVABILITY_METRICS.md @@ -145,35 +145,21 @@ This document lists all implemented telemetry metrics in the Expensify App. ### Send Message -**Constant**: `CONST.TELEMETRY.SPAN_SEND_MESSAGE` -**Sentry Name**: `ManualSendMessage` -**Threshold**: 100ms (P90) -**What's Measured**: Time from submitting message to the message's React flush. **Caveat**: the end is anchored in a `useEffect`, which fires before the frame is on screen — it does NOT measure visibility. Kept temporarily for comparison against `ManualSendMessageVisible` (below), then to be retired. -**Start**: Message submitted in composer, only when scrolled to bottom ([`src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts)) -**End**: -- User sees: nothing specific — the effect flush is invisible; the message usually appears later -- Technical: Passive effect of the message fragment runs ([`src/pages/inbox/report/comment/TextCommentFragment.tsx`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/comment/TextCommentFragment.tsx)) -**Span ID**: `${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}` (optimistic report action ID) -**Attributes**: `report_id`, `message_length`, `canceled_by_skeleton`, `send_message_source`, `report_action_count`, `money_request_preview_count` -**Cancellation (report-actions skeleton)**: While a report-actions skeleton is on screen, we listen for `ManualSendMessage` spans started for that report and cancel them immediately, tagging `canceled: true` plus `canceled_by_skeleton` with the skeleton that caused it. -- `canceled_by_skeleton` values (`CONST.TELEMETRY.CANCELED_BY_SKELETON`) based on skeleton condition -**Cancellation (report unmount / navigate away)**: If the user leaves the report before their message renders, any pending `ManualSendMessage` span is cancelled via `cancelSpansByPrefix()` to avoid orphaned spans. Cancelled this way the span gets `canceled: true` but **no** `canceled_by_skeleton` (a blanket cancel by span-id prefix, not scoped to one `report_id`). -**Notes**: `send_message_source` = `_` (+ `_rhp` in the RHP, + `_from_report` when drilled in from a report) — slice the metric by send path. -`report_action_count` / `money_request_preview_count` — how many renderable actions the chat's list holds and how many of them are `REPORT_PREVIEW` items — slice the metric by list weight (e.g. chats with many `MoneyRequestReportPreview` items). - -### Send Message (Visible) - **Constant**: `CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE` **Sentry Name**: `ManualSendMessageVisible` -**Threshold**: TBD — will read higher than `ManualSendMessage` by design; recalibrate after the comparison window -**What's Measured**: Time from submitting message to the message actually laid out on screen (`onLayout`), i.e. user-perceived send latency. Runs alongside the legacy `ManualSendMessage` span: same start, same attributes, different end anchor. Once validated in Sentry (~2 weeks), the legacy effect-anchored span gets removed and this becomes the send-message metric. -**Start**: Started back-to-back with `ManualSendMessage` in the composer submit ([`src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts)) +**Threshold**: 300ms (P90) +**What's Measured**: Time from submitting a message to the message actually laid out on screen (`onLayout`), i.e. user-perceived send latency +**Start**: Message submitted in composer, only when scrolled to bottom ([`src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts)) **End**: - User sees: Their message appears in chat - Technical: Message layout complete (`onLayout` event) in [`src/pages/inbox/report/comment/TextCommentFragment.tsx`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/comment/TextCommentFragment.tsx) **Span ID**: `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${reportActionID}` (optimistic report action ID) -**Attributes**: Same as `ManualSendMessage` (`report_id`, `message_length`, `send_message_source`, `report_action_count`, `money_request_preview_count`, plus cancellation tags) -**Cancellation**: Same paths as `ManualSendMessage` — the skeleton listener matches both ops, and prefix cancels cover it automatically because `ManualSendMessage` is a prefix of `ManualSendMessageVisible`. +**Attributes**: `report_id`, `message_length`, `canceled_by_skeleton`, `send_message_source`, `report_action_count`, `money_request_preview_count` +**Cancellation (report-actions skeleton)**: While a report-actions skeleton is on screen, we listen for `ManualSendMessageVisible` spans started for that report and cancel them immediately, tagging `canceled: true` plus `canceled_by_skeleton` with the skeleton that caused it. +- `canceled_by_skeleton` values (`CONST.TELEMETRY.CANCELED_BY_SKELETON`) based on skeleton condition +**Cancellation (report unmount / navigate away)**: If the user leaves the report before their message renders, any pending `ManualSendMessageVisible` span is cancelled via `cancelSpansByPrefix()` to avoid orphaned spans. Cancelled this way the span gets `canceled: true` but **no** `canceled_by_skeleton` (a blanket cancel by span-id prefix, not scoped to one `report_id`). +**Notes**: `send_message_source` = `_` (+ `_rhp` in the RHP, + `_from_report` when drilled in from a report) — slice the metric by send path. +`report_action_count` / `money_request_preview_count` — how many renderable actions the chat's list holds and how many of them are `REPORT_PREVIEW` items — slice the metric by list weight (e.g. chats with many `MoneyRequestReportPreview` items). ## Failure Rates diff --git a/cspell.json b/cspell.json index b5357a1a4839..69e8e6dd2697 100644 --- a/cspell.json +++ b/cspell.json @@ -36,6 +36,7 @@ "Amina", "Areport", "Authy", + "Ashby", "bannered", "BBVA", "BILLCOM", @@ -699,6 +700,7 @@ "hybridapp", "iOSQRCode", "iaco", + "iban", "idempotently", "idfa", "ifdef", diff --git a/docs/articles/new-expensify/connections/Greenhouse.md b/docs/Hidden/Greenhouse.md similarity index 100% rename from docs/articles/new-expensify/connections/Greenhouse.md rename to docs/Hidden/Greenhouse.md diff --git a/docs/articles/Unlisted/Upcoming-Expense-And-Report-Management.md b/docs/articles/Unlisted/Upcoming-Expense-And-Report-Management.md index e3946c8b9d9e..0e813b2b5050 100644 --- a/docs/articles/Unlisted/Upcoming-Expense-And-Report-Management.md +++ b/docs/articles/Unlisted/Upcoming-Expense-And-Report-Management.md @@ -1,7 +1,7 @@ --- title: Upcoming Features – Expense & Report Management (New Expensify) description: Internal AgentZero reference for planned and in-development expense and report management features in New Expensify. Not for public search indexing. -keywords: upcoming, coming soon, planned, roadmap, duplicate detection, mileage rates, distance rates, merchant naming, bulk PDF export, cross-period export, export filtered views, submit via PDF, approval paths, personal expense repayment, pending BYOC transactions, AgentZero reference +keywords: upcoming, coming soon, planned, roadmap, duplicate detection, mileage rates, distance rates, merchant naming, bulk PDF export, cross-period export, export filtered views, approval paths, personal expense repayment, pending BYOC transactions, AgentZero reference noindex: true sitemap: false internalScope: For AgentZero and internal tooling only. Use this page to give accurate, forward-looking answers about expense and report management features that are planned but not yet generally available. Do not expose this URL in user-facing help. @@ -17,7 +17,6 @@ This page is an **AgentZero reference** for expense and report management featur - **Bulk report PDF export** – Export multiple expense report PDFs at once from search results or a filtered view, rather than downloading one at a time. - **Cross-period expense exporting** – When exporting grouped report transactions to an accounting system, allow expenses from different accounting periods to be split across the correct periods rather than all landing on one date. - **Export filtered views** – Export the results of any in-app filtered or searched view directly to CSV or an accounting integration, without needing to manipulate data externally. -- **Submit via PDF (Submit workspace)** – Allow employees using a Submit workspace to generate a PDF of their expense report directly, without needing to submit to themselves as a workaround. - **Multiple approval paths per approver** – Support for approvers who manage more than one approval workflow or department, so expenses route correctly regardless of which workspace or category they belong to. - **Personal expense repayments** – When an employee accidentally charges a personal expense to a company card, allow them to repay it directly through Expensify rather than going outside the platform. - **Pending BYOC transactions** – Show pending (not-yet-settled) card transactions from Bring Your Own Card feeds in New Expensify, matching the real-time visibility employees expect from their banking apps. diff --git a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md index e7c26fa841bb..f2d65c7aab14 100644 --- a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md +++ b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md @@ -1,7 +1,7 @@ --- title: Upcoming Features – Integrations & Accounting (New Expensify) -description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, NetSuite REST, and HR imports. Not for public search indexing. -keywords: upcoming, coming soon, planned, roadmap, Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, D365, NetSuite REST API, SuiteTalk, merge.dev, BambooHR, Workday, HiBob, HR import, HRIS, OAuth, Integration Server, accounting integration, AgentZero reference +description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Puzzle.io, Microsoft Dynamics 365 Business Central, DualEntry, NetSuite REST, Greenhouse, Ashby, Lever, Campfire, Workable, and HR imports. Not for public search indexing. +keywords: [upcoming, coming soon, planned, roadmap, Campfire, Puzzle.io, DualEntry, Greenhouse, Ashby, Lever, Workable, Microsoft Dynamics 365, Microsoft Dynamics 365 Business Central, Dynamics, D365, NetSuite REST API, SuiteTalk, merge.dev, HR import, HRIS, OAuth, Integration Server, accounting integration] noindex: true sitemap: false internalScope: For AgentZero and internal tooling only. Use this page to give accurate, forward-looking answers about accounting and integration features that are planned but not yet generally available. Do not expose this URL in user-facing help. @@ -11,9 +11,14 @@ internalScope: For AgentZero and internal tooling only. Use this page to give ac This page is an **AgentZero reference** for accounting and integration features that are planned or in development for New Expensify. Use it to answer "is this coming soon?" questions accurately. These items are subject to change. -- **Rillet integration** – Native accounting integration with Rillet, a modern general ledger for US-based companies, enabling direct sync of expenses and reports. -- **Puzzle.io integration** – Native accounting integration with Puzzle.io, adding support for startups and SMBs using Puzzle as their accounting system. -- **Microsoft Dynamics 365 Business Central integration** – Native integration with Microsoft Dynamics 365 Business Central, covering a significant share of SMB and mid-market accounting users who currently rely on manual CSV/API workarounds. +- **Puzzle.io integration** – Accounting integration with Puzzle.io, adding support for startups and SMBs using Puzzle as their accounting system. +- **Microsoft Dynamics 365 Business Central integration** – Accounting integration with Microsoft Dynamics 365 Business Central, covering a significant share of accounting users who currently rely on manual CSV/API workarounds. +- **Campfire** – Accounting integration with Campfire, an AI-powered ERP and general ledger, enabling customers to import accounting data, export expenses and card spend, and keep reimbursements and settlements in sync without relying on CSV exports or custom workarounds. +- **Greenhouse** – HR integration with Greenhouse, a widely used recruiting and applicant tracking platform, expanding connectivity for companies that manage their hiring workflows in Greenhouse. +- **Ashby** – HR integration with Ashby, an all-in-one recruiting platform, expanding connectivity for teams using Ashby to manage recruiting and applicant tracking. +- **Lever** – HR integration with Lever, an applicant tracking system and recruiting CRM, expanding connectivity for companies using Lever to manage candidates and hiring workflows. +- **Workable** – HR integration with Workable, an applicant tracking and HR platform, expanding connectivity for companies using Workable to manage recruiting and talent workflows. +- **Sage Accounting** – Accounting integration with Sage Accounting, supporting core accounting workflows for businesses in the UK and Europe and making it easier to connect, import, export, and sync accounting data directly with Expensify. - **NetSuite REST API migration** – Move the NetSuite integration from the legacy SuiteTalk SOAP API to the modern REST API, improving reliability, sync speed, and long-term supportability. -- **HR system import via merge.dev** – Connect New Expensify to a wide range of HR platforms (BambooHR, Workday, HiBob, and others) via merge.dev, automatically syncing employee data, org structure, and approval hierarchies. - **Extend OAuth to the Integration Server (IS)** – Extend the OAuth framework (originally built for MCP integrations) to the Integration Server API, making it easier for third-party developers to build secure integrations with Expensify. +- diff --git a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md index 16cd70a7fd92..2e6aad0a2157 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md @@ -1,7 +1,7 @@ --- title: Compare Collect and Control Plans description: Learn the primary differences between Expensify's Collect and Control plans and which organizations each plan is designed for. -keywords: [Collect plan, Control plan, compare plans, Expensify plans, Workspace plans] +keywords: [Collect plan, Control plan, Submit plan, free plan, compare plans, Expensify plans, Workspace plans] internalScope: Audience is anyone evaluating Expensify plans. Covers the high-level differences between Collect and Control. Does not provide a complete feature list or pricing. retrievalIntent: Which plan should I choose contentType: topic @@ -11,13 +11,15 @@ order: 2 # Compare Collect and Control Plans -Expensify offers two workspace plans: +Expensify offers two workspace plans for organizations: - **Collect** for organizations that need straightforward expense management and reimbursements. - **Control** for organizations that need advanced spend management, automation, integrations, and administrative controls. This article provides a high-level comparison to help you choose the right workspace plan. For the complete feature comparison and current pricing, visit [Expensify Pricing](https://www.expensify.com/pricing). +Expensify also offers a free **Submit** plan for individual employees who want to code their expenses and submit them to an approver if their company hasn't adopted Expensify yet. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). + ## When to choose the Collect plan Collect is best for organizations that primarily want to: @@ -64,10 +66,10 @@ To learn more, see [Learn About Plan and Subscription Management](/articles/new- ## Does every workspace have a plan? -Yes. Every workspace uses either the Collect or Control plan. +Yes. Every workspace has a plan that controls which features are available on the workspace. -## Does the Expensify Card work with both plans? +## Does the Expensify Card work with both Collect and Control plans? -Yes. The Expensify Card works with both plans. Certain subscription pricing benefits are only available with eligible Control workspaces. +Yes. The Expensify Card works with both Collect and Control plans. Certain subscription pricing benefits are only available with eligible Control workspaces. To learn more, see [Understand Expensify Pricing](/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing). diff --git a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md index 647f48e8facc..cee415fff29e 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md @@ -166,3 +166,7 @@ Yes. If you increase your subscription size or exceed it during an Annual subscr ## Which currencies does Expensify support for localized pricing? Expensify offers localized pricing for USD, GBP, EUR, AUD, and NZD billing. + +## Is there a free Expensify plan? + +Yes. Expensify offers a free Submit workspace for individual employees who want to code expenses and submit them to an approver when their company hasn’t adopted Expensify. It doesn't include premium features like approvals, payments, company cards, or accounting integrations. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md index 5c5ddc821672..53d90770e6bd 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md @@ -28,7 +28,11 @@ Before you can enable either program, you must have a valid payment card on file ## How to enable or disable Personal Karma -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Account**. +Personal Karma can only be managed in a web browser. + +On web: + +1. In the navigation tabs on the left, go to **Account**. 2. Select **Save the world**. 3. In the **Enable Personal Karma** section, turn Personal Karma on or off. 4. Add a payment card if prompted. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md index b2a435220256..89d5a075c53c 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md @@ -1,8 +1,8 @@ --- title: Change Your Workspace Plan -description: Switch a Workspace's Plan type between Collect and Control. -keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, workspace admin] -internalScope: Audience is Workspace owners. Covers how to switch a Workspace between the Collect and Control plans. Does not cover the differences between plans or how to change a subscription. +description: Change your Workspace's Plan type to Collect or Control. +keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, workspace admin] +internalScope: Audience is Workspace owners. Covers how to switch a Workspace between the Collect and Control plans, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription. retrievalIntent: How to switch a Workspace plan between Collect and Control. contentType: task platform: new @@ -11,7 +11,7 @@ order: 4 # Change Your Workspace Plan -Every workspace uses either the Collect or Control plan. Your workspace plan determines which features are available. If your needs change, you can switch the workspace's **Plan type**. +Paid workspaces use either the Collect or Control plan. Your workspace plan determines which features are available. If your needs change, you can switch the workspace's **Plan type**. To learn which plan is right for your organization, see [Compare Collect and Control Plans](/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans). @@ -25,7 +25,7 @@ If you have an Annual subscription, changes to your plan may be restricted. To l --- -## How to switch between the Collect and Control plans +## How to switch to the Collect or Control plans 1. In the navigation tabs (on the left on web, at the bottom on mobile), go to **Workspaces > [workspace name]**. 2. Select **Overview**. @@ -35,6 +35,14 @@ If you have an Annual subscription, changes to your plan may be restricted. To l --- +## How to upgrade from a Submit workspace + +A free Submit workspace can be upgraded to Collect or Control. Once upgraded, it can’t be downgraded back to Submit. + +[Learn how to upgrade a Submit workspace](/articles/new-expensify/getting-started/Submit-Workspace#how-to-upgrade-a-submit-workspace). + +--- + ## Related articles - [View Your Plan and Subscription](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/View-Your-Plan-and-Subscription) @@ -60,3 +68,6 @@ This happens when you're on the Control plan with an Annual subscription. You've You can't downgrade the plan on an invoiced subscription from the app. Reach out to your Account Manager or Concierge to discuss or make changes to your subscription. +## Can I downgrade a paid workspace back to a Submit workspace? + +No. Once a Submit workspace is upgraded to Collect or Control, it can't be downgraded back to Submit. diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md index b2a14bcfd664..2ad4a668fba1 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md @@ -102,6 +102,12 @@ Your workspace also has built-in default protections that automatically block ce ![Click Settings to adjust the settlement account or frequency]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_08.png){:width="100%"} +## When your Expensify Cards settle each month + +When your settlement frequency is set to **Monthly**, the **Settings** page shows the recurring day of the month your cards settle. On the Expensify Card **Settings** page, the **Settlement frequency** row displays supporting text below it, such as *Expensify cards will settle on the 27th of each month.* + +This date is shown for reference only and cannot be changed. If your settlement frequency is set to **Daily**, no monthly settlement date is shown because balances settle automatically every business day. + ## What the Status column shows in the Expensify Card list The Expensify Card list includes a **Status** column so you can see each card's lifecycle state at a glance without opening the card. Click the **Status** column header to sort the list by status. diff --git a/docs/articles/new-expensify/getting-started/Submit-Workspace.md b/docs/articles/new-expensify/getting-started/Submit-Workspace.md index 5ce5d28fd9e4..9ee3f5fa0e46 100644 --- a/docs/articles/new-expensify/getting-started/Submit-Workspace.md +++ b/docs/articles/new-expensify/getting-started/Submit-Workspace.md @@ -7,7 +7,7 @@ internalScope: Audience is employees who want to get reimbursed by their employe A Submit workspace is a free workspace in New Expensify for employees who want to get reimbursed by their employer. You can scan receipts, code your expenses with categories and tags, and submit reports to an approver — even if your company hasn't adopted Expensify yet. There's no cost to submit, and your approver doesn't need an account to receive your report. -Premium features are not included in a Submit workspace. To use approvals, payments, company cards, the Expensify Card, Per Diem, Expensify Travel, invoices, or accounting integrations, you'll need to [learn how to upgrade your workspace plan](https://github.com/articles/new-expensify/billing-and-subscriptions/Changing-Your-Workspace-Plan). +Premium features are not included in a Submit workspace. To use approvals, payments, company cards, the Expensify Card, Per Diem, Expensify Travel, invoices, or accounting integrations, you'll need to [learn how to upgrade your workspace plan](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan). --- @@ -17,7 +17,6 @@ Premium features are not included in a Submit workspace. To use approvals, payme - Anyone on your company's email domain can join your Submit workspace and is added as an **Editor**. - An **Editor** can configure the workspace (such as adding categories and tags) but cannot see other members' expenses or reports, and cannot change member roles. - --- ## How to create a Submit workspace @@ -53,7 +52,6 @@ Editors can't: - View other members' expenses or reports. - Change member roles. - --- ## How to join an existing Submit workspace @@ -68,6 +66,7 @@ If someone on your email domain already owns a Submit workspace, you can join it When you join, you're added as an **Editor**. You can collaborate on the workspace configuration, but you can't see or take action on other members' expenses or reports. If you weren't automatically added to the workspace when you created your account, ask a current Editor of the Submit workspace to invite you to join. Learn how to [invite members to a workspace](/articles/new-expensify/workspaces/Managing-Workspace-Members#invite-members-to-a-workspace). + --- ## How to submit an expense to an approver @@ -119,6 +118,19 @@ Marking a report as paid lets you track which of your submitted reports are stil --- +## How to upgrade a Submit workspace + +If you have a free Submit workspace, you can upgrade it to a paid Collect or Control plan to unlock premium features such as approvals, payments, company cards, and accounting integrations. + +Workspace Editors and anyone who approves a report on a Submit workspace can upgrade the workspace: + + - Workspace Editors can switch the **Plan type** in the workspace **Overview** tab. + - Anyone who approves a report can upgrade the workspace by clicking **Approve** on a report submitted to them. + +You can’t downgrade a Collect or Control workspace back to a Submit workspace after upgrading. + +--- + ## What happens when my approver clicks Approve? If your approver clicks **Approve**, they're prompted to upgrade the workspace to a paid plan before they can complete the approval. @@ -153,3 +165,4 @@ Editors can configure the workspace, but can't see other members' submitted repo No. Submitting expenses and marking reports as paid are free. Payment is only required when someone upgrades the workspace to a paid plan, such as when an approver approves a report. + diff --git a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md index 47ecf9830798..a936960561c3 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md +++ b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md @@ -20,7 +20,7 @@ Report previews are summaries shown directly in your workspace chat in the **Inb ## Preview Details - Report title and status (e.g., Approved, Paid) - Up to 10 expense previews with: - - Date, category, tag + - Date and category - Merchant or description - Receipt thumbnail - Carousel navigation diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md index c3313ac22ca5..f4b0e40d6c2f 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md @@ -1,8 +1,8 @@ --- title: How to Export Expenses -description: Learn how to export expenses from the Expenses page to CSV using default or custom export templates in New Expensify. -keywords: [New Expensify, export expenses, CSV export, download expenses, expense export template, bulk export expenses, expense data] -internalScope: Audience is all members. Covers exporting expenses from the Expenses page to CSV using export templates, both individually and in bulk. Does not cover exporting reports to CSV or PDF (see Export-Reports.md), exporting to an accounting integration, or company card reconciliation. +description: Learn how to export expenses from the Expenses page to CSV using default or custom export templates, and how to download their receipts, in New Expensify. +keywords: [New Expensify, export expenses, CSV export, download expenses, download receipts, expense receipts, receipt zip, expense export template, bulk export expenses, expense data] +internalScope: Audience is all members. Covers exporting expenses from the Expenses page to CSV using export templates (both individually and in bulk) and downloading the receipts on selected expenses. Does not cover exporting reports to CSV or PDF (see Export-Reports.md), exporting to an accounting integration, or company card reconciliation. --- # How to export expenses @@ -34,6 +34,18 @@ You'll receive the exported CSV file in a message from Concierge. You'll receive the exported CSV file in a message from Concierge. +## How to download the receipts on selected expenses + +You can download the receipts attached to your selected expenses in a single ZIP file. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend** > **Expenses**. +2. Select the checkbox next to each expense whose receipt you want to download, or use the top checkbox to select all. +3. Select **Selected** at the top. +4. Select **Download receipts**. +5. Wait for the files to finish preparing. The zip file downloads automatically when it's ready. If it doesn't, select **Download file** to save it to your device. + +**Note:** **Download receipts** only appears when at least one selected expense has a receipt. Deleted expenses are not included in the download. + ## How to export the columns currently displayed using Export current view Use **Export current view** to download a CSV that matches the columns currently shown on the **Expenses** page, instead of a fixed export template. @@ -49,6 +61,8 @@ The CSV includes one row per expense using the same columns displayed on the pag --- +# FAQ + ## What export templates can I choose from? Expensify offers pre-built export templates, or you can build your own custom export template. @@ -69,6 +83,14 @@ Yes. Select **Export current view** to download a CSV that matches the columns c If all selected expenses are deleted, only the **Basic export** template is available. Other export templates require a report, and deleted expenses are not associated with any report. +## Why don't I see Download receipts for my selected expenses? + +**Download receipts** only appears when at least one selected expense has a receipt attached. If none of the selected expenses have receipts, the option is hidden. + +## Are deleted expenses included when I download receipts? + +No. Deleted expenses are excluded from the download, even if they had a receipt. Only the receipts on your live selected expenses are included in the zip file. + ## The data looks wrong in Excel. How can I fix it? Long IDs may appear in scientific notation. To fix this: diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md index 5fa5a65221dc..57f951f106ac 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md @@ -1,8 +1,8 @@ --- title: How to Export Reports description: Learn how to export reports to CSV, download them as PDFs, or download their receipts. -keywords: [New Expensify, export reports, CSV export, PDF download, download report, download receipts, report receipts, receipt zip, report export template, bulk export reports, bulk PDF download, report data] -internalScope: Audience is all members. Covers exporting reports to CSV using export templates (individually and in bulk), downloading reports as PDF (individually and in bulk), and downloading the receipts on a report. Does not cover exporting individual expenses from the Expenses page (see Export-Expenses.md), exporting to an accounting integration, or company card reconciliation. +keywords: [New Expensify, export reports, CSV export, PDF download, download report, download receipts, download receipts for multiple reports, report receipts, receipt zip, report export template, bulk export reports, bulk PDF download, report data] +internalScope: Audience is all members. Covers exporting reports to CSV using export templates (individually and in bulk), downloading reports as PDF (individually and in bulk), and downloading the receipts on a report (individually and in bulk). Does not cover exporting individual expenses from the Expenses page (see Export-Expenses.md), exporting to an accounting integration, or company card reconciliation. --- # How to export reports @@ -92,6 +92,18 @@ You can download every receipt attached to a report in a single ZIP file. **Note:** **Download receipts** only appears when at least one expense on the report has a receipt. +## How to download the receipts on multiple reports + +You can download the receipts on several reports at once in a single ZIP file. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Reports**. +2. Select the checkbox next to each report whose receipts you want to download, or use the top checkbox to select all. +3. Select **Selected** at the top. +4. Select **Download receipts**. +5. Wait for the files to finish preparing. The zip file downloads automatically when it's ready. If it doesn't, select **Download file** to save it to your device. + +**Note:** **Download receipts** only appears when at least one selected report has an expense with a receipt. + --- # FAQ @@ -106,7 +118,7 @@ When you download multiple reports as PDFs, a message tells you how many of the ## Why don't I see Download receipts on a report? -**Download receipts** only appears in the **More** menu when at least one expense on the report has a receipt attached. If none of the expenses have receipts, the option is hidden. +**Download receipts** only appears when at least one expense has a receipt attached. In a single report's **More** menu, it appears when that report has an expense with a receipt. When you select multiple reports and open **Selected**, it appears only if at least one selected report has an expense with a receipt. If none of the expenses have receipts, the option is hidden. ## What happens if some receipts fail to download? diff --git a/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md index 3596a29e8288..ac3a04b64bb9 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md @@ -78,6 +78,10 @@ When you create a tracked expense, Concierge will show an actionable message wit - **Submit it to someone** – Send it for approval or reimbursement. - **Nothing for now** – Leave it stored for later. +You can also open the tracked expense and select **More** to submit it: + - **Send to someone** – Send the expense to another person to request payment. + - **Submit to my employer** – Submit the expense to a workspace for approval and reimbursement. If you do not have a workspace yet, one is created for you automatically. + If you belong to a workspace, you can also click into the expense to: - **Categorize** – Assign to a workspace category. - **Share with your accountant** – Give access to financial contacts. diff --git a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md index 8f22d1f80fcb..cd7388a513c3 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md +++ b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md @@ -233,3 +233,10 @@ If the search operator isn’t recognized, the system will ignore it and return ## Do I need to use quotes for everything? Only use quotes for values that include spaces or exact phrases, like `description:"client lunch"` or `in:"#general"`. + +## How do I find all messages from a specific person? + +You have two options: + +- Enter `type:chat from:` followed by the person's email, phone, or display name in the search bar (for example, `type:chat from:alice@acme.com`). +- Open the person's profile and select **View user history** (or **View agent history** for an agent). This opens the **Search** page filtered to that person's messages. diff --git a/docs/articles/new-expensify/settings/Personal-Karma.md b/docs/articles/new-expensify/settings/Personal-Karma.md deleted file mode 100644 index 7201d18ff105..000000000000 --- a/docs/articles/new-expensify/settings/Personal-Karma.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Personal Karma -description: Learn how Personal Karma works, how donation amounts are calculated, and how to enable or disable automatic donations to Expensify.org. -keywords: [New Expensify, personal karma, donations, expensify.org, save the world, Expensify.org donation, automatic donation, billing card charge] ---- - -# Personal Karma - -Personal Karma lets you automatically donate a small percentage of your monthly added expenses to [Expensify.org](https://www.expensify.org/about). - -For every $500 in expenses you add, $1 is donated to a related Expensify.org fund. Donations are calculated monthly and charged to the billing card on file. You'll receive a donation receipt by email after each charge. - ---- - -## How to enable Personal Karma - -1. In the navigation tabs (on the left on web, at the bottom on mobile), choose **Account**. -2. Select **Save the World**. -3. Toggle **Enable Personal Karma** on. - -If you don't have a payment card on file, you'll be prompted to add one before Personal Karma can be enabled. If you dismiss the payment card prompt without adding a card, the toggle will revert to disabled. - -Once enabled, your billing card details are displayed below the toggle. - ---- - -## How to disable Personal Karma - -1. In the navigation tabs (on the left on web, at the bottom on mobile), choose **Account**. -2. Select **Save the World**. -3. Toggle **Enable Personal Karma** off. -4. A confirmation prompt will ask if you want to stop donating to Expensify.org. Select **Disable** to confirm, or **Cancel** to keep Personal Karma enabled. - ---- - -## How Personal Karma calculates your donation amount - -Your donation is based on the total amount of expenses added during the month. - -This includes: - - Reported expenses - - Unreported expenses - - Invoice expenses - -For every $500 in total expenses, $1 is donated. For example: - - - $500 in expenses = $1 donation - - $2,500 in expenses = $5 donation - -The total donation amount is calculated at the end of each month. - ---- - -## How Expensify charges you for Personal Karma donations - -At the end of each month, Expensify calculates your total donation amount and charges your billing card on file. A donation receipt is sent to your email. - -To change the card where Personal Karma is charged, [update your Expensify billing card](https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription#how-to-update-your-expensify-billing-card). - ---- - -# FAQ - -## Does Personal Karma include unreported expenses? - -Yes. All expenses on the Expenses page — reported and unreported — are included in the monthly total used to calculate your donation. - -## Where does my donation go? - -Donations are directed to one of Expensify.org's five funds — Climate Justice, Food Security, Housing Equity, Reentry Services, or Youth Advocacy — based on the Merchant Category Code of each expense. - -## Can I choose which Expensify.org fund receives my donation? - -No. The fund is automatically selected based on the Merchant Category Code (MCC) of each expense. - -## How can I learn more about Expensify.org's fund categories? - -To learn more about Expensify.org funding categories, visit [expensify.org/funds](https://www.expensify.org/funds). diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index a4910aeeb21b..147a9e8852c2 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -1,110 +1,164 @@ --- title: Set Distance Rates -description: Set and manage distance rates for mileage reimbursement in your Expensify workspace. -keywords: [New Expensify, distance rates, mileage reimbursement, enable distance rates, workspace settings, bulk edit rates] +description: Set and manage rates for distance reimbursement in your Expensify workspace. +keywords: [New Expensify, distance rates, mileage reimbursement, enable distance rates, workspace settings, bulk edit rates, auto-update government rate, IRS standard rate, mileage rates, distance bands] +internalScope: Audience is workspace admins. Covers configuring and auto-updating distance rates, does not cover creating distance expenses. --- # Set Distance Rates -Distance rates allow your workspace to reimburse mileage-based travel. Admins can create and manage one or more rates that members can select when submitting distance expenses. +Distance rates determine how much members are reimbursed for distance-based travel. Workspace admins can create multiple rates, control when each applies, configure the associated taxes, and automatically update rates when supported governments publish new ones. --- -## How to enable Distance rates +## Who can set distance rates -To activate Distance rates for a workspace: +Workspace admins can configure distance rates for a workspace. -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name]**. -2. Click **More Features**. +Automatic updates are available for Control workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. + +--- + +## How to enable Distance rates for a workspace + +1. In the navigation tabs (on the left on web, and at the bottom on mobile), go to **Workspaces > [workspace name]**. +2. Select **More features**. 3. Toggle **Distance rates** to enable the setting. -Once enabled, **Distance Rates** will appear in the left menu. +Once enabled, **Distance rates** will appear in the workspace menu. --- -## How to add Distance rates +## How to add distance rates -1. Go to **Workspaces > [Workspace Name] > Distance Rates**. -2. Click **Add Rate** in the top-right corner. -3. Enter the rate value (e.g., $0.585 per mile or km). -4. (Optional) Set a **Start date** and **End date** to define the period the rate applies to. Leave these blank if the rate has no time limit. -5. Click **Save**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. +3. Select **Add rate**. +4. Enter the display name and rate value (e.g., $0.585 per mile or km). +5. (Optional) Set a **Start date** and **End date** to define the period the rate applies to. Leave these blank if the rate has no time limit. +6. Click **Save**. -When a rate has a **Start date** or **End date**, Expensify uses it to apply the correct rate based on the expense date. See [How Expensify selects a rate when multiple rates exist](#how-expensify-selects-a-rate-when-multiple-rates-exist) below. +When a rate has a **Start date** or **End date**, Expensify automatically applies the correct rate based on the expense date. See [How Expensify selects a rate when multiple rates exist](#How-Expensify-selects-a-Distance-rate-based-on-the-expense-date) below. --- -## How to edit Distance rate settings +## How to track tax on distance rates + +To track taxes on distance rates, **Taxes** must be enabled on the workspace.[Learn how to track taxes](/articles/new-expensify/workspaces/Track-Taxes). + +To enable **Track tax** for distance rates: + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. +3. Select **Settings**. +4. Enable **Track tax** -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name]**. -2. Click **Distance rates**. -3. Click on the rate you want to edit. +Once **Track tax** is enabled, you can assign a tax rate and tax reclaimable amount to an existing distance rate. The selected tax rate is automatically applied when the distance rate is used. + +**Note:** You can't assign a tax rate when creating a distance rate. Create the distance rate first, then edit it to configure the tax rate and tax reclaimable amount. + +## How to change a distance rate, effective dates, or tax rate + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. +3. Select the rate you want to edit. 4. Update any of the available settings: - - Name - - Rate + - **Name** + - **Rate** - **Start date** - **End date** - - Tax rate - - Tax reclaimable amount -5. Click **Save**. - -**Note:** If **Taxes** is enabled on the Workspace, each Distance rate can be associated with a tax rate and a tax reclaimable amount. The selected tax rate is automatically applied when the Distance rate is used on an expense. To track **Taxes** on a Workspace, [learn how to track Taxes](/articles/new-expensify/workspaces/Track-Taxes). + - **Tax rate** (if enabled) + - **Tax reclaimable** (if enabled) +5. Select **Save**. --- -## How to remove Distance rates +## How to disable or delete distance rates -To prevent members from using a Distance rate, you can either disable it or delete it. +To prevent members from using a distance rate, you can either disable it or delete it. -- Disabled rates remain in the Workspace but cannot be selected on new Distance expenses. +- Disabled rates remain in the workspace but cannot be selected on new distance expenses. - Deleted rates are permanently removed. -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name]**. -2. Click **Distance rates**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. 3. Select each rate you want to disable or delete. -4. Click **Selected** +4. Choose **Selected**. 5. Choose **Disable rates** or **Delete rates**. --- -# FAQ +## How to automatically update distance rates when government rates change + +Expensify can automatically update distance rates in your workspace when the standard government reimbursement rate changes, such as the IRS standard mileage rate in the United States or equivalent rates in supported countries. -## Can I create multiple Distance rates? +When **Auto-update government rate** is enabled, Expensify adds a new effective-dated distance rate when a supported government publishes a new rate. This means admins don't need to manually update the standard rate each time it changes. -Yes. A Workspace can have multiple active Distance rates. Members can select from the available rates when creating a Distance expense. +Automatic updates are available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. -## Can I disable a Distance rate without deleting it? +To enable automatic updates: -Yes. Disabled Distance rates remain in the Workspace but cannot be selected on new Distance expenses. You can re-enable the rate at any time. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. +3. Select **Settings**. +4. Enable **Auto-update government rate**. -## Can I apply taxes to Distance rates? +--- -Yes. If **Taxes** is enabled on the Workspace, you can associate a tax rate with each Distance rate. When a member selects that Distance rate on an expense, the associated tax rate is applied automatically. +## What happens when Expensify automatically updates a distance rate -## What is the Tax reclaimable amount? +When **Auto-update government rate** is enabled and a new supported government rate takes effect: -The **Tax reclaimable** amount represents the portion of the Distance rate that can be reclaimed as tax. It is entered as a fixed monetary value for the Distance rate. +- Expensify adds a new rate with a **Start date** matching the date the new rate takes effect. +- Previous rates remain available so historical expenses can retain the rate that applied on their expense date. +- If the previous government rate does not have an **End date**, Expensify sets its **End date** to the day before the new rate takes effect. -## What happens if I delete a Distance rate? +Workspace admins can still edit or disable an automatically added rate. -Deleted Distance rates are permanently removed from the Workspace and cannot be restored. If you want to prevent members from using a rate without removing it permanently, disable the rate instead. +![Distance rates showing automatically created government rates]({{site.url}}/assets/images/Distance_rates_automatic_update.png){:width="100%"} -## Can I change the reimbursement rate for existing Distance expenses? +--- -No. Updating a Distance rate only affects future Distance expenses. Existing expenses keep the rate that was applied when the expense was created. +## How Expensify selects a distance rate based on the expense date -## Do I need at least one Distance rate? +When a workspace has more than one distance rate, Expensify uses effective dates to determine which rate applies to an expense. This allows a workspace to keep multiple rates active, such as a previous year's rate and a new rate. -Yes. When **Distance Rates** is enabled, the Workspace must always have at least one active Distance rate. + - When a member creates a distance expense, Expensify selects the rate whose **Start date** and **End date** include the expense date, using the most narrow and most recent date range if multiple apply, + - If a member manually selects a rate that isn't valid for the expense date, the expense shows a violation indicating that the rate doesn't match the selected date. + - Setting effective dates is optional. Rates without a **Start date** or **End date** continue to apply without an effective-date restriction. --- -## How Expensify Selects a Rate When Multiple Rates Exist +# FAQ + +## Can I create multiple distance rates? + +Yes. A workspace can have multiple active distance rates. Members can select from the available rates when creating a distance expense. + +## Can I disable a distance rate without deleting it? + +Yes. Disabled distance rates remain in the workspace but cannot be selected on new distance expenses. You can re-enable the rate at any time. + +## Can I apply taxes to distance rates? + +Yes. **Taxes** must be enabled on the workspace, and **Track tax** must be enabled for **Distance rates**. You can then edit an existing distance rate to assign a tax rate and tax reclaimable amount. The selected tax rate is automatically applied when the distance rate is used. + +## What does Tax reclaimable mean for a distance rate? + +**Tax reclaimable** represents the portion of the distance rate that can be reclaimed as tax. It is entered as a fixed monetary value for the distance rate. + +## What happens if I delete a distance rate? + +Deleted distance rates are permanently removed from the workspace and cannot be restored. If you want to prevent members from using a rate without removing it permanently, disable the rate instead. + +## Can I change the reimbursement rate for existing distance expenses? + +No. Updating a distance rate only affects future distance expenses. Existing expenses keep the rate that was applied when the expense was created. -When a Workspace has more than one distance rate, Expensify automatically applies the rate that matches the expense date. This lets you keep multiple rates active at once — for example, last year's mileage rate and this year's updated rate. +## Do I need at least one distance rate? -- When a member creates or edits a Distance expense, Expensify selects the rate whose **Start date** and **End date** range includes the expense date. -- If a member manually selects a rate that isn't valid for the expense date, the expense shows a violation indicating the rate doesn't match the selected date. This is informational and does not block submission. +Yes. When **Distance rates** is enabled, the workspace must always have at least one active distance rate. -**Note:** Setting effective dates is optional. If your rates don't have **Start date** or **End date** values, Expensify continues to apply rates as before. +## Why isn't Auto-update government rate turned on for my workspace? +**Auto-update government rate** is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. diff --git a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md index ddffe6fa8c86..05122441c811 100644 --- a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md +++ b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md @@ -1,7 +1,7 @@ --- title: Configuring Booking Restrictions description: Learn how travel admins can restrict travel bookings in Expensify by user, country, or legal entity. -keywords: [booking restrictions, restrict travel, block countries, user restrictions, legal entity, expensify travel, classic, new expensify] +keywords: [booking restrictions, restrict travel, block countries, user restrictions, legal entity, expensify travel, classic, new expensify, pilot, rollout] --- @@ -72,6 +72,17 @@ Restrictions will block travelers from booking specific types of travel. If a re --- +## Use booking restrictions for a phased rollout or pilot + +Booking restrictions let you test Expensify Travel with a small group before rolling it out to the whole company: + +1. Make sure Expensify Travel is enabled on your Workspace. If it isn't, reach out to your Account Manager or Concierge to get it enabled. +2. Create a user restriction that includes everyone **except** your test group. For larger companies, you can restrict by legal entity instead of adding members one by one. Only do this if your test group belongs to a separate legal entity that isn't part of the restriction. If your pilot members share a legal entity with the rest of the company, select the non-pilot members individually so your test group isn't blocked. +3. Add a reason, such as *"Expensify Travel is in a limited test. Please continue using your current travel booking process."* Restricted members see this message if they attempt to book. +4. When testing is complete, click **Delete** to remove the restriction. All members can book immediately. + +--- + # FAQ ## Can I apply restrictions to just one travel type? diff --git a/docs/assets/images/Distance_rates_automatic_update.png b/docs/assets/images/Distance_rates_automatic_update.png new file mode 100644 index 000000000000..a39d7586d5eb Binary files /dev/null and b/docs/assets/images/Distance_rates_automatic_update.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 374b733d9a57..0eca599b22c1 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -1017,5 +1017,7 @@ https://help.expensify.com/articles/new-expensify/expensify-card/Set-Up-and-Mana https://help.expensify.com/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-or-Google-Pay,https://help.expensify.com/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet https://help.expensify.com/articles/expensify-classic/settings/Magic-Code-Access-Troubleshooting,https://help.expensify.com/articles/expensify-classic/settings/Security-Code-Access-Troubleshooting https://help.expensify.com/Hidden/issues-receiving-the-magic-code,https://help.expensify.com/Hidden/issues-receiving-the-security-code +https://help.expensify.com/articles/new-expensify/settings/Personal-Karma,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma +https://help.expensify.com/articles/new-expensify/connections/Greenhouse,https://help.expensify.com/docs/Hidden/Greenhouse https://help.expensify.com/articles/Unlisted/Configure-Rillet,https://help.expensify.com/new-expensify/connections/rillet/Configure-Rillet https://help.expensify.com/articles/Unlisted/Connect-to-Rillet,https://help.expensify.com/articles/new-expensify/connections/rillet/Connect-to-Rillet diff --git a/eslint-plugin-local-rules/require-locale-for-localized-date-format.js b/eslint-plugin-local-rules/require-locale-for-localized-date-format.js index 1e45145443c8..6313f1cfa7f5 100644 --- a/eslint-plugin-local-rules/require-locale-for-localized-date-format.js +++ b/eslint-plugin-local-rules/require-locale-for-localized-date-format.js @@ -55,7 +55,16 @@ const DATE_FNS_MODULES = new Set(['date-fns', 'date-fns-tz']); * `CONST.DATE.*` formats with no language-dependent tokens. Anything else in `CONST.DATE` is treated as localized, so a * newly added format is guarded by default rather than silently escaping this rule. */ -const MACHINE_DATE_CONSTANTS = new Set(['FNS_FORMAT_STRING', 'FNS_DATE_TIME_FORMAT_STRING', 'FNS_DB_FORMAT_STRING', 'FNS_TIMEZONE_FORMAT_STRING', 'YEAR_MONTH_FORMAT', 'SHORT_DATE_FORMAT']); +const MACHINE_DATE_CONSTANTS = new Set([ + 'FNS_FORMAT_STRING', + 'FNS_DATE_TIME_FORMAT_STRING', + 'FNS_DB_FORMAT_STRING', + 'FNS_TIMEZONE_FORMAT_STRING', + 'YEAR_MONTH_FORMAT', + 'SHORT_DATE_FORMAT', + 'LOCAL_TIME_FORMAT_WITHOUT_PERIOD', + 'TIME_FORMAT_WITHOUT_PERIOD', +]); /** * Strips the single-quoted escaped literals date-fns supports (e.g. the "T" in `yyyy-MM-dd'T'HH:mm`) diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 51d4e4a43e73..6858ef3e0c73 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.4.61 + 9.4.63 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.61.0 + 9.4.63.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 18ab69f3f3d8..b0f75221f069 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.61 + 9.4.63 CFBundleVersion - 9.4.61.0 + 9.4.63.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 1332dda6eaef..d2fe52c28c7a 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.61 + 9.4.63 CFBundleVersion - 9.4.61.0 + 9.4.63.0 NSExtension NSExtensionAttributes diff --git a/jest/setup.ts b/jest/setup.ts index 6e63c1ea3015..3a6464839967 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -131,6 +131,9 @@ jest.mock('react-native-share', () => ({ jest.mock('react-native-reanimated', () => ({ ...jest.requireActual('react-native-reanimated/mock'), createAnimatedPropAdapter: jest.fn, + // react-native-reanimated/mock leaves dispatchCommand out (see its own "ADD ME IF NEEDED" comment). forceClearInput + // (src/libs/ComponentUtils) dispatches it from a UI-thread worklet, so any test exercising that path needs it mocked. + dispatchCommand: jest.fn(), useReducedMotion: jest.fn, useScrollViewOffset: jest.fn(() => 0), useAnimatedRef: jest.fn(() => jest.fn()), diff --git a/package-lock.json b/package-lock.json index 3f3f478bff99..41eb41342f16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.61-0", + "version": "9.4.63-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.61-0", + "version": "9.4.63-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 6acfa79cd244..071d9c16c201 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.61-0", + "version": "9.4.63-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/react-native-fs/details.md b/patches/react-native-fs/details.md new file mode 100644 index 000000000000..2e623c0ee25b --- /dev/null +++ b/patches/react-native-fs/details.md @@ -0,0 +1,17 @@ +# `react-native-fs` patches + +### [react-native-fs+2.20.0+001+encode-file-uris.patch](react-native-fs+2.20.0+001+encode-file-uris.patch) + +- Reason: + + ``` + `getFileUri` built a file URI by concatenation, `Uri.parse("file://" + filepath)`. `Uri.parse` reads URI syntax in the result, so a "#" in the path starts a fragment and drops everything after it, and a "?" starts a query. This patch calls `Uri.fromFile` instead, which percent-encodes the path first. + + Every RNFS call that opens a stream goes through `getFileUri`: readFile, writeFile, appendFile, copyFile, and the cross-filesystem fallback inside moveFile. On Android the receipts folder sits on external storage while picked files land in the app cache, so moveFile's rename fails and that fallback always runs. A receipt whose filename carried a "#" stayed in the app cache instead of reaching Documents/Receipts-Upload. + + Delete this patch when react-native-blob-util replaces react-native-fs. + ``` + +- Upstream PR/issue: 🛑, `itinance/react-native-fs` is unmaintained. Last pushed 2024-03-18, 630 open issues, and npm `latest` is still the 2.20.0 we depend on, which does not carry this fix. Maintained forks do carry it, but they ship under their own package names, so there is no version of `react-native-fs` to upgrade to. +- E/App issue: https://github.com/Expensify/App/issues/99350 +- PR introducing patch: https://github.com/Expensify/App/pull/99333 diff --git a/patches/react-native-fs/react-native-fs+2.20.0+001+encode-file-uris.patch b/patches/react-native-fs/react-native-fs+2.20.0+001+encode-file-uris.patch new file mode 100644 index 000000000000..10bf410586ea --- /dev/null +++ b/patches/react-native-fs/react-native-fs+2.20.0+001+encode-file-uris.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-fs/android/src/main/java/com/rnfs/RNFSManager.java b/node_modules/react-native-fs/android/src/main/java/com/rnfs/RNFSManager.java +index 351ac06..feabc38 100755 +--- a/node_modules/react-native-fs/android/src/main/java/com/rnfs/RNFSManager.java ++++ b/node_modules/react-native-fs/android/src/main/java/com/rnfs/RNFSManager.java +@@ -80,7 +80,7 @@ public class RNFSManager extends ReactContextBaseJavaModule { + if (!isDirectoryAllowed && file.isDirectory()) { + throw new IORejectionException("EISDIR", "EISDIR: illegal operation on a directory, read '" + filepath + "'"); + } +- uri = Uri.parse("file://" + filepath); ++ uri = Uri.fromFile(file); + } + return uri; + } diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 701529ddf2e5..afa1889ec2fb 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -615,6 +615,8 @@ const CONST = { FNS_FORMAT_STRING: 'yyyy-MM-dd', FNS_DATE_TIME_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss', LOCAL_TIME_FORMAT: 'h:mm a', + LOCAL_TIME_FORMAT_WITHOUT_PERIOD: 'h:mm', + TIME_FORMAT_WITHOUT_PERIOD: 'hh:mm', YEAR_MONTH_FORMAT: 'yyyyMM', MONTH_FORMAT: 'MMMM', WEEKDAY_TIME_FORMAT: 'eeee', @@ -758,6 +760,7 @@ const CONST = { // The back-end is always returning account number with 4 last digits and mask the rest with X MASKED_US_ACCOUNT_NUMBER: /^[X]{0,13}[0-9]{4}$/, SWIFT_BIC: /^[A-Za-z0-9]{8,11}$/, + IBAN: /^[a-zA-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}([a-zA-Z0-9]?){0,16}$/, }, STATE: { VERIFYING: 'VERIFYING', @@ -1022,6 +1025,7 @@ const CONST = { INTUIT_ENTERPRISE_SUITE: 'intuitEnterpriseSuite', RULES_REVAMP: 'rulesRevamp', COMMUTER_EXCLUSIONS: 'commuterExclusions', + MULTIPLE_APPROVERS: 'multipleApprovers', GLOBAL_REIMBURSEMENTS: 'globalReimbursements', GLOBAL_REIMBURSEMENT_FX: 'globalReimbursementFX', DEFAULT_LETTER_AVATARS: 'defaultLetterAvatars', @@ -1678,6 +1682,7 @@ const CONST = { REIMBURSEMENT_SETUP: 'REIMBURSEMENTSETUP', // Deprecated OldDot Action REIMBURSEMENT_SETUP_REQUESTED: 'REIMBURSEMENTSETUPREQUESTED', // Deprecated OldDot Action REIMBURSEMENT_DIRECTOR_INFORMATION_REQUIRED: 'DIRECTORINFORMATIONREQUIRED', + HOME_ADDRESS_REQUIRED: 'HOMEADDRESSREQUIRED', REJECTED: 'REJECTED', REJECTED_TO_SUBMITTER: 'REJECTEDTOSUBMITTER', REMOVED_FROM_APPROVAL_CHAIN: 'REMOVEDFROMAPPROVALCHAIN', @@ -2255,7 +2260,6 @@ const CONST = { SPAN_EXPENSE_SERVER_RESPONSE: 'ManualCreateExpenseServerResponse', SPAN_RECONNECT_SERVER_RESPONSE: 'ManualReconnectServerResponse', SPAN_GEOLOCATION_WAIT: 'ManualGeolocationWait', - SPAN_SEND_MESSAGE: 'ManualSendMessage', SPAN_SEND_MESSAGE_VISIBLE: 'ManualSendMessageVisible', SPAN_NOT_FOUND_PAGE: 'ManualNotFoundPage', SPAN_ODOMETER_TO_CONFIRMATION: 'ManualOdometerToConfirmation', @@ -3682,6 +3686,7 @@ const CONST = { SUB_PAGE_NAMES: { PLAID_BANK_ACCOUNT: 'plaid', MANUAL_BANK_ACCOUNT_DETAILS: 'manual', + INTERNATIONAL_BANK_ACCOUNT_DETAILS: 'international-bank-account-details', LEGAL_NAME: 'legal-name', ADDRESS: 'address', PHONE_NUMBER: 'phone-number', @@ -4372,7 +4377,7 @@ const CONST = { }, COMMUTER_EXCLUSION_METHOD: { FIXED_DISTANCE: 'fixedDistance', - // R2 will add HOME_AND_OFFICE: 'homeAndOffice' + HOME_AND_OFFICE: 'homeAndOffice', }, // Upper bound for the commuter exclusion fixed distance. Matches the length of the longest road in the world COMMUTER_EXCLUSION_MAX_DISTANCE: 19000, @@ -8151,6 +8156,22 @@ const CONST = { }, }, + RULES: { + SCOPE: { + POLICY: 'policy', + }, + APPROVAL_WORKFLOW: { + TRIGGER: { + REPORT_SUBMIT: 'ReportSubmit', + REPORT_APPROVE: 'ReportApprove', + }, + ACTION: { + FORWARD_TO: 'ForwardTo', + APPROVE_REPORT: 'ApproveReport', + }, + }, + }, + BOOT_SPLASH_STATE: { VISIBLE: 'visible', READY_TO_BE_HIDDEN: 'readyToBeHidden', @@ -8317,6 +8338,8 @@ const CONST = { EXCLUDED_CURRENCIES: ['IRR', 'CUP', 'SYP', 'UAH', 'KPW', 'RUB'] as string[], ACCOUNT_TYPE_KEY: 'BeneficiaryAccountType', ACCOUNT_HOLDER_COUNTRY_KEY: 'accountHolderCountry', + IBAN_LABEL_KEYWORD: 'iban', + SWIFT_LABEL_KEYWORD: 'swift', BANK_INFORMATION_FIELDS: ['bankName', 'bankAddressLine1', 'bankAddressLine2', 'bankCity', 'bankRegion', 'bankPostal', 'BeneficiaryBankBranchName'] as string[], ACCOUNT_HOLDER_FIELDS: [ 'accountHolderName', @@ -8339,6 +8362,7 @@ const CONST = { PAGE_NAME: { COUNTRY: 'country', ACCOUNT_DETAILS: 'account-details', + INTERNATIONAL_BANK_ACCOUNT_DETAILS: 'international-bank-account-details', ACCOUNT_TYPE: 'account-type', BANK_INFORMATION: 'bank-information', ACCOUNT_HOLDER_DETAILS: 'account-holder-details', @@ -8349,11 +8373,12 @@ const CONST = { MAPPING: { COUNTRY_SELECTOR: 0, BANK_ACCOUNT_DETAILS: 1, - ACCOUNT_TYPE: 2, - BANK_INFORMATION: 3, - ACCOUNT_HOLDER_INFORMATION: 4, - CONFIRMATION: 5, - SUCCESS: 6, + INTERNATIONAL_BANK_ACCOUNT_DETAILS: 2, + ACCOUNT_TYPE: 3, + BANK_INFORMATION: 4, + ACCOUNT_HOLDER_INFORMATION: 5, + CONFIRMATION: 6, + SUCCESS: 7, }, }, }, diff --git a/src/CONST/runtimeConfigured.ts b/src/CONST/runtimeConfigured.ts index 9d28e4e51bcb..070d61bdf70b 100644 --- a/src/CONST/runtimeConfigured.ts +++ b/src/CONST/runtimeConfigured.ts @@ -73,7 +73,7 @@ const CONST_RUNTIME: ConstRuntime = { SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT, SCREENS.SUBMIT_PLAN_WELCOME_MODAL.DYNAMIC_ROOT, SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT, - SCREENS.MONEY_REQUEST.STEP_SCAN, + SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION), ], diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 88e502dcc567..63413dfe3931 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -468,6 +468,13 @@ const ONYXKEYS = { /** Set when we are loading the bulk change approver page */ IS_LOADING_BULK_CHANGE_APPROVER_PAGE: 'isLoadingBulkChangeApproverPage', + /** + * Set when we are loading the reimbursement countries used to decide whether to collect international deposit + * details. RAM-only so an interrupted request never persists a stuck `true` and leaves the deposit account flow + * showing a loading state on the next launch. + */ + RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP: 'isLoadingDepositAccountSetup', + /** Is report data loading? */ IS_LOADING_APP: 'isLoadingApp', @@ -733,6 +740,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /** Draft message typed into the Concierge prompt box on the home page */ + CONCIERGE_PROMPT_DRAFT: 'conciergePromptDraft', + /** The user's Self DM reportID */ SELF_DM_REPORT_ID: 'selfDMReportID', @@ -885,6 +895,7 @@ const ONYXKEYS = { POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED: 'policyHasConnectionsDataBeenFetched_', POLICY_CONNECTION_SYNC_PROGRESS: 'policyConnectionSyncProgress_', POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN: 'policyMergeHRInitialSyncModalShown_', + RULE: 'rules_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', WORKSPACE_INVITE_ROLE_DRAFT: 'workspaceInviteRoleDraft_', @@ -1472,6 +1483,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD]: OnyxTypes.ExportDownload; [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; [ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy; + [ONYXKEYS.COLLECTION.RULE]: OnyxTypes.Rule; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagLists; @@ -1685,6 +1697,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_PAYMENT_METHODS]: boolean; [ONYXKEYS.IS_LOADING_SHARE_BANK_ACCOUNTS]: boolean; [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; + [ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; [ONYXKEYS.IS_LOADING_AGENT_TEMPLATES]: boolean; @@ -1788,6 +1801,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_THINKING_KICKOFF]: boolean; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.CONCIERGE_PROMPT_DRAFT]: string; [ONYXKEYS.SELF_DM_REPORT_ID]: string; [ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS]: Participant; [ONYXKEYS.SHARE_TEMP_FILE]: OnyxTypes.ShareTempFile; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f8e54cd28e95..570e7940880e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -175,11 +175,15 @@ const DYNAMIC_ROUTES = { }, WORKSPACE_CONFIRMATION_CURRENCY: { path: 'currency', - entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_UPGRADE], + entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_UPGRADE], }, WORKSPACE_CONFIRMATION_PLAN_TYPE: { path: 'plan-type', - entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_UPGRADE], + entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_UPGRADE], + }, + TRAVEL_WORKSPACE_CONFIRMATION: { + path: 'workspace-confirmation', + entryScreens: [SCREENS.TRAVEL.DYNAMIC_UPGRADE], }, MIGRATED_USER_WELCOME: { path: 'migrated-user-welcome', @@ -285,7 +289,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, ], getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string) => { @@ -306,7 +310,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, ], getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string) => { @@ -327,7 +331,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, SCREENS.WORKSPACE.CREATE_DISTANCE_RATE_UPGRADE, @@ -375,7 +379,7 @@ const DYNAMIC_ROUTES = { }, MONEY_REQUEST_RECEIPT_VIEW: { path: 'receipt-view', - entryScreens: [SCREENS.MONEY_REQUEST.CREATE, SCREENS.MONEY_REQUEST.STEP_SCAN], + entryScreens: [SCREENS.MONEY_REQUEST.CREATE, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN], getRoute: (transactionID: string) => getUrlWithParams('receipt-view', {transactionID}), queryParams: ['transactionID'], }, @@ -388,15 +392,17 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, ], - // `splitReportID` is deliberately not named `reportID` so it cannot inherit the base path's report: + // `splitReportID` is deliberately not named `reportID` so it cannot inherit the base path's report. + // `originalTransactionID` is deliberately not named `transactionID` so it cannot collide with the + // `transactionID` query param used by the expense step screens nested under this dynamic route. getRoute: (splitReportID: string | undefined, originalTransactionID: string | undefined, splitExpenseTransactionID?: string) => { if (!splitReportID || !originalTransactionID) { Log.warn(`Invalid ${splitReportID}(reportID) or ${originalTransactionID}(transactionID) is used to build the MONEY_REQUEST_SPLIT_EXPENSE route`); } - return getUrlWithParams('split-expense-overview', {splitReportID, transactionID: originalTransactionID, splitExpenseTransactionID}); + return getUrlWithParams('split-expense-overview', {splitReportID, originalTransactionID, splitExpenseTransactionID}); }, - queryParams: ['splitReportID', 'transactionID', 'splitExpenseTransactionID'], + queryParams: ['splitReportID', 'originalTransactionID', 'splitExpenseTransactionID'], }, MONEY_REQUEST_SPLIT_EXPENSE_SEARCH: { path: 'split-expense-overview-search', @@ -412,12 +418,12 @@ const DYNAMIC_ROUTES = { Log.warn(`Invalid ${splitReportID}(reportID) or ${originalTransactionID}(transactionID) is used to build the MONEY_REQUEST_SPLIT_EXPENSE_SEARCH route`); } - return getUrlWithParams('split-expense-overview-search', {splitReportID, transactionID: originalTransactionID, splitExpenseTransactionID}); + return getUrlWithParams('split-expense-overview-search', {splitReportID, originalTransactionID, splitExpenseTransactionID}); }, - queryParams: ['splitReportID', 'transactionID', 'splitExpenseTransactionID'], + queryParams: ['splitReportID', 'originalTransactionID', 'splitExpenseTransactionID'], }, MONEY_REQUEST_SPLIT_EXPENSE_CREATE_DATE_RANGE: { - // No query params of its own: `splitReportID`/`transactionID` are inherited from the overview screen underneath. + // No query params of its own: `splitReportID`/`originalTransactionID` are inherited from the overview screen underneath. path: 'split-expense-create-date-range', entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE, SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_SEARCH], }, @@ -432,13 +438,25 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, ], // `orderWeight` is stringified explicitly because the first tag list has index 0, which `getUrlWithParams` would drop as falsy. getRoute: (action: IOUAction, iouType: IOUType, orderWeight: number, transactionID: string, reportID?: string, reportActionID?: string) => getUrlWithParams('expense-tag', {action, iouType, orderWeight: String(orderWeight), transactionID, reportID, reportActionID}), queryParams: ['action', 'iouType', 'orderWeight', 'transactionID', 'reportID', 'reportActionID'], }, + SPLIT_EXPENSE_EDIT: { + // `editSplitExpenseTransactionID` is deliberately not named `splitExpenseTransactionID` so this path param cannot be + // shadowed by the overview route's `splitExpenseTransactionID` query param when this route is nested under it. + path: 'split-expense/edit/:reportID/:editSplitExpenseTransactionID?', + entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE, SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_SEARCH], + getRoute: (reportID: string, editSplitExpenseTransactionID?: string) => { + if (!editSplitExpenseTransactionID) { + return `split-expense/edit/${reportID}` as const; + } + return `split-expense/edit/${reportID}/${editSplitExpenseTransactionID}` as const; + }, + }, MONEY_REQUEST_STEP_MERCHANT: { path: 'expense-merchant', entryScreens: [ @@ -467,7 +485,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, ], getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string) => @@ -484,7 +502,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, ], getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string) => @@ -511,6 +529,34 @@ const DYNAMIC_ROUTES = { }, queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'isWorkspacesOnly'], }, + MONEY_REQUEST_STEP_SCAN: { + path: 'expense-scan', + entryScreens: [ + SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, + SCREENS.MONEY_REQUEST.CREATE, + SCREENS.SHARE.SUBMIT_DETAILS, + SCREENS.REPORT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, + SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, + ], + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined) => { + if (!transactionID || !reportID) { + Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_SCAN route'); + } + + return getUrlWithParams('expense-scan', {action, iouType, transactionID, reportID}); + }, + queryParams: ['action', 'iouType', 'transactionID', 'reportID'], + }, + MONEY_REQUEST_STEP_WAYPOINT: { + path: 'expense-waypoint', + entryScreens: [SCREENS.MONEY_REQUEST.CREATE, SCREENS.MONEY_REQUEST.DISTANCE_CREATE, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DISTANCE], + getRoute: (pageIndex: number) => getUrlWithParams('expense-waypoint', {pageIndex: String(pageIndex)}), + queryParams: ['pageIndex'], + }, MONEY_REQUEST_STEP_DESTINATION: { path: 'per-diem-destination', entryScreens: [SCREENS.MONEY_REQUEST.CREATE], @@ -561,7 +607,7 @@ const DYNAMIC_ROUTES = { SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.WORKSPACE_CONFIRMATION.OWNER_SELECTOR, SCREENS.WORKSPACE_DUPLICATE.ROOT, - SCREENS.TRAVEL.WORKSPACE_CONFIRMATION, + SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_UPGRADE, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, ], @@ -1374,6 +1420,48 @@ const DYNAMIC_ROUTES = { path: 'travel-upgrade', entryScreens: [SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, SCREENS.SEARCH.ROOT], }, + TRAVEL_TRIP_DETAILS: { + path: 'trip-details/:reportID/:transactionID/:pnr/:sequenceIndex', + entryScreens: [ + SCREENS.REPORT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.HOME, + SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_CONFIRMATION, + SCREENS.MERGE_TRANSACTION.DYNAMIC_CONFIRMATION_PAGE, + SCREENS.TRAVEL.DYNAMIC_TRIP_SUMMARY, + ], + getRoute: (reportID: string | undefined, transactionID: string | undefined, pnr: string | undefined, sequenceIndex: number) => { + if (!reportID || !transactionID || !pnr) { + Log.warn('Invalid reportID, transactionID or pnr is used to build the TRAVEL_TRIP_DETAILS route'); + } + return `trip-details/${reportID}/${transactionID}/${pnr}/${sequenceIndex}` as const; + }, + }, + TRAVEL_TRIP_SUMMARY: { + path: 'trip-summary/:reportID/:transactionID', + entryScreens: [ + SCREENS.REPORT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_CONFIRMATION, + SCREENS.MERGE_TRANSACTION.DYNAMIC_CONFIRMATION_PAGE, + ], + getRoute: (reportID: string | undefined, transactionID: string | undefined) => { + if (!reportID || !transactionID) { + Log.warn('Invalid reportID or transactionID is used to build the TRAVEL_TRIP_SUMMARY route'); + } + return `trip-summary/${reportID}/${transactionID}` as const; + }, + }, + TRAVEL_VERIFY_ACCOUNT: { + path: 'travel-verify-account', + entryScreens: [SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, SCREENS.SEARCH.ROOT, SCREENS.TRAVEL.ENABLE], + getRoute: (policyID?: string) => getUrlWithParams('travel-verify-account', {policyID}), + queryParams: ['policyID'], + }, REPORT_CHANGE_APPROVER: { path: 'change-approver', entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], @@ -1716,7 +1804,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SHARE.SUBMIT_DETAILS, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, ], getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string | undefined) => getUrlWithParams('taxRate', {action, iouType, transactionID, reportID}), queryParams: ['action', 'iouType', 'transactionID', 'reportID'], @@ -1754,7 +1842,7 @@ const DYNAMIC_ROUTES = { SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, SCREENS.MONEY_REQUEST.CREATE, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_PARTICIPANTS, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.REPORT, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, @@ -2437,16 +2525,6 @@ const ROUTES = { route: 'r/:reportID/chronos/schedule-ooo', getRoute: (reportID: string) => `r/${reportID}/chronos/schedule-ooo` as const, }, - SPLIT_EXPENSE_EDIT: { - route: 'edit/split-expense/overview/:reportID/:transactionID/:splitExpenseTransactionID?', - getRoute: (reportID: string | undefined, originalTransactionID: string | undefined, splitExpenseTransactionID?: string, backTo?: string) => { - if (!reportID || !originalTransactionID) { - Log.warn(`Invalid ${reportID}(reportID) or ${originalTransactionID}(transactionID) is used to build the SPLIT_EXPENSE_EDIT route`); - } - - return getUrlWithBackToParam(`edit/split-expense/overview/${reportID}/${originalTransactionID}${splitExpenseTransactionID ? `/${splitExpenseTransactionID}` : ''}`, backTo); - }, - }, MONEY_REQUEST_CREATE: { route: ':action/:iouType/start/:transactionID/:reportID/:backToReport?', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => { @@ -2603,21 +2681,6 @@ const ROUTES = { return getUrlWithBackToParam(`${action as string}/${iouType as string}/distanceRate/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo); }, }, - MONEY_REQUEST_STEP_SCAN: { - route: ':action/:iouType/scan/:transactionID/:reportID', - getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '') => { - if (!transactionID || !reportID) { - Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_SCAN route'); - } - - return getUrlWithBackToParam(`${action as string}/${iouType as string}/scan/${transactionID}/${reportID}`, backTo); - }, - }, - MONEY_REQUEST_STEP_WAYPOINT: { - route: ':action/:iouType/waypoint/:transactionID/:reportID/:pageIndex', - getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID?: string, pageIndex = '', backTo = '') => - getUrlWithBackToParam(`${action as string}/${iouType as string}/waypoint/${transactionID}/${reportID}/${pageIndex}`, backTo), - }, // This URL is used as a redirect to one of the create tabs below. This is so that we can message users with a link // straight to those flows without needing to have optimistic transaction and report IDs. MONEY_REQUEST_START: { @@ -2978,7 +3041,8 @@ const ROUTES = { }, WORKSPACE_WORKFLOWS_APPROVALS_EDIT: { route: 'workspaces/:policyID/workflows/approvals/:firstApproverEmail/edit', - getRoute: (policyID: string, firstApproverEmail: string) => `workspaces/${policyID}/workflows/approvals/${encodeURIComponent(firstApproverEmail)}/edit` as const, + getRoute: (policyID: string, firstApproverEmail: string, memberEmail?: string) => + `workspaces/${policyID}/workflows/approvals/${encodeURIComponent(firstApproverEmail)}/edit${memberEmail ? `?memberEmail=${encodeURIComponent(memberEmail)}` : ''}` as const, }, WORKSPACE_WORKFLOWS_APPROVALS_APPROVER: { route: 'workspaces/:policyID/workflows/approvals/approver', @@ -3922,26 +3986,6 @@ const ROUTES = { }, }, TRACK_TRAINING_MODAL: 'track-training', - TRAVEL_TRIP_SUMMARY: { - route: 'r/:reportID/trip/:transactionID', - getRoute: (reportID: string | undefined, transactionID: string | undefined, backTo?: string) => { - if (!reportID || !transactionID) { - Log.warn('Invalid reportID or transactionID is used to build the TRAVEL_TRIP_SUMMARY route'); - } - - return getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}`, backTo); - }, - }, - TRAVEL_TRIP_DETAILS: { - route: 'r/:reportID/trip/:transactionID/:pnr/:sequenceIndex', - getRoute: (reportID: string | undefined, transactionID: string | undefined, pnr: string | undefined, sequenceIndex: number, backTo?: string) => { - if (!reportID || !transactionID || !pnr) { - Log.warn('Invalid reportID, transactionID or pnr is used to build the TRAVEL_TRIP_DETAILS route'); - } - - return getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}/${pnr}/${sequenceIndex}`, backTo); - }, - }, TRAVEL_WORKSPACE_CONFIRMATION: { route: 'travel/upgrade/workspace/confirmation', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 014a36128658..fb115ea7c93f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -32,12 +32,12 @@ const SCREENS = { MY_TRIPS: 'Travel_MyTrips', DYNAMIC_UPGRADE: 'Dynamic_Travel_Upgrade', DYNAMIC_DOMAIN_PERMISSION_INFO: 'Dynamic_Travel_DomainPermissionInfo', - TRIP_SUMMARY: 'Travel_TripSummary', - TRIP_DETAILS: 'Travel_TripDetails', + DYNAMIC_TRIP_SUMMARY: 'Dynamic_Travel_TripSummary', + DYNAMIC_TRIP_DETAILS: 'Dynamic_Travel_TripDetails', DYNAMIC_PUBLIC_DOMAIN_ERROR: 'Dynamic_Travel_PublicDomainError', - WORKSPACE_CONFIRMATION: 'Travel_WorkspaceConfirmation', + DYNAMIC_WORKSPACE_CONFIRMATION: 'Dynamic_Travel_WorkspaceConfirmation', TRAVEL_DOT_LINK_WEB_VIEW: 'Travel_DotLinkWebView', - VERIFY_ACCOUNT: 'Travel_VerifyAccount', + DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Travel_VerifyAccount', ENABLE: 'Travel_Enable', MISSING_PERSONAL_DETAILS_CONFIRM_VALIDATE_CODE: 'Travel_MissingPersonalDetails_ConfirmValidateCode', }, @@ -339,9 +339,9 @@ const SCREENS = { DYNAMIC_STEP_DISTANCE_RATE: 'Dynamic_Money_Request_Step_Rate', DYNAMIC_STEP_MERCHANT: 'Dynamic_Money_Request_Step_Merchant', DYNAMIC_STEP_PARTICIPANTS: 'Dynamic_Money_Request_Step_Participants', - STEP_SCAN: 'Money_Request_Step_Scan', + DYNAMIC_STEP_SCAN: 'Dynamic_Money_Request_Step_Scan', DYNAMIC_STEP_TAG: 'Dynamic_Money_Request_Step_Tag', - STEP_WAYPOINT: 'Money_Request_Step_Waypoint', + DYNAMIC_STEP_WAYPOINT: 'Dynamic_Money_Request_Step_Waypoint', DYNAMIC_STEP_TAX_AMOUNT: 'Dynamic_Money_Request_Step_Tax_Amount', DYNAMIC_STEP_TAX_RATE: 'Dynamic_Money_Request_Step_Tax_Rate', STEP_VENDOR: 'Money_Request_Step_Vendor', @@ -365,7 +365,7 @@ const SCREENS = { DYNAMIC_SPLIT_EXPENSE: 'Dynamic_Money_Request_Split_Expense', DYNAMIC_SPLIT_EXPENSE_SEARCH: 'Dynamic_Money_Request_Split_Expense_Search', DYNAMIC_SPLIT_EXPENSE_CREATE_DATE_RANGE: 'Dynamic_Money_Request_Split_Expense_Create_Date_Range', - SPLIT_EXPENSE_EDIT: 'Money_Request_Split_Expense_Edit', + DYNAMIC_SPLIT_EXPENSE_EDIT: 'Dynamic_Money_Request_Split_Expense_Edit', DISTANCE_CREATE: 'Money_Request_Distance_Create', STEP_DISTANCE_MAP: 'Money_Request_Step_Distance_Map', DYNAMIC_STEP_DISTANCE_MANUAL: 'Dynamic_Money_Request_Step_Distance_Manual', diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx index a3b9dca157d8..ae7de9beb61e 100644 --- a/src/components/AIFeaturesPromoModal.tsx +++ b/src/components/AIFeaturesPromoModal.tsx @@ -12,11 +12,9 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import type {FeatureTrainingContentDataProps} from './FeatureTrainingContent'; - import Badge from './Badge'; import CenteredModalLayout from './CenteredModalLayout'; -import {FeatureTrainingCarousel} from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import LottieAnimations from './LottieAnimations'; import Text from './Text'; @@ -38,34 +36,6 @@ function AIFeaturesPromoModal() { ); - const pages: FeatureTrainingContentDataProps[] = [ - { - animation: LottieAnimations.SpendAnalysis, - title: translate('aiFeaturesPromoModal.spendAnalysis.title'), - subtitle: translate('aiFeaturesPromoModal.subtitle'), - description: translate('aiFeaturesPromoModal.spendAnalysis.description'), - confirmText: translate('common.next'), - }, - { - animation: LottieAnimations.ExpenseAssistant, - title: translate('aiFeaturesPromoModal.expenseAssistant.title'), - subtitle: translate('aiFeaturesPromoModal.subtitle'), - description: translate('aiFeaturesPromoModal.expenseAssistant.description'), - confirmText: canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText'), - }, - ...(canUseCustomAgent - ? [ - { - animation: LottieAnimations.CustomAgents, - title: customAgentPromoTitle, - subtitle: translate('aiFeaturesPromoModal.subtitle'), - description: translate('aiFeaturesPromoModal.customAgents.description'), - confirmText: translate('aiFeaturesPromoModal.confirmText'), - }, - ] - : []), - ]; - const dismissNVP = (isDismissedUsingCloseButton: boolean) => { dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isDismissedUsingCloseButton); }; @@ -80,26 +50,81 @@ function AIFeaturesPromoModal() { dismissNVP(true); }; + const illustrationAspectRatio = LottieAnimations.SpendAnalysis.w / LottieAnimations.SpendAnalysis.h; + const subtitle = translate('aiFeaturesPromoModal.subtitle'); + return ( - + shouldUseScrollView + > + + + + + {subtitle} + {translate('aiFeaturesPromoModal.spendAnalysis.title')} + {translate('aiFeaturesPromoModal.spendAnalysis.description')} + + + + {translate('common.next')} + + + + + + + + {subtitle} + {translate('aiFeaturesPromoModal.expenseAssistant.title')} + {translate('aiFeaturesPromoModal.expenseAssistant.description')} + + + + + {canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText')} + + + + + {canUseCustomAgent && ( + + + + + {subtitle} + {customAgentPromoTitle} + {translate('aiFeaturesPromoModal.customAgents.description')} + + + + {translate('aiFeaturesPromoModal.confirmText')} + + + + )} + ); } diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 9741f46f585b..4f2c928a9243 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -68,6 +68,12 @@ type AddressFormProps = { /** Whether the form submit button should be enabled when offline */ enabledWhenOffline?: boolean; + /** + * Whether to force the zip/postal code to be present. Workspace addresses for homeAndOffice commuter exclusions + * require a complete address + */ + shouldRequireZip?: boolean; + /** Whether PO boxes and mail drops are rejected on address lines */ shouldValidatePhysicalAddress?: boolean; @@ -94,6 +100,7 @@ function AddressForm({ zip = '', shouldHideCountrySelector = false, enabledWhenOffline: enabledWhenOfflineProp = true, + shouldRequireZip = false, shouldValidatePhysicalAddress = false, addBottomSafeAreaPadding = true, }: AddressFormProps) { @@ -121,7 +128,8 @@ function AddressForm({ const errors: Errors & { zipPostCode?: string | string[]; } = {}; - const requiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); + const baseRequiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); + const requiredFields = shouldRequireZip ? ([...baseRequiredFields, 'zipPostCode'] as const) : baseRequiredFields; // Check "State" dropdown is a valid state if selected Country is USA if (values.country === CONST.COUNTRY.US && !values.state) { @@ -187,7 +195,7 @@ function AddressForm({ return errors; }, - [translate, shouldHideCountrySelector, country, shouldValidatePhysicalAddress], + [translate, shouldHideCountrySelector, country, shouldRequireZip, shouldValidatePhysicalAddress], ); return ( diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index b9a7a8e7a01a..283479e6f26a 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -21,7 +21,7 @@ import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import CenteredModalLayout from './CenteredModalLayout'; -import FeatureTrainingContent from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import Icon from './Icon'; import Text from './Text'; @@ -57,7 +57,6 @@ function AutoSubmitModal() { const shouldDismiss = !willShowAgainRef.current; willShowAgainRef.current = null; - // Defer the Onyx write until after the close transition finishes. This prevents potential checkbox flicker. TransitionTracker.runAfterTransitions({ callback: () => dismissASAPSubmitExplanation(shouldDismiss), waitForUpcomingTransition: true, @@ -70,51 +69,58 @@ function AutoSubmitModal() { const onConfirm = (willShowAgain: boolean) => { willShowAgainRef.current = willShowAgain; + handleClose(); }; + const shouldShowDismissOption = dismissedASAPSubmitExplanation === false; + return ( - - {menuSections.map((section) => ( - - - - {translate(section.titleTranslationKey as TranslationPaths)} - {translate(section.descriptionTranslationKey as TranslationPaths)} - - - ))} - + + + + {translate('autoSubmitModal.title')} + {translate('autoSubmitModal.description')} + {menuSections.map((section) => ( + + + + {translate(section.titleTranslationKey as TranslationPaths)} + {translate(section.descriptionTranslationKey as TranslationPaths)} + + + ))} + + {shouldShowDismissOption && } + {translate('common.buttonConfirm')} + + ); } diff --git a/src/components/AutoUpdateTime.tsx b/src/components/AutoUpdateTime.tsx index 6b81e33b1e24..9a1e41a50803 100644 --- a/src/components/AutoUpdateTime.tsx +++ b/src/components/AutoUpdateTime.tsx @@ -20,7 +20,7 @@ type AutoUpdateTimeProps = { }; function AutoUpdateTime({timezone}: AutoUpdateTimeProps) { - const {translate, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); + const {translate, getLocalDateFromDatetime} = useLocalize(); const styles = useThemeStyles(); const [, setTick] = useState(0); @@ -43,7 +43,7 @@ function AutoUpdateTime({timezone}: AutoUpdateTimeProps) { diff --git a/src/components/BookTravelButton.tsx b/src/components/BookTravelButton.tsx index 8a021e4d5cd7..5d86b83fb5e2 100644 --- a/src/components/BookTravelButton.tsx +++ b/src/components/BookTravelButton.tsx @@ -54,6 +54,10 @@ type BookTravelButtonProps = WithSentryLabel & { const hasPolicyIDInActiveRoute = () => getSearchParamFromPath(Navigation.getActiveRoute(), CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID) !== null; +// Avoids a duplicate policyID query param when the active route already has one (e.g. opened from the FAB). +const getPolicyDynamicSuffix = (dynamicRoute: {path: string; getRoute: (policyID?: string) => string}, policyID?: string) => + hasPolicyIDInActiveRoute() ? dynamicRoute.path : dynamicRoute.getRoute(policyID); + function BookTravelButton({ text, shouldRenderErrorMessageBelowButton = false, @@ -90,7 +94,7 @@ function BookTravelButton({ const shouldResumeBookingRef = useRef(false); const navigateToPublicDomainError = () => { - const dynamicSuffix = hasPolicyIDInActiveRoute() ? DYNAMIC_ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR.path : DYNAMIC_ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR.getRoute(activePolicyID); + const dynamicSuffix = getPolicyDynamicSuffix(DYNAMIC_ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR, activePolicyID); Navigation.navigate(createDynamicRoute(dynamicSuffix)); }; @@ -173,7 +177,7 @@ function BookTravelButton({ // Legacy request-access path for not-yet-provisioned workspaces when the self-serve provisioning beta is off. if (!isPolicyProvisioned && !isBetaEnabled(CONST.BETAS.IS_TRAVEL_VERIFIED)) { if (!isUserValidated) { - Navigation.navigate(ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(undefined, activePolicyID, Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(getPolicyDynamicSuffix(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT, activePolicyID))); return; } if (shouldShowVerifyAccountModal) { @@ -204,7 +208,7 @@ function BookTravelButton({ // replaced with the verify URL a render later. if (!isUserValidated) { setTravelProvisioningNextStep(enableTravelRoute); - Navigation.navigate(ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(undefined, activePolicyID, Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(getPolicyDynamicSuffix(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT, activePolicyID))); return; } Navigation.navigate(enableTravelRoute); diff --git a/src/components/CenteredModalLayout/index.tsx b/src/components/CenteredModalLayout/index.tsx index 6fe5f84d57a3..895a2958958f 100644 --- a/src/components/CenteredModalLayout/index.tsx +++ b/src/components/CenteredModalLayout/index.tsx @@ -35,7 +35,7 @@ type CenteredModalLayoutProps = { /** * Whether the content wrapper should apply the bottom safe-area inset. Disable it when the children - * already handle the inset themselves (e.g. FeatureTrainingContent with `shouldUseScrollView`, whose + * already handle the inset themselves (e.g. FeatureTraining with `shouldUseScrollView`, whose * ScrollView content padding includes it), otherwise the inset is applied twice and renders as an * empty band under the content on devices with a bottom inset. */ diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 58ea85e105a8..f89b8e00a2c2 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -14,7 +14,7 @@ import { useDynamicYDomain, useLabelHitTesting, } from '@components/Charts/hooks'; -import {calculateMinDomainPadding, getYAxisLabelWidth} from '@components/Charts/utils'; +import {calculateMinDomainPadding, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; import useTheme from '@hooks/useTheme'; @@ -88,7 +88,7 @@ function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'l const totalDomainPadding = domainPadding.left + domainPadding.right; const paddingScale = barAreaWidth > 0 ? barAreaWidth / (barAreaWidth + totalDomainPadding) : 0; - const originalLabels = data.map((p) => p.label); + const originalLabels = data.map(getXAxisLabel); const measurements = useChartLabelMeasurements(data, fontManager, variables.iconSizeExtraSmall); diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 131a831f7b75..614aa459136b 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -16,7 +16,7 @@ import { useDynamicYDomain, useLabelHitTesting, } from '@components/Charts/hooks'; -import {getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; +import {getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from '@components/Charts/VictoryTheme'; import useTheme from '@hooks/useTheme'; @@ -129,7 +129,7 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' measurements, }); - const originalLabels = data.map((p) => p.label); + const originalLabels = data.map(getXAxisLabel); const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ fontManager, diff --git a/src/components/Charts/hooks/useChartLabelMeasurements.ts b/src/components/Charts/hooks/useChartLabelMeasurements.ts index eb9cb0e302a6..9d88d37befcf 100644 --- a/src/components/Charts/hooks/useChartLabelMeasurements.ts +++ b/src/components/Charts/hooks/useChartLabelMeasurements.ts @@ -1,5 +1,5 @@ import type {ChartDataPoint} from '@components/Charts/types'; -import {getFontLineMetrics, measureTextWidth} from '@components/Charts/utils'; +import {getFontLineMetrics, getXAxisLabel, measureTextWidth} from '@components/Charts/utils'; import {ELLIPSIS, MIN_TRUNCATED_CHARS} from '@components/Charts/VictoryTheme'; import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; @@ -15,22 +15,23 @@ function useChartLabelMeasurements(data: ChartDataPoint[], fontManager: SkTypefa const {ascent, descent} = getFontLineMetrics(fontManager, fontSize); const lineHeight = Math.abs(ascent) + Math.abs(descent); const ellipsisWidth = measureTextWidth(ELLIPSIS, fontManager, fontSize); - const labelWidths = data.map((point) => measureTextWidth(point.label, fontManager, fontSize)); + const labels = data.map(getXAxisLabel); + const labelWidths = labels.map((label) => measureTextWidth(label, fontManager, fontSize)); const maxLabelWidth = Math.max(...labelWidths); const firstLabelWidth = labelWidths.at(0) ?? 0; const lastLabelWidth = labelWidths.at(-1) ?? 0; const minTruncatedWidth = Math.max( - ...data.map((point, index) => { - if (point.label.length <= MIN_TRUNCATED_CHARS) { + ...labels.map((label, index) => { + if (label.length <= MIN_TRUNCATED_CHARS) { return labelWidths.at(index) ?? 0; } - return measureTextWidth(point.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize); + return measureTextWidth(label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize); }), ); - const firstLabel = data.at(0)?.label ?? ''; - const lastLabel = data.at(-1)?.label ?? ''; + const firstLabel = labels.at(0) ?? ''; + const lastLabel = labels.at(-1) ?? ''; const firstMinTrunc = firstLabel.length <= MIN_TRUNCATED_CHARS ? firstLabelWidth : measureTextWidth(firstLabel.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize); const lastMinTrunc = lastLabel.length <= MIN_TRUNCATED_CHARS ? lastLabelWidth : measureTextWidth(lastLabel.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize); diff --git a/src/components/Charts/types/index.ts b/src/components/Charts/types/index.ts index 529a45556401..e220ec1162fa 100644 --- a/src/components/Charts/types/index.ts +++ b/src/components/Charts/types/index.ts @@ -4,9 +4,12 @@ import type {SkParagraph} from '@shopify/react-native-skia'; import type {ValueOf} from 'type-fest'; type ChartDataPoint = { - /** Label displayed under the data point (e.g., "Amazon", "Nov 2025") */ + /** Full label for the data point (e.g., "Amazon", "November 2025") */ label: string; + /** Compact label for the x-axis (e.g., "Nov ’25"). Defaults to `label`. */ + shortLabel?: string; + /** Total amount (pre-formatted, e.g., dollars not cents) */ total: number; diff --git a/src/components/Charts/utils/index.ts b/src/components/Charts/utils/index.ts index 0ed4c688edeb..692b073cc38a 100644 --- a/src/components/Charts/utils/index.ts +++ b/src/components/Charts/utils/index.ts @@ -257,6 +257,11 @@ function processDataIntoSlices( ).slices; } +/** Label to render on the x-axis for a data point: the compact one when provided, otherwise the full label. */ +function getXAxisLabel(point: ChartDataPoint): string { + return point.shortLabel ?? point.label; +} + /** Truncate `label` so its pixel width fits within `maxWidth`, adding ellipsis. */ function truncateLabel(label: string, labelWidth: number, maxWidth: number, ellipsisWidth: number): string { if (labelWidth <= maxWidth) { @@ -460,6 +465,7 @@ export { isAngleInSlice, findSliceAtPosition, processDataIntoSlices, + getXAxisLabel, truncateLabel, effectiveWidth, effectiveHeight, diff --git a/src/components/EmptyStateComponent/types.ts b/src/components/EmptyStateComponent/types.ts index fbfe045c6226..95f6a922f43a 100644 --- a/src/components/EmptyStateComponent/types.ts +++ b/src/components/EmptyStateComponent/types.ts @@ -1,5 +1,6 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ImageStyle} from 'expo-image'; @@ -16,11 +17,7 @@ type EmptyStateButton = { style?: StyleProp; innerStyles?: StyleProp; hoverStyles?: StyleProp; - dropDownOptions?: Array< - DropdownOption< - ValueOf<{readonly CREATE_NEW_EXPENSE: 'createNewExpense'; readonly TRACK_DISTANCE_EXPENSE: 'trackDistanceExpense'; readonly ADD_EXISTING_EXPENSE: 'addExistingExpense'}> - > - >; + dropDownOptions?: Array>>; }; type EmptyStateComponentProps = { diff --git a/src/components/FeatureTraining/FeatureTraining.tsx b/src/components/FeatureTraining/FeatureTraining.tsx new file mode 100644 index 000000000000..822755be5721 --- /dev/null +++ b/src/components/FeatureTraining/FeatureTraining.tsx @@ -0,0 +1,71 @@ +import variables from '@styles/variables'; + +import React, {useCallback, useMemo, useState} from 'react'; + +import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './context'; +import type {FeatureTrainingProps} from './types'; + +import {FeatureTrainingActionsContext, FeatureTrainingStateContext} from './context'; +import useScrollableWrapper from './hooks/useScrollableWrapper'; + +function FeatureTraining({ + onConfirm, + onClose, + onWillShowAgainChange, + shouldUseScrollView = false, + width = variables.featureTrainingModalWidth, + confirmSentryLabel, + children, +}: FeatureTrainingProps) { + const [willShowAgain, setWillShowAgain] = useState(true); + const {Wrapper, wrapperProps} = useScrollableWrapper({shouldUseScrollView, width}); + + const toggleWillShowAgain = useCallback(() => { + onWillShowAgainChange?.(!willShowAgain); + setWillShowAgain((prev) => !prev); + }, [onWillShowAgainChange, willShowAgain]); + + // Closing after confirm is the consumer's responsibility — call it inside onConfirm when desired. + const handleConfirm = useCallback(() => { + onConfirm?.(willShowAgain); + }, [onConfirm, willShowAgain]); + + const handleClose = useCallback(() => onClose?.(), [onClose]); + + const stateValue = useMemo( + () => ({ + willShowAgain, + shouldShowLoadingImmediatelyOnPress: undefined, + isCarousel: false, + confirmSentryLabel, + currentPage: undefined, + pageCount: undefined, + isLastPage: undefined, + contentMinHeight: undefined, + }), + [willShowAgain, confirmSentryLabel], + ); + + const actionsValue = useMemo( + () => ({ + toggleWillShowAgain, + handleConfirm, + handleClose, + advance: undefined, + goBack: undefined, + }), + [toggleWillShowAgain, handleConfirm, handleClose], + ); + + return ( + + + {children} + + + ); +} + +FeatureTraining.displayName = 'FeatureTraining'; + +export default FeatureTraining; diff --git a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx new file mode 100644 index 000000000000..efb4849265de --- /dev/null +++ b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx @@ -0,0 +1,295 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import type {ReactElement, ReactNode} from 'react'; +import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native'; + +import React, {cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {FlatList, Platform, View} from 'react-native'; + +import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './context'; +import type {IllustrationProps} from './primitives/Illustration'; +import type {FeatureTrainingCarouselProps} from './types'; + +import {FeatureTrainingActionsContext, FeatureTrainingStateContext} from './context'; +import useScrollableWrapper from './hooks/useScrollableWrapper'; +import Body from './primitives/Body'; +import BodyText from './primitives/BodyText'; +import CloseButton from './primitives/CloseButton'; +import Illustration from './primitives/Illustration'; +import Page from './primitives/Page'; +import PaginationDots from './primitives/PaginationDots'; + +const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThreshold: 95}; + +const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start', scrollSnapStop: 'always'} as ViewStyle) : {}; + +type BodyElement = ReactElement>; +type BodyTextElement = ReactElement>; +type IllustrationElement = ReactElement; +type PageElement = ReactElement>; + +function isBodyElement(child: ReactElement): child is BodyElement { + return child.type === Body; +} +function isBodyTextElement(child: ReactElement): child is BodyTextElement { + return child.type === BodyText; +} +function isIllustrationElement(child: ReactElement): child is IllustrationElement { + return child.type === Illustration; +} +function isPageElement(child: ReactElement): child is PageElement { + return child.type === Page; +} + +type SplitPage = { + illustration: IllustrationElement | null; + body: BodyElement | null; + bodyText: BodyTextElement | null; +}; + +function splitPageChildren(pageChildren: ReactNode): SplitPage { + let illustration: IllustrationElement | null = null; + let body: BodyElement | null = null; + let bodyText: BodyTextElement | null = null; + React.Children.forEach(pageChildren, (child) => { + if (!isValidElement(child)) { + return; + } + if (isIllustrationElement(child)) { + illustration = child; + return; + } + if (isBodyElement(child)) { + body = child; + React.Children.forEach(child.props.children, (bodyChild) => { + if (!isValidElement(bodyChild) || !isBodyTextElement(bodyChild)) { + return; + } + bodyText = bodyChild; + }); + } + }); + return {illustration, body, bodyText}; +} + +function FeatureTrainingCarousel({ + onConfirm, + onClose, + onPageChange, + shouldUseScrollView = false, + width = variables.featureTrainingModalWidth, + confirmSentryLabel, + children, +}: FeatureTrainingCarouselProps) { + const [currentPage, setCurrentPage] = useState(0); + const [carouselViewportWidth, setCarouselViewportWidth] = useState(0); + const horizontalListRef = useRef>(null); + const lastReportedPage = useRef(0); + + const [contentMinHeight, setContentMinHeight] = useState(undefined); + const measuredHeightsRef = useRef>({}); + + const {Wrapper, wrapperProps, setContainerHeight, shouldUseScrollView: usingScrollView, isInLandscapeMode} = useScrollableWrapper({shouldUseScrollView, width}); + + const pages = useMemo(() => { + const pageList: SplitPage[] = []; + React.Children.forEach(children, (child) => { + if (!isValidElement(child) || !isPageElement(child)) { + return; + } + pageList.push(splitPageChildren(child.props.children)); + }); + return pageList; + }, [children]); + + const onPageChangeRef = useRef(onPageChange); + useEffect(() => { + onPageChangeRef.current = onPageChange; + }, [onPageChange]); + + const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]}) => { + const entry = viewableItems.at(0); + if (entry?.index == null || entry.index === lastReportedPage.current) { + return; + } + lastReportedPage.current = entry.index; + setCurrentPage(entry.index); + onPageChangeRef.current?.(entry.index); + }, []); + + const advance = useCallback(() => { + horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true}); + }, [currentPage, pages.length]); + + const goBack = useCallback(() => { + if (currentPage <= 0) { + return; + } + horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true}); + }, [currentPage]); + + const pageCountRef = useRef(pages.length); + useEffect(() => { + pageCountRef.current = pages.length; + }, [pages.length]); + + // Re-align the horizontal offset to the current page whenever the viewport width changes (e.g. device + // rotation or window resize). Without this the old pixel offset no longer lands on a page boundary and + // the carousel gets stuck between pages. + useEffect(() => { + if (carouselViewportWidth <= 0) { + return; + } + horizontalListRef.current?.scrollToOffset({offset: lastReportedPage.current * carouselViewportWidth, animated: false}); + }, [carouselViewportWidth]); + + const recordPageHeight = useCallback((index: number, measured: number) => { + if (measuredHeightsRef.current[index] === measured) { + return; + } + measuredHeightsRef.current[index] = measured; + if (Object.keys(measuredHeightsRef.current).length < pageCountRef.current) { + return; + } + setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current))); + }, []); + + const isLastPage = pages.length === 0 || currentPage >= pages.length - 1; + + const handleConfirm = useCallback(() => { + onConfirm?.(false); + }, [onConfirm]); + + const handleClose = useCallback(() => onClose?.(), [onClose]); + + const stateValue = useMemo( + () => ({ + willShowAgain: true, + shouldShowLoadingImmediatelyOnPress: isLastPage ? undefined : false, + isCarousel: true, + confirmSentryLabel, + currentPage, + pageCount: pages.length, + isLastPage, + contentMinHeight, + }), + [isLastPage, confirmSentryLabel, currentPage, pages.length, contentMinHeight], + ); + + const actionsValue = useMemo( + () => ({ + toggleWillShowAgain: () => {}, + handleConfirm, + handleClose, + advance, + goBack, + }), + [handleConfirm, handleClose, advance, goBack], + ); + + const currentPageBody = pages.at(currentPage)?.body ?? null; + + const onWrapperLayout = useCallback( + (e: LayoutChangeEvent) => { + const newWidth = e.nativeEvent.layout.width; + if (newWidth === carouselViewportWidth || newWidth <= 0) { + return; + } + setCarouselViewportWidth(newWidth); + if (!usingScrollView) { + return; + } + setContainerHeight(e.nativeEvent.layout.height); + }, + [carouselViewportWidth, usingScrollView, setContainerHeight], + ); + + const probeStyle = useMemo(() => ({position: 'absolute', left: 0, top: 0, width: carouselViewportWidth, opacity: 0}), [carouselViewportWidth]); + + return ( + + + + {carouselViewportWidth > 0 && contentMinHeight === undefined && ( + + {pages.map((probePage, index) => + probePage.bodyText == null ? null : ( + + ), + )} + + )} + {carouselViewportWidth > 0 && ( + <> + + `FeatureTrainingCarousel-page-${index}`} + horizontal + pagingEnabled + initialScrollIndex={currentPage} + disableIntervalMomentum + snapToInterval={carouselViewportWidth} + decelerationRate="fast" + bounces={false} + showsHorizontalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG} + onViewableItemsChanged={onViewableItemsChanged} + getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})} + renderItem={({item, index}) => ( + + {item.illustration == null ? null : cloneElement(item.illustration, {isFocused: index === currentPage})} + + )} + /> + + + {currentPageBody} + + + )} + + + + ); +} + +FeatureTrainingCarousel.displayName = 'FeatureTraining.Carousel'; + +type ProbePageProps = { + index: number; + bodyText: BodyTextElement; + onMeasure: (index: number, height: number) => void; +}; + +// The probe measures only the BodyText block (title/subtitle/description) — buttons and checkboxes are +// excluded so the locked minHeight matches the area it is later applied to. The mh5 wrapper mirrors +// Body's horizontal margins so the probe text wraps at the same width as the visible page. +function ProbePage({index, bodyText, onMeasure}: ProbePageProps) { + const styles = useThemeStyles(); + const onLayout = useCallback((event: LayoutChangeEvent) => onMeasure(index, event.nativeEvent.layout.height), [index, onMeasure]); + + return {cloneElement(bodyText, {onLayout})}; +} + +export default FeatureTrainingCarousel; diff --git a/src/components/FeatureTraining/context/FeatureTrainingContext.ts b/src/components/FeatureTraining/context/FeatureTrainingContext.ts new file mode 100644 index 000000000000..a8a567a86111 --- /dev/null +++ b/src/components/FeatureTraining/context/FeatureTrainingContext.ts @@ -0,0 +1,35 @@ +import {createContext, useContext} from 'react'; + +import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './types'; + +const defaultState: FeatureTrainingStateValue = { + willShowAgain: true, + shouldShowLoadingImmediatelyOnPress: undefined, + isCarousel: false, + confirmSentryLabel: undefined, + currentPage: undefined, + pageCount: undefined, + isLastPage: undefined, + contentMinHeight: undefined, +}; + +const defaultActions: FeatureTrainingActionsValue = { + toggleWillShowAgain: () => {}, + handleConfirm: () => {}, + handleClose: () => {}, + advance: undefined, + goBack: undefined, +}; + +const FeatureTrainingStateContext = createContext(defaultState); +const FeatureTrainingActionsContext = createContext(defaultActions); + +function useFeatureTrainingState(): FeatureTrainingStateValue { + return useContext(FeatureTrainingStateContext); +} + +function useFeatureTrainingActions(): FeatureTrainingActionsValue { + return useContext(FeatureTrainingActionsContext); +} + +export {FeatureTrainingStateContext, FeatureTrainingActionsContext, useFeatureTrainingState, useFeatureTrainingActions}; diff --git a/src/components/FeatureTraining/context/index.ts b/src/components/FeatureTraining/context/index.ts new file mode 100644 index 000000000000..068102501430 --- /dev/null +++ b/src/components/FeatureTraining/context/index.ts @@ -0,0 +1,2 @@ +export {FeatureTrainingActionsContext, FeatureTrainingStateContext, useFeatureTrainingActions, useFeatureTrainingState} from './FeatureTrainingContext'; +export type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './types'; diff --git a/src/components/FeatureTraining/context/types.ts b/src/components/FeatureTraining/context/types.ts new file mode 100644 index 000000000000..b2265b91ee75 --- /dev/null +++ b/src/components/FeatureTraining/context/types.ts @@ -0,0 +1,44 @@ +type FeatureTrainingStateValue = { + /** Whether the "don't show me this again" option is currently unchecked (i.e. modal will show again) */ + willShowAgain: boolean; + + /** Whether the confirm action should show a spinner immediately on press */ + shouldShowLoadingImmediatelyOnPress: boolean | undefined; + + /** Whether this content is being rendered inside a carousel */ + isCarousel: boolean; + + /** Sentry label for the confirm button */ + confirmSentryLabel: string | undefined; + + /** Carousel-only: current 0-based page index (undefined in single-page mode) */ + currentPage: number | undefined; + + /** Carousel-only: total number of pages (undefined in single-page mode) */ + pageCount: number | undefined; + + /** Carousel-only: true when currentPage is the last one (undefined in single-page mode) */ + isLastPage: boolean | undefined; + + /** Carousel-only: locked minimum content height once all page heights are measured */ + contentMinHeight: number | undefined; +}; + +type FeatureTrainingActionsValue = { + /** Toggles the "don't show again" state */ + toggleWillShowAgain: () => void; + + /** Fires the consumer's onConfirm callback with the current willShowAgain value; also closes when configured */ + handleConfirm: () => void; + + /** Closes the modal */ + handleClose: () => void; + + /** Carousel-only: advances to the next page (undefined in single-page mode) */ + advance: (() => void) | undefined; + + /** Carousel-only: returns to the previous page (undefined in single-page mode) */ + goBack: (() => void) | undefined; +}; + +export type {FeatureTrainingStateValue, FeatureTrainingActionsValue}; diff --git a/src/components/FeatureTraining/hooks/useScrollableWrapper.ts b/src/components/FeatureTraining/hooks/useScrollableWrapper.ts new file mode 100644 index 000000000000..bfff2ca6ac05 --- /dev/null +++ b/src/components/FeatureTraining/hooks/useScrollableWrapper.ts @@ -0,0 +1,80 @@ +import ScrollView from '@components/ScrollView'; + +import useKeyboardState from '@hooks/useKeyboardState'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; + +import CONST from '@src/CONST'; + +// eslint-disable-next-line no-restricted-imports -- type-only import from react-native +import type {LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, ViewStyle} from 'react-native'; + +import {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +type UseScrollableWrapperOptions = { + shouldUseScrollView?: boolean; + width?: number; +}; + +function useScrollableWrapper({shouldUseScrollView: shouldUseScrollViewProp = false, width}: UseScrollableWrapperOptions) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + const {windowHeight, windowWidth} = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const {isKeyboardActive} = useKeyboardState(); + + const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; + + const scrollViewRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); + + useEffect(() => { + if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) { + return; + } + scrollViewRef.current?.scrollToEnd({animated: false}); + }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]); + + const Wrapper = shouldUseScrollView ? ScrollView : View; + const wrapperStyles = shouldUseScrollView + ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) + : ({} as {style?: StyleProp; containerStyle?: StyleProp}); + + const style: StyleProp = [ + onboardingIsMediumOrLargerScreenWidth && width !== undefined && StyleUtils.getWidthStyle(width), + wrapperStyles.style, + isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100, + ]; + + const onLayout = shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined; + const onContentSizeChange = shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined; + + return { + Wrapper, + wrapperProps: { + scrollsToTop: false, + style, + contentContainerStyle: wrapperStyles.containerStyle, + keyboardShouldPersistTaps: shouldUseScrollView ? ('handled' as const) : undefined, + ref: shouldUseScrollView ? scrollViewRef : undefined, + onLayout, + onContentSizeChange, + fsClass: CONST.FULLSTORY.CLASS.UNMASK, + }, + setContainerHeight, + setContentHeight, + shouldUseScrollView, + isInLandscapeMode, + }; +} + +export default useScrollableWrapper; diff --git a/src/components/FeatureTraining/index.tsx b/src/components/FeatureTraining/index.tsx new file mode 100644 index 000000000000..2b2fc2551f7e --- /dev/null +++ b/src/components/FeatureTraining/index.tsx @@ -0,0 +1,32 @@ +import FeatureTrainingBase from './FeatureTraining'; +import FeatureTrainingCarousel from './FeatureTrainingCarousel'; +import BackButton from './primitives/BackButton'; +import Body from './primitives/Body'; +import BodyText from './primitives/BodyText'; +import ButtonRow from './primitives/ButtonRow'; +import ConfirmButton from './primitives/ConfirmButton'; +import Description from './primitives/Description'; +import DismissOption from './primitives/DismissOption'; +import HelpButton from './primitives/HelpButton'; +import Illustration from './primitives/Illustration'; +import Page from './primitives/Page'; +import Subtitle from './primitives/Subtitle'; +import Title from './primitives/Title'; + +const FeatureTraining = Object.assign(FeatureTrainingBase, { + Carousel: FeatureTrainingCarousel, + Page, + Illustration, + Body, + BodyText, + Title, + Subtitle, + Description, + DismissOption, + HelpButton, + ConfirmButton, + BackButton, + ButtonRow, +}); + +export default FeatureTraining; diff --git a/src/components/FeatureTraining/primitives/BackButton.tsx b/src/components/FeatureTraining/primitives/BackButton.tsx new file mode 100644 index 000000000000..83e694074e8f --- /dev/null +++ b/src/components/FeatureTraining/primitives/BackButton.tsx @@ -0,0 +1,40 @@ +import Button from '@components/ButtonComposed'; +import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; + +import useLocalize from '@hooks/useLocalize'; + +import CONST from '@src/CONST'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; + +type BackButtonProps = { + /** Style for the button, e.g. flex1 when sharing a ButtonRow */ + style?: StyleProp; +}; + +function BackButton({style}: BackButtonProps) { + const {translate} = useLocalize(); + const {isCarousel, currentPage} = useFeatureTrainingState(); + const {goBack} = useFeatureTrainingActions(); + + if (!isCarousel || currentPage === 0 || !goBack) { + return null; + } + + return ( + + ); +} + +BackButton.displayName = 'FeatureTraining.BackButton'; + +export default BackButton; diff --git a/src/components/FeatureTraining/primitives/Body.tsx b/src/components/FeatureTraining/primitives/Body.tsx new file mode 100644 index 000000000000..5ee2b5690f75 --- /dev/null +++ b/src/components/FeatureTraining/primitives/Body.tsx @@ -0,0 +1,24 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type BodyProps = { + children?: ReactNode; + + /** Style applied to the body container */ + style?: StyleProp; +}; + +function Body({children, style}: BodyProps) { + const styles = useThemeStyles(); + + return {children}; +} + +Body.displayName = 'FeatureTraining.Body'; + +export default Body; diff --git a/src/components/FeatureTraining/primitives/BodyText.tsx b/src/components/FeatureTraining/primitives/BodyText.tsx new file mode 100644 index 000000000000..2c4bfb871fbd --- /dev/null +++ b/src/components/FeatureTraining/primitives/BodyText.tsx @@ -0,0 +1,39 @@ +import {useFeatureTrainingState} from '@components/FeatureTraining/context'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type BodyTextProps = { + children?: ReactNode; + + /** Style applied to the text container */ + style?: StyleProp; + + /** onLayout used by the carousel probe layer to measure page heights */ + onLayout?: (event: LayoutChangeEvent) => void; +}; + +function BodyText({children, style, onLayout}: BodyTextProps) { + const styles = useThemeStyles(); + const {contentMinHeight} = useFeatureTrainingState(); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + + return ( + + {children} + + ); +} + +BodyText.displayName = 'FeatureTraining.BodyText'; + +export default BodyText; diff --git a/src/components/FeatureTraining/primitives/ButtonRow.tsx b/src/components/FeatureTraining/primitives/ButtonRow.tsx new file mode 100644 index 000000000000..427ad2ef4d3b --- /dev/null +++ b/src/components/FeatureTraining/primitives/ButtonRow.tsx @@ -0,0 +1,20 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; + +import React from 'react'; +import {View} from 'react-native'; + +type ButtonRowProps = { + /** Buttons sharing the horizontal row, e.g. BackButton and ConfirmButton */ + children?: ReactNode; +}; + +function ButtonRow({children}: ButtonRowProps) { + const styles = useThemeStyles(); + return {children}; +} + +ButtonRow.displayName = 'FeatureTraining.ButtonRow'; + +export default ButtonRow; diff --git a/src/components/FeatureTraining/primitives/CloseButton.tsx b/src/components/FeatureTraining/primitives/CloseButton.tsx new file mode 100644 index 000000000000..bdaf3ca33524 --- /dev/null +++ b/src/components/FeatureTraining/primitives/CloseButton.tsx @@ -0,0 +1,51 @@ +import {useFeatureTrainingActions} from '@components/FeatureTraining/context'; +import Icon from '@components/Icon'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Tooltip from '@components/Tooltip'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +const CONTENT_PADDING = variables.spacing2; + +function CloseButton() { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); + const {handleClose} = useFeatureTrainingActions(); + + return ( + + + + + + + + ); +} + +CloseButton.displayName = 'FeatureTraining.CloseButton'; + +export default CloseButton; diff --git a/src/components/FeatureTraining/primitives/ConfirmButton.tsx b/src/components/FeatureTraining/primitives/ConfirmButton.tsx new file mode 100644 index 000000000000..7ea5dd844a90 --- /dev/null +++ b/src/components/FeatureTraining/primitives/ConfirmButton.tsx @@ -0,0 +1,49 @@ +import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; + +type ConfirmButtonProps = { + /** Text label shown on the confirm button */ + children: string; + + /** Sentry label for the confirm button; falls back to the root confirmSentryLabel */ + sentryLabel?: string; + + /** Style for the button container, e.g. flex1 when sharing a ButtonRow */ + style?: StyleProp; +}; + +function ConfirmButton({children, sentryLabel, style}: ConfirmButtonProps) { + const {confirmSentryLabel, shouldShowLoadingImmediatelyOnPress, isCarousel, isLastPage} = useFeatureTrainingState(); + const {handleConfirm, advance} = useFeatureTrainingActions(); + + const isNextPageAction = isCarousel && !isLastPage; + + const onSubmit = () => { + if (isNextPageAction && advance) { + advance(); + return; + } + handleConfirm(); + }; + + const loading = isNextPageAction ? false : shouldShowLoadingImmediatelyOnPress; + + return ( + + ); +} + +ConfirmButton.displayName = 'FeatureTraining.ConfirmButton'; + +export default ConfirmButton; diff --git a/src/components/FeatureTraining/primitives/Description.tsx b/src/components/FeatureTraining/primitives/Description.tsx new file mode 100644 index 000000000000..c65a3a151360 --- /dev/null +++ b/src/components/FeatureTraining/primitives/Description.tsx @@ -0,0 +1,37 @@ +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +type DescriptionProps = { + /** Description text to render */ + children?: string; + + /** Whether the description is HTML markup and should be rendered via RenderHTML */ + shouldRenderHTML?: boolean; +}; + +function Description({children, shouldRenderHTML = false}: DescriptionProps) { + const styles = useThemeStyles(); + + if (!children) { + return null; + } + + if (shouldRenderHTML) { + return ( + + + + ); + } + + return {children}; +} + +Description.displayName = 'FeatureTraining.Description'; + +export default Description; diff --git a/src/components/FeatureTraining/primitives/DismissOption.tsx b/src/components/FeatureTraining/primitives/DismissOption.tsx new file mode 100644 index 000000000000..1bbd5cd1ad15 --- /dev/null +++ b/src/components/FeatureTraining/primitives/DismissOption.tsx @@ -0,0 +1,28 @@ +import CheckboxWithLabel from '@components/CheckboxWithLabel'; +import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +function DismissOption() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {willShowAgain} = useFeatureTrainingState(); + const {toggleWillShowAgain} = useFeatureTrainingActions(); + + return ( + + ); +} + +DismissOption.displayName = 'FeatureTraining.DismissOption'; + +export default DismissOption; diff --git a/src/components/FeatureTraining/primitives/HelpButton.tsx b/src/components/FeatureTraining/primitives/HelpButton.tsx new file mode 100644 index 000000000000..f0fe989cf0bb --- /dev/null +++ b/src/components/FeatureTraining/primitives/HelpButton.tsx @@ -0,0 +1,37 @@ +import Button from '@components/ButtonComposed'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type HelpButtonProps = { + /** Text label shown on the help button */ + children: string; + + /** Called when the help button is pressed */ + onPress?: () => void; + + /** Sentry label for the help button */ + sentryLabel?: string; +}; + +function HelpButton({children, onPress, sentryLabel}: HelpButtonProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +HelpButton.displayName = 'FeatureTraining.HelpButton'; + +export default HelpButton; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTraining/primitives/Illustration.tsx similarity index 70% rename from src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx rename to src/components/FeatureTraining/primitives/Illustration.tsx index 0716dca3987f..a8b69f655fc9 100644 --- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx +++ b/src/components/FeatureTraining/primitives/Illustration.tsx @@ -1,6 +1,8 @@ import ImageSVG from '@components/ImageSVG'; +import type ImageSVGProps from '@components/ImageSVG/types'; import Lottie from '@components/Lottie'; import LottieAnimations from '@components/LottieAnimations'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; import VideoPlayer from '@components/VideoPlayer'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -17,49 +19,60 @@ import {getIsOffline} from '@libs/NetworkState'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; +import type {ImageContentFit} from 'expo-image'; import type {SourceLoadEventPayload} from 'expo-video'; import type LottieView from 'lottie-react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; +import type {MergeExclusive} from 'type-fest'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import type {FeatureTrainingContentIllustrationProps as BaseFeatureTrainingContentIllustrationProps, BaseFeatureTrainingContentProps} from './types'; - -// Aspect ratio and height of the video. -// Useful before video loads to reserve space. const VIDEO_ASPECT_RATIO = 1280 / 960; - const CONTENT_PADDING = variables.spacing2; - const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7; type VideoStatus = 'video' | 'animation'; -type FeatureTrainingContentIllustrationProps = Pick & - BaseFeatureTrainingContentIllustrationProps & { - /** Whether this illustration belongs to the currently-visible carousel page */ - isFocused?: boolean; +type VideoOrAnimationProps = { + animation?: DotLottieAnimation; + animationStyle?: StyleProp; + videoURL?: string; +}; + +type ImageProps = { + image: IconAsset; + contentFitImage?: ImageContentFit; + imageWidth?: ImageSVGProps['width']; + imageHeight?: ImageSVGProps['height']; +}; + +type IllustrationMediaProps = MergeExclusive; + +type IllustrationProps = IllustrationMediaProps & { + aspectRatio?: number; + innerContainerStyle?: StyleProp; + outerContainerStyle?: StyleProp; + + /** + * Whether this illustration is the currently-focused page in a carousel. Injected by FeatureTraining.Carousel + * via cloneElement — consumers should not set this directly. Defaults to true (always play) for single-page use. + */ + isFocused?: boolean; +}; - /** Whether this illustration is part of a carousel */ - isCarousel?: boolean; - }; - -/** - * Once the device has been online, lock to 'video' permanently. - * While it has never been online, show 'animation' as a fallback. - */ function useVideoStatus(): VideoStatus { const [isLockedToVideo, setIsLockedToVideo] = useState(() => !getIsOffline()); const {isOffline} = useNetwork({ onReconnect: () => setIsLockedToVideo(true), }); - return isLockedToVideo || !isOffline ? 'video' : 'animation'; } -function FeatureTrainingContentIllustration({ +function Illustration({ animation, animationStyle, videoURL, @@ -67,24 +80,23 @@ function FeatureTrainingContentIllustration({ contentFitImage, imageWidth, imageHeight, - illustrationAspectRatio: illustrationAspectRatioProp, - illustrationInnerContainerStyle, - illustrationOuterContainerStyle, + aspectRatio: aspectRatioProp, + innerContainerStyle, + outerContainerStyle, isFocused = true, - isCarousel = false, -}: FeatureTrainingContentIllustrationProps) { +}: IllustrationProps) { const styles = useThemeStyles(); const isReduceMotionEnabled = Accessibility.useReducedMotion(); const illustrations = useMemoizedLazyIllustrations(['Hands']); const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const videoStatus = useVideoStatus(); const {windowHeight, windowWidth} = useWindowDimensions(); - const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO); + const [measuredAspectRatio, setMeasuredAspectRatio] = useState(aspectRatioProp ?? VIDEO_ASPECT_RATIO); const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); const animationRef = useRef(null); useEffect(() => { - if (isMobile() || !isCarousel || !animationRef.current || isReduceMotionEnabled) { + if (isMobile() || !animationRef.current || isReduceMotionEnabled) { return; } if (isFocused) { @@ -92,35 +104,27 @@ function FeatureTrainingContentIllustration({ } else { animationRef.current.pause(); } - }, [isFocused, isCarousel, isReduceMotionEnabled]); + }, [isFocused, isReduceMotionEnabled]); const setAspectRatio = (event: SourceLoadEventPayload) => { const track = event.availableVideoTracks.at(0); if (!track) { return; } - setIllustrationAspectRatio(track.size.width / track.size.height); + setMeasuredAspectRatio(track.size.width / track.size.height); }; - const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO; + const aspectRatio = measuredAspectRatio || VIDEO_ASPECT_RATIO; return ( - + {!!image && ( + {Array.from({length: pageCount}, (_v, index) => ( + + ))} + + ); +} + +PaginationDots.displayName = 'FeatureTraining.PaginationDots'; + +export default PaginationDots; diff --git a/src/components/FeatureTraining/primitives/Subtitle.tsx b/src/components/FeatureTraining/primitives/Subtitle.tsx new file mode 100644 index 000000000000..323e36b8dce9 --- /dev/null +++ b/src/components/FeatureTraining/primitives/Subtitle.tsx @@ -0,0 +1,26 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; + +import React from 'react'; + +type SubtitleProps = { + /** Subtitle text shown above the title */ + children?: ReactNode; +}; + +function Subtitle({children}: SubtitleProps) { + const styles = useThemeStyles(); + + if (children == null || children === '') { + return null; + } + + return {children}; +} + +Subtitle.displayName = 'FeatureTraining.Subtitle'; + +export default Subtitle; diff --git a/src/components/FeatureTraining/primitives/Title.tsx b/src/components/FeatureTraining/primitives/Title.tsx new file mode 100644 index 000000000000..1b8d41f9dbce --- /dev/null +++ b/src/components/FeatureTraining/primitives/Title.tsx @@ -0,0 +1,38 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; + +import React, {isValidElement} from 'react'; + +type TitleProps = { + /** Title content — rendered as a headline when a string, as-is when a ReactNode */ + children?: ReactNode; + + /** Additional style applied to the title text (string titles only) */ + style?: StyleProp; +}; + +function Title({children, style}: TitleProps) { + const styles = useThemeStyles(); + + if (children == null || children === '') { + return null; + } + + if (typeof children === 'string') { + return {children}; + } + + if (isValidElement(children)) { + return children; + } + + return null; +} + +Title.displayName = 'FeatureTraining.Title'; + +export default Title; diff --git a/src/components/FeatureTraining/types.ts b/src/components/FeatureTraining/types.ts new file mode 100644 index 000000000000..369cdb945d29 --- /dev/null +++ b/src/components/FeatureTraining/types.ts @@ -0,0 +1,31 @@ +import type {ReactNode} from 'react'; + +type FeatureTrainingProps = { + /** Called when the user confirms the tutorial. `willShowAgain` reflects the DismissOption checkbox (true by default). */ + onConfirm?: (willShowAgain: boolean) => void; + + /** Called when the modal closes */ + onClose?: () => void; + + /** Called when the DismissOption checkbox is toggled */ + onWillShowAgainChange?: (willShowAgain: boolean) => void; + + /** Whether the content should render inside a ScrollView (auto-enabled in landscape) */ + shouldUseScrollView?: boolean; + + /** Modal content width on medium-or-larger screens */ + width?: number; + + /** Sentry label for the confirm button; can also be set per-primitive on */ + confirmSentryLabel?: string; + + /** Composed content — Illustration, Body, DismissOption, HelpButton, ConfirmButton, etc. */ + children?: ReactNode; +}; + +type FeatureTrainingCarouselProps = FeatureTrainingProps & { + /** Called when the visible page changes */ + onPageChange?: (index: number) => void; +}; + +export type {FeatureTrainingProps, FeatureTrainingCarouselProps}; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx deleted file mode 100644 index e07ed12ea63f..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import Icon from '@components/Icon'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import ScrollView from '@components/ScrollView'; -import Tooltip from '@components/Tooltip'; - -import useKeyboardState from '@hooks/useKeyboardState'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - -// eslint-disable-next-line no-restricted-imports -- Type import needed for ref typing; no wrapper available -import type {LayoutChangeEvent, FlatList as RNFlatList, ScrollView as RNScrollView, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native'; - -import React, {useEffect, useRef, useState} from 'react'; -import {FlatList, Platform, View} from 'react-native'; - -import type {FeatureTrainingCarouselProps, FeatureTrainingContentDataProps} from './types'; - -import FeatureTrainingContentBody from './FeatureTrainingContentBody'; -import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText'; -import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration'; - -const CONTENT_PADDING = variables.spacing2; - -// A page is considered "viewable" — and `currentPage` updates — only once it occupies at least -// 95% of the viewport. The viewability event fires for both user swipes and programmatic -// scrollToIndex once the scroll has practically settled on a new page. -const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThreshold: 95}; -const CAROUSEL_DOT_SIZE = 6; -const PAGINATION_DOTS_BOTTOM_OFFSET = 5; - -const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start', scrollSnapStop: 'always'} as ViewStyle) : {}; - -function FeatureTrainingCarousel({ - pages, - width = variables.featureTrainingModalWidth, - titleStyles, - illustrationAspectRatio, - illustrationInnerContainerStyle, - illustrationOuterContainerStyle, - shouldRenderHTMLDescription = false, - shouldUseScrollView: shouldUseScrollViewProp = false, - helpText = '', - onHelp = () => {}, - helpSentryLabel, - confirmSentryLabel, - contentInnerContainerStyles, - contentOuterContainerStyles, - onConfirm, - onClose, - onPageChange, -}: FeatureTrainingCarouselProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const theme = useTheme(); - const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - const {windowHeight, windowWidth} = useWindowDimensions(); - const insets = useSafeAreaInsets(); - const {isKeyboardActive} = useKeyboardState(); - const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); - - const [currentPage, setCurrentPage] = useState(0); - const [carouselViewportWidth, setCarouselViewportWidth] = useState(0); - const horizontalListRef = useRef>(null); - const lastReportedPage = useRef(0); - - const scrollViewRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(0); - const [contentHeight, setContentHeight] = useState(0); - const [contentMinHeight, setContentMinHeight] = useState(undefined); - const measuredHeightsRef = useRef>({}); - const handleProbeLayout = (index: number) => (event: LayoutChangeEvent) => { - const measured = event.nativeEvent.layout.height; - if (measuredHeightsRef.current[index] === measured) { - return; - } - measuredHeightsRef.current[index] = measured; - if (Object.keys(measuredHeightsRef.current).length < pages.length) { - return; - } - setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current))); - }; - - const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; - - // FlatList's `onViewableItemsChanged` must keep a stable identity (it errors otherwise). - // The handler reads the latest `onPageChange` via a ref so the callback identity never changes. - const onPageChangeRef = useRef(onPageChange); - useEffect(() => { - onPageChangeRef.current = onPageChange; - }, [onPageChange]); - - const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]}) => { - const entry = viewableItems.at(0); - if (entry?.index == null || entry.index === lastReportedPage.current) { - return; - } - lastReportedPage.current = entry.index; - setCurrentPage(entry.index); - onPageChangeRef.current?.(entry.index); - }; - - const advanceCarousel = () => { - horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true}); - }; - - const goBack = () => { - if (currentPage <= 0) { - return; - } - horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true}); - }; - - const confirmPage = () => { - if (currentPage < pages.length - 1) { - advanceCarousel(); - return; - } - onConfirm?.(false); - }; - - useEffect(() => { - if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) { - return; - } - scrollViewRef.current?.scrollToEnd({animated: false}); - }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]); - - const Wrapper = shouldUseScrollView ? ScrollView : View; - - const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}; - - const carouselPaginationDots = pages.map((_page, index) => ( - - )); - - const currentPageData = pages.at(currentPage); - - return ( - { - const newWidth = e.nativeEvent.layout.width; - if (newWidth === carouselViewportWidth || newWidth <= 0) { - return; - } - setCarouselViewportWidth(newWidth); - if (!shouldUseScrollView) { - return; - } - setContainerHeight(e.nativeEvent.layout.height); - }} - onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined} - // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking - fsClass={CONST.FULLSTORY.CLASS.UNMASK} - > - {carouselViewportWidth > 0 && - contentMinHeight === undefined && ( - // Probe layer is used to measure the tallest page to lock the modal height - // when moving between pages with different content lengths. - - {pages.map((page, index) => ( - - - - ))} - - )} - {carouselViewportWidth > 0 && ( - <> - - `FeatureTrainingContentIllustration-${index}`} - horizontal - pagingEnabled - disableIntervalMomentum - snapToInterval={carouselViewportWidth} - decelerationRate="fast" - bounces={false} - showsHorizontalScrollIndicator={false} - keyboardShouldPersistTaps="handled" - viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG} - onViewableItemsChanged={onViewableItemsChanged} - getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})} - renderItem={({item: page, index}) => ( - - - - )} - /> - - {carouselPaginationDots} - - - 0} - onBack={goBack} - titleStyles={titleStyles} - contentInnerContainerStyles={[contentInnerContainerStyles, contentMinHeight !== undefined && {minHeight: contentMinHeight}]} - contentOuterContainerStyles={contentOuterContainerStyles} - shouldRenderHTMLDescription={shouldRenderHTMLDescription} - /> - - - - - - - - - )} - - ); -} - -export default FeatureTrainingCarousel; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx deleted file mode 100644 index 792e4e070372..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import ScrollView from '@components/ScrollView'; - -import useKeyboardState from '@hooks/useKeyboardState'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - -// eslint-disable-next-line no-restricted-imports -- type-only import from react-native -import type {LayoutChangeEvent, ScrollView as RNScrollView} from 'react-native'; - -import React, {useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; - -import type {FeatureTrainingContentProps} from './types'; - -import FeatureTrainingContentBody from './FeatureTrainingContentBody'; -import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration'; - -function FeatureTrainingContent({ - illustrationInnerContainerStyle, - illustrationOuterContainerStyle, - illustrationAspectRatio: illustrationAspectRatioProp, - width = variables.featureTrainingModalWidth, - title = '', - subtitle = '', - description = '', - titleStyles, - shouldShowDismissModalOption = false, - confirmText = '', - onConfirm, - onClose, - onWillShowAgainChange, - helpText = '', - onHelp, - children, - contentInnerContainerStyles, - contentOuterContainerStyles, - shouldRenderHTMLDescription = false, - shouldCloseOnConfirm = true, - shouldUseScrollView: shouldUseScrollViewProp = false, - helpSentryLabel, - confirmSentryLabel, - ...props -}: FeatureTrainingContentProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - const {windowHeight, windowWidth} = useWindowDimensions(); - const [willShowAgain, setWillShowAgain] = useState(true); - const scrollViewRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(0); - const [contentHeight, setContentHeight] = useState(0); - const insets = useSafeAreaInsets(); - const {isKeyboardActive} = useKeyboardState(); - const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); - - const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; - - const toggleWillShowAgain = () => { - onWillShowAgainChange?.(!willShowAgain); - setWillShowAgain((prev) => !prev); - }; - - const handleConfirm = () => { - onConfirm?.(willShowAgain); - if (shouldCloseOnConfirm) { - onClose?.(); - } - }; - - useEffect(() => { - if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) { - return; - } - scrollViewRef.current?.scrollToEnd({animated: false}); - }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]); - - const Wrapper = shouldUseScrollView ? ScrollView : View; - - const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}; - - return ( - setContainerHeight(e.nativeEvent.layout.height) : undefined} - onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined} - // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking - fsClass={CONST.FULLSTORY.CLASS.UNMASK} - > - - - {children} - - - ); -} - -export default FeatureTrainingContent; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx deleted file mode 100644 index 912bc7f3339e..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import Button from '@components/ButtonComposed'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; - -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import CONST from '@src/CONST'; - -import React from 'react'; -import {View} from 'react-native'; - -import type {FeatureTrainingContentBodyProps as BaseFeatureTrainingContentBodyProps, BaseFeatureTrainingContentProps} from './types'; - -import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText'; - -type FeatureTrainingModalContentProps = Pick< - BaseFeatureTrainingContentProps, - | 'helpText' - | 'onConfirm' - | 'onHelp' - | 'helpSentryLabel' - | 'confirmSentryLabel' - | 'shouldShowDismissModalOption' - | 'titleStyles' - | 'contentInnerContainerStyles' - | 'contentOuterContainerStyles' - | 'shouldRenderHTMLDescription' - | 'children' -> & - BaseFeatureTrainingContentBodyProps & { - /** Whether the modal should be shown again (drives the dismiss checkbox state) */ - willShowAgain?: boolean; - - /** Callback when the "Don't show me this again" option is toggled */ - toggleWillShowAgain?: () => void; - - /** Whether to render a Back button (carousel mode, non-first pages) */ - shouldShowBackButton?: boolean; - - /** Callback when the Back button is pressed */ - onBack?: () => void; - - /** Whether the confirm button should show a spinner immediately on press */ - shouldShowLoadingImmediatelyOnPress?: boolean; - }; - -function FeatureTrainingContentBody({ - title = '', - subtitle = '', - description = '', - confirmText, - onConfirm, - helpText = '', - onHelp, - helpSentryLabel, - confirmSentryLabel, - shouldShowDismissModalOption = false, - willShowAgain = false, - toggleWillShowAgain, - shouldShowBackButton = false, - onBack, - shouldShowLoadingImmediatelyOnPress, - titleStyles, - contentInnerContainerStyles, - contentOuterContainerStyles, - shouldRenderHTMLDescription = false, - children, -}: FeatureTrainingModalContentProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - return ( - - - {children} - - {shouldShowDismissModalOption && ( - - )} - {!!helpText && ( - - )} - - {shouldShowBackButton && ( - - )} - onConfirm?.(willShowAgain)} - buttonText={confirmText} - enabledWhenOffline - shouldShowLoadingImmediatelyOnPress={shouldShowLoadingImmediatelyOnPress} - sentryLabel={confirmSentryLabel} - containerStyles={styles.flex1} - /> - - - ); -} - -export default FeatureTrainingContentBody; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx deleted file mode 100644 index 757fd4f0db7f..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import RenderHTML from '@components/RenderHTML'; -import Text from '@components/Text'; - -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; - -import React from 'react'; -import {View} from 'react-native'; - -type FeatureTrainingModalTextContentProps = { - /** Title for the modal */ - title?: string | React.ReactNode; - - /** Subtitle for the modal */ - subtitle?: string; - - /** Describe what is showing */ - description?: string; - - /** Style for the title */ - titleStyles?: StyleProp; - - /** Styles applied to the inner text container */ - contentInnerContainerStyles?: StyleProp; - - /** Whether description is HTML markup */ - shouldRenderHTMLDescription?: boolean; - - /** Children rendered below the description (single-page mode only) */ - children?: React.ReactNode; - - /** onLayout hook — used by the carousel probe to measure the tallest page */ - onLayout?: (event: LayoutChangeEvent) => void; -}; - -function FeatureTrainingContentBodyText({ - title = '', - subtitle = '', - description = '', - titleStyles, - contentInnerContainerStyles, - shouldRenderHTMLDescription = false, - children, - onLayout, -}: FeatureTrainingModalTextContentProps) { - const styles = useThemeStyles(); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - - if (!title || !description) { - return null; - } - - return ( - - {!!subtitle && {subtitle}} - {typeof title === 'string' ? {title} : title} - {shouldRenderHTMLDescription ? ( - - - - ) : ( - {description} - )} - {children} - - ); -} - -export default FeatureTrainingContentBodyText; diff --git a/src/components/FeatureTrainingContent/index.tsx b/src/components/FeatureTrainingContent/index.tsx deleted file mode 100644 index ce07f8f6eb2c..000000000000 --- a/src/components/FeatureTrainingContent/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {FeatureTrainingContentDataProps, FeatureTrainingContentProps} from './types'; - -import FeatureTrainingCarousel from './FeatureTrainingCarousel'; -import FeatureTrainingContent from './FeatureTrainingContent'; - -export default FeatureTrainingContent; -export {FeatureTrainingCarousel}; -export type {FeatureTrainingContentProps, FeatureTrainingContentDataProps}; diff --git a/src/components/FeatureTrainingContent/types.ts b/src/components/FeatureTrainingContent/types.ts deleted file mode 100644 index 4ec58b1fb41b..000000000000 --- a/src/components/FeatureTrainingContent/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type ImageSVGProps from '@components/ImageSVG/types'; -import type DotLottieAnimation from '@components/LottieAnimations/types'; - -import type IconAsset from '@src/types/utils/IconAsset'; - -import type {ImageContentFit} from 'expo-image'; -import type {ReactNode} from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import type {MergeExclusive} from 'type-fest'; - -type BaseFeatureTrainingContentProps = { - /** The aspect ratio to preserve for the icon, video or animation */ - illustrationAspectRatio?: number; - - /** Style for the inner container of the animation */ - illustrationInnerContainerStyle?: StyleProp; - - /** Style for the outer container of the animation */ - illustrationOuterContainerStyle?: StyleProp; - - /** Style for the title */ - titleStyles?: StyleProp; - - /** Whether to show `Don't show me this again` option */ - shouldShowDismissModalOption?: boolean; - - /** A callback to call when user confirms the tutorial */ - onConfirm?: (willShowAgain: boolean) => void; - - /** A callback to call when modal closes */ - onClose?: () => void; - - /** Called whenever the "don't show again" checkbox value changes */ - onWillShowAgainChange?: (willShowAgain: boolean) => void; - - /** Text to show on secondary button */ - helpText?: string; - - /** Link to navigate to when user wants to learn more */ - onHelp?: () => void; - - /** Styles for the content container */ - contentInnerContainerStyles?: StyleProp; - - /** Styles for the content outer container */ - contentOuterContainerStyles?: StyleProp; - - /** Styles for the modal inner container */ - modalInnerContainerStyle?: ViewStyle; - - /** Children to show below title and description and above buttons (single-page mode only) */ - children?: ReactNode; - - /** Modal width */ - width?: number; - - /** Whether to disable the modal */ - isModalDisabled?: boolean; - - /** Whether the modal description is written in HTML */ - shouldRenderHTMLDescription?: boolean; - - /** Whether the modal will be closed on confirm */ - shouldCloseOnConfirm?: boolean; - - /** Whether the modal should avoid the keyboard */ - avoidKeyboard?: boolean; - - /** Whether the modal content is scrollable */ - shouldUseScrollView?: boolean; - - /** Whether to navigate back when closing the modal */ - shouldGoBack?: boolean; - - /** Whether to call onHelp when modal is hidden completely */ - shouldCallOnHelpWhenModalHidden?: boolean; - - /** Sentry label for the help/skip button */ - helpSentryLabel?: string; - - /** Sentry label for the confirm/submit button */ - confirmSentryLabel?: string; -}; - -type FeatureTrainingContentBodyProps = { - /** Title for the modal */ - title?: string | React.ReactNode; - - /** Subtitle for the modal */ - subtitle?: string; - - /** Describe what is showing */ - description?: string; - - /** Text to show on primary button */ - confirmText: string; -}; - -type FeatureTrainingContentIllustrationVideoProps = { - /** Animation to show when video is unavailable. Useful when app is offline */ - animation?: DotLottieAnimation; - - /** Additional styles for the animation */ - animationStyle?: StyleProp; - - /** URL for the video */ - videoURL?: string; -}; - -type FeatureTrainingContentIllustrationSVGProps = { - /** Expensicon for the page */ - image: IconAsset; - - /** Determines how the image should be resized to fit its container */ - contentFitImage?: ImageContentFit; - - /** The width of the image */ - imageWidth?: ImageSVGProps['width']; - - /** The height of the image */ - imageHeight?: ImageSVGProps['height']; -}; - -// Illustration requires either an icon or a video/animation, but not both. -type FeatureTrainingContentIllustrationProps = MergeExclusive; - -type FeatureTrainingContentDataProps = FeatureTrainingContentIllustrationProps & FeatureTrainingContentBodyProps; - -// Single-page mode -type FeatureTrainingContentProps = BaseFeatureTrainingContentProps & FeatureTrainingContentDataProps; - -// Carousel mode -type FeatureTrainingCarouselProps = BaseFeatureTrainingContentProps & { - /** Renders a horizontal paging carousel */ - pages: FeatureTrainingContentDataProps[]; - - /** Called when the user swipes to a different page. */ - onPageChange?: (index: number) => void; -}; - -export type { - BaseFeatureTrainingContentProps, - FeatureTrainingContentBodyProps, - FeatureTrainingContentIllustrationProps, - FeatureTrainingContentDataProps, - FeatureTrainingContentProps, - FeatureTrainingCarouselProps, -}; diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index 93a76defce17..792aff26098b 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -10,23 +10,48 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {ReactNode} from 'react'; import type {ViewStyle} from 'react-native'; import React, {useEffect, useRef, useState} from 'react'; -import type {FeatureTrainingContentProps} from './FeatureTrainingContent'; - -import FeatureTrainingContent from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import Modal from './Modal'; const MODAL_PADDING = variables.spacing2; -type FeatureTrainingModalProps = FeatureTrainingContentProps & { - /** Styles for the modal inner container */ +type FeatureTrainingModalProps = { + /** Called when the user confirms the tutorial */ + onConfirm?: (willShowAgain: boolean) => void; + + /** Called when the modal closes */ + onClose?: () => void; + + /** Whether the modal should close after confirm */ + shouldCloseOnConfirm?: boolean; + + /** Whether the modal content should render inside a ScrollView */ + shouldUseScrollView?: boolean; + + /** Modal content width */ + width?: number; + + /** Style for the modal inner container */ modalInnerContainerStyle?: ViewStyle; + + /** Composed feature training content */ + children?: ReactNode; }; -function FeatureTrainingModal({modalInnerContainerStyle, onConfirm, onClose, shouldUseScrollView: shouldUseScrollViewProp = false, ...contentProps}: FeatureTrainingModalProps) { +function FeatureTrainingModal({ + modalInnerContainerStyle, + onConfirm, + onClose, + shouldCloseOnConfirm = true, + shouldUseScrollView: shouldUseScrollViewProp = false, + width, + children, +}: FeatureTrainingModalProps) { const styles = useThemeStyles(); const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const {windowWidth, windowHeight} = useWindowDimensions(); @@ -91,12 +116,20 @@ function FeatureTrainingModal({modalInnerContainerStyle, onConfirm, onClose, sho shouldDisableBottomSafeAreaPadding={shouldUseScrollView} shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={!shouldUseScrollView} > - { + onConfirm?.(willShowAgain); + if (!shouldCloseOnConfirm) { + return; + } + closeModal(); + }} onClose={closeModal} - /> + width={width} + > + {children} + ); } diff --git a/src/components/Form/InputWrapper.tsx b/src/components/Form/InputWrapper.tsx index 2c094d04673d..a2c2925035d7 100644 --- a/src/components/Form/InputWrapper.tsx +++ b/src/components/Form/InputWrapper.tsx @@ -31,10 +31,11 @@ function computeComponentSpecificRegistrationParams({ shouldSubmitForm, multiline, autoGrowHeight, + autoGrowSingleLine, submitBehavior, }: InputComponentBaseProps): ComputedComponentSpecificRegistrationParams { if (textInputBasedComponents.has(InputComponent)) { - const isEffectivelyMultiline = !!multiline || !!autoGrowHeight; + const isEffectivelyMultiline = !!multiline || !!autoGrowHeight || !!autoGrowSingleLine; // If the user can use the hardware keyboard, they have access to an alternative way of inserting a new line // (like a Shift+Enter keyboard shortcut). For simplicity, we assume that when there's no touch screen, it's a @@ -44,7 +45,9 @@ function computeComponentSpecificRegistrationParams({ // We want to avoid a situation when the user can't insert a new line. For single-line inputs, it's not a problem and we // force-enable form submission. For multi-line inputs, ensure that it was requested to enable form submission for this specific // input and that alternative ways exist to add a new line. - const shouldReallySubmitForm = isEffectivelyMultiline ? !!shouldSubmitForm && canUseHardwareKeyboard : true; + // `autoGrowSingleLine` inputs only render as multi-line so their value can wrap - a new line is never a valid + // value there, so nothing has to be reserved for inserting one and the return key always submits. + const shouldReallySubmitForm = !!autoGrowSingleLine || (isEffectivelyMultiline ? !!shouldSubmitForm && canUseHardwareKeyboard : true); return { // There are inputs that don't have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 2f3d3d3e29c4..e57c368c89bf 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -124,6 +124,7 @@ type InputComponentBaseProps = Input ref?: Ref; multiline?: boolean; autoGrowHeight?: boolean; + autoGrowSingleLine?: boolean; submitBehavior?: SubmitBehavior; shouldSubmitForm?: boolean; uncontrolled?: boolean; diff --git a/src/components/HoldOrRejectEducationalModal.tsx b/src/components/HoldOrRejectEducationalModal.tsx index 78e8918dd125..c37657c4dd95 100644 --- a/src/components/HoldOrRejectEducationalModal.tsx +++ b/src/components/HoldOrRejectEducationalModal.tsx @@ -12,6 +12,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; import {View} from 'react-native'; +import FeatureTraining from './FeatureTraining'; import FeatureTrainingModal from './FeatureTrainingModal'; import Icon from './Icon'; import Text from './Text'; @@ -60,39 +61,42 @@ function HoldOrRejectEducationalModal({onClose, onConfirm}: HoldOrRejectEducatio return ( - <> - {menuSections.map((section) => ( - - - - {translate(section.titleTranslationKey)} + + + + {translate('iou.reject.educationalTitle')} + {translate('iou.reject.educationalText')} + {menuSections.map((section) => ( + + + + {translate(section.titleTranslationKey)} + - - ))} - + ))} + + {translate('common.buttonConfirm')} + ); } diff --git a/src/components/HoldSubmitterEducationalModal.tsx b/src/components/HoldSubmitterEducationalModal.tsx index 8268db0cf60f..39c7e429ce7c 100644 --- a/src/components/HoldSubmitterEducationalModal.tsx +++ b/src/components/HoldSubmitterEducationalModal.tsx @@ -9,6 +9,7 @@ import CONST from '@src/CONST'; import React from 'react'; +import FeatureTraining from './FeatureTraining'; import FeatureTrainingModal from './FeatureTrainingModal'; import HoldMenuSectionList from './HoldMenuSectionList'; @@ -32,22 +33,27 @@ function HoldSubmitterEducationalModal({onClose, onConfirm, isDM}: HoldSubmitter return ( - + + + + {translate('iou.holdEducationalTitle')} + {translate(isDM ? 'iou.whatIsHoldExplainDM' : 'iou.whatIsHoldExplain')} + + + {translate('common.buttonConfirm')} + ); } diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index 33fe063557aa..cc7baaef5002 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -65,6 +65,7 @@ import EarthWithControls from '@assets/images/product-illustrations/earth-with-c import EmptyStateExpenses from '@assets/images/product-illustrations/emptystate__expenses.svg'; import HoldExpense from '@assets/images/product-illustrations/emptystate__holdexpense.svg'; import ReceiptFairy from '@assets/images/product-illustrations/emptystate__receiptfairy.svg'; +import EmptyStateSearchExpenses from '@assets/images/product-illustrations/emptystate__searchexpenses.svg'; import EmptyStateTravel from '@assets/images/product-illustrations/emptystate__travel.svg'; import Fireworks from '@assets/images/product-illustrations/fireworks.svg'; import FolderWithPapersAndWatch from '@assets/images/product-illustrations/folder-with-papers-and-watch.svg'; @@ -280,6 +281,7 @@ const Illustrations = { BrokenCompanyCardBankConnection, BrokenMagnifyingGlass, DjBoothReferenceHands, + EmptyStateSearchExpenses, EmptyStateExpenses, HoldExpense, ReceiptFairy, diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index 1d68d9f83ade..f85edfe5685a 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -347,4 +347,5 @@ function ImportColumn({column, columnName, columnRoles, columnIndex, shouldShowD } export type {ColumnRole}; +export {findColumnName}; export default ImportColumn; diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index dd5c71edba6e..a5b363a755ca 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -177,7 +177,7 @@ function LocaleContextProvider({children}: LocaleContextProviderProps) { const formatTravelDate: LocaleContextProps['formatTravelDate'] = (datetime) => { const date = new Date(datetime); const formattedDate = formatDate(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, {locale: dateFnsLocale}); - const formattedHour = formatDate(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale}); + const formattedHour = DateUtils.formatTimeWithPeriod(translate, date); const at = translateLocalize(currentLocale, 'common.conjunctionAt'); return `${formattedDate} ${at} ${formattedHour}`; }; diff --git a/src/components/MigratedUserWelcomeModal.tsx b/src/components/MigratedUserWelcomeModal.tsx index f00c73334c4d..f8be8df92a94 100644 --- a/src/components/MigratedUserWelcomeModal.tsx +++ b/src/components/MigratedUserWelcomeModal.tsx @@ -25,7 +25,7 @@ import {View} from 'react-native'; import type {FeatureListItem} from './FeatureList'; import CenteredModalLayout from './CenteredModalLayout'; -import FeatureTrainingContent from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import Icon from './Icon'; import LottieAnimations from './LottieAnimations'; import RenderHTML from './RenderHTML'; @@ -71,14 +71,6 @@ function MigratedUserWelcomeModal() { }); }; - const illustrationProps = isReduceMotionEnabled - ? {image: illustrations.PlanetWithMobileApp} - : { - videoURL: '', - animation: LottieAnimations.WorkspacePlanet, - animationStyle: [styles.emptyWorkspaceIllustrationStyle], - }; - const onHelp = () => { Log.info('[MigratedUserWelcomeModal] onHelp called, opening help URL based on admin status and device type'); const adminUrl = shouldUseNarrowLayout ? CONST.STORYLANE.ADMIN_MIGRATED_MOBILE : CONST.STORYLANE.ADMIN_MIGRATED; @@ -93,41 +85,55 @@ function MigratedUserWelcomeModal() { onBackdropPress={handleClose} contentStyle={[styles.pt0, styles.pb0]} > - - - {ExpensifyFeatures.map(({translationKey, icon}) => ( + {isReduceMotionEnabled ? ( + + ) : ( + + )} + + + {translate('migratedUserWelcomeModal.title')} + {translate('migratedUserWelcomeModal.subtitle')} - - - ${convertToLTR(translate(translationKey))}`} /> - + {ExpensifyFeatures.map(({translationKey, icon}) => ( + + + + ${convertToLTR(translate(translationKey))}`} /> + + + ))} - ))} - - + + {translate('migratedUserWelcomeModal.helpText')} + {translate('migratedUserWelcomeModal.confirmText')} + + ); } diff --git a/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx b/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx index 6aeb55dfbcbc..7ce6aae8f946 100644 --- a/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx @@ -31,7 +31,7 @@ type PerDiemFieldsProps = { function PerDiemFields({perDiemCustomUnit, transaction, isReadOnly, didConfirm, transactionID, shouldDisplayFieldError, formError}: PerDiemFieldsProps) { const styles = useThemeStyles(); - const {translate, dateFnsLocale} = useLocalize(); + const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'CalendarSolid']); const subRates = getSubratesFields(perDiemCustomUnit, transaction); @@ -114,7 +114,7 @@ function PerDiemFields({perDiemCustomUnit, transaction, isReadOnly, didConfirm, { - if (!reportId) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs); - }, - }, - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, - text: translate('iou.trackDistance'), - icon: expensifyIcons.Location, - onSelected: () => { - if (!reportId) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs, lastDistanceExpenseType); - }, - }, + // Teachers Unite doesn't support reimbursement, so "Create expense" and "Track distance" are hidden for those reports. + ...(isReportTeachersUnite + ? [] + : [ + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, + text: translate('iou.createExpense'), + icon: expensifyIcons.Plus, + onSelected: () => { + if (!reportId) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs); + }, + }, + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, + text: translate('iou.trackDistance'), + icon: expensifyIcons.Location, + onSelected: () => { + if (!reportId) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + if (blockDistanceRequestIfNeeded()) { + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs, lastDistanceExpenseType); + }, + }, + ]), { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, text: translate('iou.addExistingExpense'), diff --git a/src/components/OnboardingHelpDropdownButton.tsx b/src/components/OnboardingHelpDropdownButton.tsx index 09e2e35b229b..f30cd188330b 100644 --- a/src/components/OnboardingHelpDropdownButton.tsx +++ b/src/components/OnboardingHelpDropdownButton.tsx @@ -87,11 +87,10 @@ function OnboardingHelpDropdownButton({reportID, shouldUseNarrowLayout, shouldSh {locale: dateFnsLocale}, )}`, value: CONST.ONBOARDING_HELP.EVENT_TIME, - description: `${DateUtils.formatInTimeZoneWithFallback(latestScheduledCall.eventTime, userTimezone, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})} - ${DateUtils.formatInTimeZoneWithFallback( + description: `${DateUtils.formatTimeInTimeZoneWithPeriod(translate, latestScheduledCall.eventTime, userTimezone)} - ${DateUtils.formatTimeInTimeZoneWithPeriod( + translate, addMinutes(latestScheduledCall.eventTime, 30), userTimezone, - CONST.DATE.LOCAL_TIME_FORMAT, - {locale: dateFnsLocale}, )} ${DateUtils.getZoneAbbreviation(new Date(latestScheduledCall.eventTime), userTimezone)}`, descriptionTextStyle: [styles.themeTextColor, styles.ml2], displayInDefaultIconColor: true, diff --git a/src/components/Onfido/BaseOnfidoWeb.tsx b/src/components/Onfido/BaseOnfidoWeb.tsx index 4cdb2f3731c3..a806fffbe89a 100644 --- a/src/components/Onfido/BaseOnfidoWeb.tsx +++ b/src/components/Onfido/BaseOnfidoWeb.tsx @@ -29,7 +29,7 @@ type OnfidoEvent = Event & { }; function initializeOnfido({sdkToken, onSuccess, onError, onUserExit, preferredLocale, translate, theme}: InitializeOnfidoProps) { - OnfidoSDK.init({ + return OnfidoSDK.init({ token: sdkToken, containerId: CONST.ONFIDO.CONTAINER_ID, customUI: { @@ -147,7 +147,7 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit, ref}: OnfidoProps) { const theme = useTheme(); useEffect(() => { - initializeOnfido({ + const onfidoOut = initializeOnfido({ sdkToken, onSuccess, onError, @@ -158,7 +158,13 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit, ref}: OnfidoProps) { }); window.addEventListener('userAnalyticsEvent', logOnFidoEvent); - return () => window.removeEventListener('userAnalyticsEvent', logOnFidoEvent); + return () => { + window.removeEventListener('userAnalyticsEvent', logOnFidoEvent); + // Tear down the Onfido SDK so its modal is removed from the DOM when the component unmounts + // (e.g. when the RHP is closed before verification is completed). Otherwise the SDK's modal + // remains orphaned on screen. See https://github.com/Expensify/App/issues/98633 + onfidoOut.tearDown(); + }; // Onfido should be initialized only once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/Onfido/index.tsx b/src/components/Onfido/index.tsx index 439d9f47fcaa..290f0ca4cfb0 100644 --- a/src/components/Onfido/index.tsx +++ b/src/components/Onfido/index.tsx @@ -8,8 +8,6 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit}: OnfidoProps) { const baseOnfidoRef = useRef(null); useEffect(() => { - const onfidoOut = baseOnfidoRef.current?.onfidoOut; - const observer = new MutationObserver(() => { const fidoRef = baseOnfidoRef.current; /** This condition is needed because we are using external embedded content and they are @@ -31,20 +29,12 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit}: OnfidoProps) { observer.observe(baseOnfidoRef.current, {attributes: false, childList: true, subtree: true}); } - if (!onfidoOut) { - return; - } - - onfidoOut.tearDown(); - // Clean up function to remove the observer when component unmounts return () => { observer.disconnect(); }; }, []); - useEffect(() => {}, []); - return ( ; }; -type OnfidoElement = HTMLDivElement & {onfidoOut?: Handle}; +type OnfidoElement = HTMLDivElement; type OnfidoProps = { /** Token used to initialize the Onfido SDK */ diff --git a/src/components/ReportActionItem/ChronosOOOListActions.tsx b/src/components/ReportActionItem/ChronosOOOListActions.tsx index 62e5529e35c0..7dc85abac220 100644 --- a/src/components/ReportActionItem/ChronosOOOListActions.tsx +++ b/src/components/ReportActionItem/ChronosOOOListActions.tsx @@ -60,7 +60,7 @@ function ChronosOOOListActions({reportID, action}: ChronosOOOListActionsProps) { : translate( 'chronos.oooEventSummaryPartialDay', event.summary, - `${DateUtils.formatToLocalTime(start, dateFnsLocale)} - ${DateUtils.formatToLocalTime(end, dateFnsLocale)}`, + `${DateUtils.formatToLocalTime(translate, start)} - ${DateUtils.formatToLocalTime(translate, end)}`, DateUtils.formatToLongDateWithWeekday(end, dateFnsLocale), )} diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index c059f04944fb..a5e8196bee7b 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -9,7 +9,6 @@ import ReceiptEmptyState from '@components/ReceiptEmptyState'; import ReceiptHoverZoom from '@components/ReceiptHoverZoom'; import Tooltip from '@components/Tooltip'; -import useActiveRoute from '@hooks/useActiveRoute'; import useAncestors from '@hooks/useAncestors'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -36,6 +35,7 @@ import {getBrokenConnectionUrlToFixPersonalCard} from '@libs/CardUtils'; import {hasHoverSupport} from '@libs/DeviceCapabilities'; import {getMicroSecondOnyxErrorObject, getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {isGroupPolicyByType} from '@libs/PolicyUtils'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {getOriginalMessage, isMoneyRequestAction, wasActionTakenByCurrentUser} from '@libs/ReportActionsUtils'; @@ -72,7 +72,7 @@ import {clearError, getLastModifiedExpense, revert} from '@userActions/Transacti import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -142,7 +142,6 @@ function MoneyRequestReceiptView({ const {convertToDisplayString, getCurrencyDecimals} = useCurrencyListActions(); const {environmentURL} = useEnvironment(); const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); - const {getReportRHPActiveRoute} = useActiveRoute(); const route = useRoute(); const routeBackTo = (route.params as {backTo?: string} | undefined)?.backTo; const parentReportID = report?.parentReportID; @@ -602,7 +601,7 @@ function MoneyRequestReceiptView({ return; } Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID)), ); }} isThumbnail={!canEditReceipt} diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx index d7ffca5cf536..618eb3256f4f 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx @@ -1,5 +1,6 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -26,6 +27,12 @@ function AddExpenseActionButton() { const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); return ( ; - - parentReportID?: string; - - /** Policy that the report belongs to */ - expensePolicy: OnyxEntry; - - /** Whether we should display the animated banner above the component */ - shouldShowAnimatedBackground: boolean; - - /** Whether we should show Money Request with disabled all fields */ - readonly?: boolean; - - /** whether this report is from review duplicates */ - isFromReviewDuplicates?: boolean; - - /** Updated transaction to show in duplicate & merge transaction flow */ - updatedTransaction?: OnyxEntry; - - /** Merge transaction ID to show in merge transaction flow */ - mergeTransactionID?: string; -}; - -const perDiemPoliciesSelector = (policies: OnyxCollection) => { - return Object.fromEntries( - Object.entries(policies ?? {}).filter(([, policy]) => { - const perDiemCustomUnit = getPerDiemCustomUnit(policy); - const hasPolicyPerDiemRates = !isEmptyObject(perDiemCustomUnit?.rates); - - return isPerDiemEnabled(policy) && hasPolicyPerDiemRates; - }), - ); -}; - -function MoneyRequestView({ - transactionThreadReport, - parentReportID, - expensePolicy, - shouldShowAnimatedBackground, - readonly = false, - updatedTransaction, - isFromReviewDuplicates = false, - mergeTransactionID, -}: MoneyRequestViewProps) { - const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase', 'NewWindow']); - const styles = useThemeStyles(); - const theme = useTheme(); - const StyleUtils = useStyleUtils(); - const {isOffline} = useNetwork(); - const {environmentURL} = useEnvironment(); - const {translate, toLocaleDigit, localeCompare, dateFnsLocale} = useLocalize(); - const {convertToDisplayString, getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); - const {getReportRHPActiveRoute} = useActiveRoute(); - const {showConfirmModal} = useConfirmModal(); - const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); - - const {currentSearchResults} = useSearchResultsContext(); - - // When this component is used when merging from the search page, we might not have the parent report stored in the main collection - const [parentReportFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); - const parentReport = parentReportFromOnyx ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); - const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); - const parentReportAction = transactionThreadReport?.parentReportActionID ? parentReportActions?.[transactionThreadReport.parentReportActionID] : undefined; - - const isFromMergeTransaction = !!mergeTransactionID; - const linkedTransactionID = parentReportAction && isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; - const [onyxTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(linkedTransactionID)}`); - const transaction = updatedTransaction ?? onyxTransaction; - const isExpenseUnreported = isExpenseUnreportedTransactionUtils(transaction); - const personalPolicy = usePersonalPolicy(); - const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy, shouldNavigateToUpgradePath} = usePolicyForMovingExpenses(); - const isTimeRequest = isTimeRequestTransactionUtils(transaction); - - const [policiesWithPerDiem] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - selector: perDiemPoliciesSelector, - }); - const splitEffectivePolicy = useSplitEffectivePolicy(transactionThreadReport, undefined, transaction); - const isPerDiemRequest = isPerDiemRequestTransactionUtils(transaction); - const perDiemOriginalPolicy = getPolicyByCustomUnitID(transaction, policiesWithPerDiem); - - const customUnitRateID = isDistanceRequestTransactionUtils(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; - const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(expensePolicy, customUnitRateID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); - - let policy; - let policyID; - // If the expense is unreported the policy should be the user's default policy, if the expense is a per diem request and is unreported - // the policy should be the one where the per diem rates are enabled, otherwise it should be the expense's report policy - if (isExpenseUnreported && !isPerDiemRequest) { - policy = policyForMovingExpenses; - policyID = policyForMovingExpensesID; - } else if (isExpenseUnreported && isPerDiemRequest) { - policy = perDiemOriginalPolicy; - policyID = perDiemOriginalPolicy?.id; - } else { - policy = expensePolicy; - policyID = parentReport?.policyID; - } - - // Use the report's real policy, not `policy` above (swapped to an unrelated workspace for - // unreported expenses), else self-DM split editing wrongly redirects to RESTRICTED_ACTION. - const restrictedActionPolicyID = useRestrictedActionPolicyID(expensePolicy); - - const allPolicyCategories = usePolicyCategories(); - const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; - const targetPolicyID = updatedTransaction?.reportID ? parentReport?.policyID : policyID; - const allPolicyTags = usePolicyTags(); - const policyTagList = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`]; - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); - - const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${getNonEmptyStringOnyxID(linkedTransactionID)}`); - const transactionViolations = useTransactionViolations(transaction?.transactionID, true, distanceOriginalPolicy ?? policy); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const delegateAccountID = useDelegateAccountID(); - const personalDetailsList = usePersonalDetails(); - const currentUserAccountIDParam = currentUserPersonalDetails.accountID; - const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const {isBetaEnabled} = usePermissions(); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const isP2PDistanceRequest = isCustomUnitRateIDForP2P(transaction); - const moneyRequestReport = parentReport; - const parentReportTransactions = useReportTransactions(moneyRequestReport?.reportID); - // Exclude transactions pending deletion so the report is recognized as single-expense immediately after deleting one of its expenses, - // instead of waiting for the optimistic delete to be removed from Onyx (https://github.com/Expensify/App/issues/91058). - // While offline the deleted expense is still rendered, so keep counting it to stay consistent with the visible transaction list. - const visibleParentReportTransactions = parentReportTransactions.filter((t) => isOffline || !isTransactionPendingDelete(t)); - const isApproved = isReportApproved({report: moneyRequestReport}); - const isInvoice = isInvoiceReport(moneyRequestReport); - const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); - - let iouType: ValueOf; - if (isTrackExpense) { - iouType = CONST.IOU.TYPE.TRACK; - } else if (isInvoice) { - iouType = CONST.IOU.TYPE.INVOICE; - } else { - iouType = CONST.IOU.TYPE.SUBMIT; - } - - const allowNegativeAmount = shouldEnableNegative(parentReport, policy, iouType); - - const { - created: transactionDate, - amount: transactionAmount, - taxAmount: transactionTaxAmount, - currency: transactionCurrency, - comment: transactionDescription, - merchant: transactionMerchant, - reimbursable: transactionReimbursable, - billable: transactionBillable, - category: transactionCategory, - tag: transactionTag, - originalCurrency: transactionOriginalCurrency, - postedDate: transactionPostedDate, - convertedAmount: transactionConvertedAmount, - } = getTransactionDetails(transaction, undefined, undefined, allowNegativeAmount, false) ?? {}; - const transactionAttendees = useAttendees(transaction); - const isEmptyMerchant = isInvalidMerchantValue(transactionMerchant); - const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); - const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); - const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); - const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); - const isMapDistanceRequest = isMapDistanceRequestTransactionUtils(transaction) || isDistanceTypeRequest(transaction); - const isTransactionScanning = isScanning(updatedTransaction ?? transaction); - const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); - - const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees; - const actualAttendees = enrichAndSortAttendees(rawActualAttendees, loginToAccountIDMap, personalDetailsList, localeCompare); - - // Use the updated transaction amount in merge flow to have correct positive/negative sign - const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount; - const actualCurrency = updatedTransaction ? getCurrency(updatedTransaction) : transactionCurrency; - const shouldDisplayTransactionAmount = (isDistanceRequest && hasRoute) || !isDistanceRequest; - const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount, actualCurrency) : ''; - const formattedPerAttendeeAmount = - shouldDisplayTransactionAmount && actualAmount !== undefined ? convertToDisplayString(actualAmount / (transactionAttendees?.length ?? 1), actualCurrency) : ''; - - const transactionOriginalAmount = transaction && getOriginalAmountForDisplay(transaction, isExpenseReport(moneyRequestReport)); - const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); - const isFromCardImport = isCardTransactionTransactionUtils(transaction); - const cardProgramName = getCompanyCardDescription(translate, transaction?.cardName, transaction?.cardID, nonPersonalAndWorkspaceCards); - const shouldShowCard = isFromCardImport && cardProgramName; - - const taxRates = policy?.taxRates; - const formattedTaxAmount = - updatedTransaction?.taxAmount !== undefined - ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency) - : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); - // Skip a zero converted tax (e.g. tax exempt) so we don't render a redundant "Converted 0.00". - const formattedConvertedTaxAmount = transaction?.convertedTaxAmount ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : ''; - - const taxRatesDescription = taxRates?.name; - - const baseTransaction = updatedTransaction ?? transaction; - const {taxCode, taxValue} = baseTransaction ?? {}; - - const taxRateTitle = getTaxName(policy, baseTransaction, isExpenseUnreported); - const resolvedTaxCode = taxCode ? resolveCurrentTaxCode(policy, taxCode) : undefined; - const selectedPolicyTaxValue = resolvedTaxCode ? policy?.taxRates?.taxes?.[resolvedTaxCode]?.value : undefined; - const hasTaxValueChanged = taxCode && taxValue !== undefined ? selectedPolicyTaxValue !== taxValue : false; - - const actualTransactionDate = isFromMergeTransaction && updatedTransaction ? getFormattedCreated(updatedTransaction) : transactionDate; - const fallbackTaxRateTitle = transaction?.taxValue; - - const isSettled = isSettledReportUtils(moneyRequestReport); - const isCancelled = moneyRequestReport?.isCancelledIOU; - const isChatReportArchived = useReportIsArchived(moneyRequestReport?.chatReportID); - const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); - const pendingAction = transaction?.pendingAction; - const shouldShowPaid = isSettled && transactionReimbursable && !pendingAction; - - // Flags for allowing or disallowing editing an expense - // Used for non-restricted fields such as: description, category, tag, billable, etc... - const isReportArchived = useReportIsArchived(transactionThreadReport?.reportID); - const isEditable = !!canUserPerformWriteActionReportUtils(transactionThreadReport, isReportArchived) && !readonly; - const canEdit = - isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, moneyRequestReport, policy, parentReportActions) && isEditable; - const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(transactionThreadReport?.policyID)}`; - const {personalCardsWithBrokenConnection} = useCardFeedErrors(); - const connectionLink = getBrokenConnectionUrlToFixPersonalCard(personalCardsWithBrokenConnection, environmentURL); - const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); - const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); - const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); - const [reportPolicyType] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`, {selector: policyTypeSelector}); - const hasMultipleSplits = useHasMultipleSplitChildren(transaction?.comment?.originalTransactionID); - const isReportOpen = isOpenReport(moneyRequestReport); - const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); - const isSplitAvailable = - moneyRequestReport && - transaction && - isSplitAction(moneyRequestReport, [transaction], originalTransaction, currentUserPersonalDetails.login ?? '', currentUserPersonalDetails.accountID, policy); - - const canEditTaxFields = canEdit && !isDistanceRequest; - const canEditAmount = - !isGPSDistanceRequest && - isEditable && - (canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, - isChatReportArchived, - reportNameValuePairs, - transaction, - }) || - (shouldShowSplitIndicator && isSplitAvailable)); - const canEditMerchant = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }); - - const canEditDate = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }); - - const canEditDistanceOrRate = isPolicyAccessible(policy, currentUserEmailParam) || isTrackExpense || isP2PDistanceRequest; - - const canEditDistance = - !isGPSDistanceRequest && - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.DISTANCE, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }) && - canEditDistanceOrRate; - - const canEditDistanceRate = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }) && - canEditDistanceOrRate; - - const canEditReport = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, - isChatReportArchived, - outstandingReportsByPolicyID, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }) && - (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(policy) || (isExpenseUnreported && !!perDiemOriginalPolicy)); - - // A flag for verifying that the current report is a sub-report of a expense chat - // if the policy of the report is either Collect or Control, then this report must be tied to expense chat - const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); - const policyTagLists = getTagLists(policyTagList); - - const category = transactionCategory ?? ''; - const categoryForDisplay = isCategoryMissing(category) ? '' : category; - - // Flags for showing categories and tags - // transactionCategory can be an empty string - - const shouldShowCategory = - (isPolicyExpenseChat && (categoryForDisplay || hasEnabledOptions(policyCategories ?? {}))) || - (isExpenseUnreported && (!policyForMovingExpenses || hasEnabledOptions(policyCategories ?? {}))); - // transactionTag can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldShowTag = (isPolicyExpenseChat || isExpenseUnreported) && (transactionTag || (canEdit && hasEnabledTags(policyTagLists))); - // Surface a delete confirmation (like tax) when the value is stale and there's nothing valid to select, instead of - // navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully emptied, - // so also cover "no enabled tags remain". Applies to multi-level tags too - deleting clears the whole tag value. - const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; - const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag; - const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); - const isCurrentTransactionReimbursableDifferentFromPolicyDefault = - policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; - const shouldShowReimbursable = - (isPolicyExpenseChat || (isExpenseUnreported && !!policy)) && - (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && - !isManagedCardTransactionTransactionUtils(transaction) && - !isInvoice; - const canEditReimbursable = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }); - const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); - - const transactionVendor = transaction?.comment?.vendor; - - // The title prefers any matched vendor name (active vendor-matching integration first, then a - // permissive cross-connection lookup), then the display name persisted on the transaction at - // match/assign time. When no connection has the vendor any more and no name was persisted, fall - // back to the raw externalID so admins still see a stable identifier for what was assigned — the - // red INACTIVE_VENDOR violation (from ViolationsUtils, scoped to the active integration) provides - // the "Vendor no longer valid" indicator separately. - const matchedVendorName = findVendorByID(policy, transactionVendor?.externalID)?.name; - let transactionVendorName = ''; - if (matchedVendorName) { - transactionVendorName = matchedVendorName; - } else if (transactionVendor?.name) { - transactionVendorName = transactionVendor.name; - } else if (transactionVendor?.externalID) { - transactionVendorName = transactionVendor.externalID; - } - const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice; - const vendorFieldLabel = isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'); - - const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); - const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; - - const transactionTripID = transaction?.comment?.tripID; - - // Trip rooms are the grandparent report, so check that first before scanning the collection. - const grandparentReportID = parentReport?.parentReportID; - const tripRoomReportSelector = (reports: OnyxCollection): OnyxEntry => { - if (!transactionTripID || !reports) { - return undefined; - } - const grandparent = grandparentReportID ? reports[`${ONYXKEYS.COLLECTION.REPORT}${grandparentReportID}`] : undefined; - const match = - grandparent?.tripData?.tripID === transactionTripID ? grandparent : Object.values(reports).find((candidateReport) => candidateReport?.tripData?.tripID === transactionTripID); - if (!match?.reportID) { - return undefined; - } - return match; - }; - const [tripRoomReport] = originalUseOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: tripRoomReportSelector}); - const tripRoomReportID = tripRoomReport?.reportID; - - const derivedReportNames = useDerivedReportNamesByReportIDs([tripRoomReportID, parentReport?.reportID]); - - const tripRoomDerivedName = tripRoomReportID ? derivedReportNames?.[tripRoomReportID] : undefined; - const tripRoomName = tripRoomReport ? getReportName(tripRoomReport, tripRoomDerivedName) : undefined; - const shouldShowTripRoomLink = !!tripRoomReportID && !!tripRoomName; - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - - const {getViolationsForField} = useViolations(transactionViolations ?? [], isTransactionScanning || !isGroupPolicyByType(reportPolicyType)); - const hasViolations = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => - getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0; - const isMarkAsCash = parentReport && currentUserEmailParam ? isMarkAsCashActionForTransaction(currentUserEmailParam, parentReport, transactionViolations, policy) : false; - // Need to return undefined when we have pendingAction to avoid the duplicate pending action - const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => (pendingAction ? undefined : transaction?.pendingFields?.[fieldPath]); - - const isTaxEnabled = isTaxTrackingEnabled(isPolicyExpenseChat || isExpenseUnreported, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); - const shouldShowTaxDisabledAlert = !isTaxEnabled && !!transaction?.taxCode && !isTimeRequest && !isPerDiemRequest; - const shouldShowTax = isFromMergeTransaction ? !!transaction?.taxName : isTaxEnabled || shouldShowTaxDisabledAlert; - - let amountDescription = `${translate('iou.amount')}`; - let dateDescription = `${translate('common.date')}`; - const { - unit, - rate, - name: rateName, - } = DistanceRequestUtils.getRate({ - transaction: updatedTransaction ?? transaction, - policy: distanceOriginalPolicy ?? policy, - personalPolicyOutputCurrency: personalPolicy?.outputCurrency, - }); - const distance = getDistanceInMeters(transactionBackup ?? updatedTransaction ?? transaction, unit); - const currency = transactionCurrency ?? CONST.CURRENCY.USD; - const hasRequiredCompanyCardViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.COMPANY_CARD_REQUIRED); - const isCustomUnitOutOfPolicy = - (transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && !rate)) && !isTrackExpense; - const calculateFromTransactionData = isTrackExpense && !rate; - const distanceUnit = calculateFromTransactionData ? transaction?.comment?.customUnit?.distanceUnit : unit; - const distanceRate = calculateFromTransactionData ? (transactionAmount ?? 0) / (transaction?.comment?.customUnit?.quantity ?? 1) : rate; - let rateToDisplay = DistanceRequestUtils.getRateForExpenseDisplay( - rateName, - isCustomUnitOutOfPolicy, - distanceUnit, - distanceRate, - currency, - translate, - toLocaleDigit, - getCurrencySymbol, - isOffline, - ); - - const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - // Commuter exclusions are a workspace control, so they are never surfaced on a personal (self-DM or P2P) expense, - // even if one carries the fields from a workspace rate it was created with. - const commuterExclusionData = - transaction?.comment?.originalTransactionID || !isPolicyExpenseChat - ? null - : DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, distanceUnitValue); - const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, translate, false, isManualDistanceRequest, commuterExclusionData); - const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate); - - let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; - let amountTitle = formattedTransactionAmount?.toString() || ''; - if (isTransactionScanning) { - merchantTitle = translate('iou.receiptStatusTitle'); - amountTitle = translate('iou.receiptStatusTitle'); - } - - const updatedTransactionDescription = getDescription(updatedTransaction) || undefined; - const shouldHideEmptyDescription = (isFromReviewDuplicates || isFromMergeTransaction) && !(updatedTransactionDescription ?? transactionDescription); - const isEmptyUpdatedMerchant = isInvalidMerchantValue(updatedTransaction?.modifiedMerchant); - const updatedMerchantTitle = isEmptyUpdatedMerchant ? '' : (updatedTransaction?.modifiedMerchant ?? merchantTitle); - const originalMerchantForGoogleSearch = isInvalidMerchantValue(onyxTransaction?.merchant) ? '' : Str.recapitalize(onyxTransaction?.merchant ?? ''); - const shouldShowGoogleMerchantSearchLink = !!originalMerchantForGoogleSearch && !isTransactionScanning && isFromCardImport; - - const renderGoogleMerchantSearchLink = () => ( - { - event?.stopPropagation(); - openExternalLink(`${CONST.GOOGLE_SEARCH_URL}${encodeURIComponent(originalMerchantForGoogleSearch)}`); - }} - style={[styles.flexRow, styles.alignItemsCenter, styles.mt1, styles.alignSelfStart]} - > - - {translate('common.googleThisMerchant', { - merchant: originalMerchantForGoogleSearch, - })} - - - - ); - - const shouldShowConvertedAmount = - transactionConvertedAmount && - currency !== moneyRequestReport?.currency && - !isFromCardImport && - transaction?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && - !isFromMergeTransaction && - !isFromReviewDuplicates && - !getPendingFieldAction('amount') && - !pendingAction; - - const saveBillable = (newBillable: boolean) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newBillable === getBillable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - updateMoneyRequestBillable({ - transactionID: transaction.transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - value: newBillable, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }; - - const saveReimbursable = (newReimbursable: boolean) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - updateMoneyRequestReimbursable({ - transactionID: transaction.transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - value: newReimbursable, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }; - - if (isFromCardImport) { - if (transactionPostedDate) { - dateDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.posted')} ${transactionPostedDate}`; - } - if (formattedOriginalAmount) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.purchase')} ${formattedOriginalAmount}`; - } - if (isCancelled) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; - } - } else if (isCancelled) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; - } else if (isApproved) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.approved')}`; - } else if (shouldShowPaid) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.settledExpensify')}`; - } - if (shouldShowSplitIndicator) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.split')}`; - } - if (shouldShowConvertedAmount) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`; - } - const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; - if (!isCurrentTransactionReimbursable && isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; - } - - // Show the converted tax here since the redundant report-level Tax total is hidden for a single-expense report. - let taxAmountDescription = translate('iou.taxAmount'); - if (shouldShowConvertedAmount && formattedConvertedTaxAmount) { - taxAmountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${formattedConvertedTaxAmount}`; - } - - if (isFromMergeTransaction && !rateName) { - // Because we lack the necessary data in policy.customUnits to determine the rate in merge flow, - // We need to extract the rate from the merchant string - // See https://github.com/Expensify/App/pull/71675#issuecomment-3425488228 for more information - rateToDisplay = getRateFromMerchant(updatedMerchantTitle); - } - - const hasErrors = hasMissingSmartscanFields(transaction, transactionReport); - const isMissingAttendeesViolation = getIsMissingAttendeesViolation( - policyCategories, - updatedTransaction?.category ?? categoryForDisplay, - actualAttendees, - currentUserPersonalDetails, - isAttendeeTrackingEnabled(policy), - policy?.type === CONST.POLICY.TYPE.CORPORATE, - ); - - const getErrorForField = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { - // Checks applied when creating a new expense - // NOTE: receipt field can return multiple violations, so we need to handle it separately - const fieldChecks: Partial> = { - merchant: { - isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, - translationPath: canEditMerchant ? 'common.error.enterMerchant' : 'common.error.missingMerchantName', - }, - date: { - isError: transactionDate === '', - translationPath: canEditDate ? 'common.error.enterDate' : 'common.error.missingDate', - }, - }; - - const {isError, translationPath} = fieldChecks[field] ?? {}; - - if (readonly) { - return ''; - } - - // Return form errors if there are any - if (hasErrors && isError && translationPath) { - return translate(translationPath); - } - - if (isCustomUnitOutOfPolicy && field === 'customUnitRateID') { - return translate('violations.customUnitOutOfPolicy'); - } - - // Return violations if there are any - if (field !== 'merchant' && hasViolations(field, data, policyHasDependentTags, tagValue)) { - const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); - return `${violations - .map((violation) => { - const cardID = violation.data?.cardID; - const card = cardID ? cardList?.[cardID] : undefined; - return ViolationsUtils.getViolationTranslation({ - dateFnsLocale, - violation, - translate, - convertToDisplayString, - canEdit, - companyCardPageURL, - connectionLink, - card, - isMarkAsCash, - routeDistanceMeters: transaction?.comment?.customUnit?.routeDistanceMeters, - distanceUnit: transaction?.comment?.customUnit?.distanceUnit, - }); - }) - .join('. ')}.`; - } - - if (field === 'attendees' && isMissingAttendeesViolation) { - return translate('violations.missingAttendees'); - } - - return ''; - }; - - const showTaxDisabledAlert = () => { - showConfirmModal({ - title: translate('iou.taxDisabledAlert.title'), - prompt: translate('iou.taxDisabledAlert.prompt'), - confirmText: translate('iou.taxDisabledAlert.confirmText'), - cancelText: translate('common.cancel'), - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !canEditTaxFields) { - return; - } - - updateMoneyRequestTaxRate({ - transactionID: transaction?.transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - taxCode: '', - taxValue: '', - taxAmount: 0, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }); - }; - - const showCategoryDisabledAlert = () => { - const transactionID = transaction?.transactionID; - if (!transactionID) { - return; - } - showConfirmModal({ - title: translate('iou.categoryDisabledAlert.title'), - prompt: translate('iou.categoryDisabledAlert.prompt'), - confirmText: translate('iou.categoryDisabledAlert.confirmText'), - cancelText: translate('common.cancel'), - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !canEdit) { - return; - } - - updateMoneyRequestCategory({ - transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - category: '', - policy, - policyTagList, - policyCategories, - policyRecentlyUsedCategories: undefined, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }); - }; +import type {MoneyRequestViewProps} from './MoneyRequestView/types'; - const showTagDisabledAlert = (tagListIndex: number) => { - const transactionID = transaction?.transactionID; - if (!transactionID) { - return; - } - showConfirmModal({ - title: translate('iou.tagDisabledAlert.title'), - prompt: translate('iou.tagDisabledAlert.prompt'), - confirmText: translate('iou.tagDisabledAlert.confirmText'), - cancelText: translate('common.cancel'), - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !canEdit) { - return; - } - - // Clear only the pressed level so the other levels of a multi-level tag are kept. - const updatedTag = insertTagIntoTransactionTagsString(transactionTag ?? '', '', tagListIndex, policy?.hasMultipleTagLists ?? false); - updateMoneyRequestTag({ - transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - tag: updatedTag, - policy, - policyTagList, - policyRecentlyUsedTags: undefined, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }); - }; - - const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; - const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; - const amountCopyValue = !canEditAmount ? amountTitle : undefined; - const descriptionHTML = updatedTransactionDescription ?? transactionDescription; - const descriptionCopyValue = !canEdit && descriptionHTML ? Parser.htmlToText(descriptionHTML) : undefined; - const merchantCopyValue = !canEditMerchant ? updatedMerchantTitle : undefined; - const dateCopyValue = !canEditDate ? transactionDate : undefined; - const categoryValue = updatedTransaction?.category ?? categoryForDisplay; - const decodedCategoryName = getDecodedLeafCategoryName(categoryValue); - const categoryCopyValue = !canEdit ? decodedCategoryName : undefined; - const cardCopyValue = cardProgramName; - const taxRateValue = hasTaxValueChanged ? taxValue : (transaction?.taxName ?? taxRateTitle ?? fallbackTaxRateTitle ?? ''); - const taxRateCopyValue = !canEditTaxFields ? taxRateValue : undefined; - const taxAmountTitle = formattedTaxAmount ? formattedTaxAmount.toString() : ''; - const taxAmountCopyValue = !canEditTaxFields ? taxAmountTitle : undefined; - - const distanceRequestFields = ( - <> - - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (isOdometerDistanceRequest) { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ); - return; - } - - if (isManualDistanceRequest) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('waypoints') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('waypoints')} - copyValue={distanceCopyValue} - copyable={!!distanceCopyValue} - /> - - - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (isTrackExpense) { - if (shouldNavigateToUpgradePath && transactionThreadReport) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.DISTANCE_RATES, - }), - ), - ); - return; - } - if (!policy && shouldSelectPolicy) { - Navigation.navigate( - ROUTES.SET_DEFAULT_WORKSPACE.getRoute( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ), - ); - return; - } - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('customUnitRateID') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('customUnitRateID')} - copyValue={distanceRateCopyValue} - copyable={!!distanceRateCopyValue} - /> - - - ); - - const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTagList); - - const [previousTransactionTag, setPreviousTransactionTag] = useState(transactionTag); - const [previousTag, setPreviousTag] = useState(undefined); - const [currentTransactionTag, setCurrentTransactionTag] = useState(undefined); - if (transactionTag !== previousTransactionTag) { - setPreviousTransactionTag(transactionTag); - setPreviousTag(previousTransactionTag); - setCurrentTransactionTag(transactionTag); - } - const previousTagLength = getLengthOfTag(previousTag ?? ''); - const currentTagLength = getLengthOfTag(currentTransactionTag ?? ''); - - // actualAttendees is already sorted by enrichAndSortAttendees above; pass without localeCompare to preserve that order while stripping the SMS domain. - const getAttendeesTitle = Array.isArray(actualAttendees) ? getAttendeesListDisplayString(actualAttendees) : ''; - const attendeesCopyValue = !canEdit ? getAttendeesTitle : undefined; - - const tagList = policyTagLists.map(({name, orderWeight, tags}, index) => { - const tagForDisplay = getTagForDisplay(updatedTransaction ?? transaction, index); - let shouldShow = false; - if (hasDependentTags) { - shouldShow = shouldShowDependentTagList(index, transactionTag, tags); - } else { - shouldShow = !!tagForDisplay || (canEdit && hasEnabledOptions(tags)); - } - - if (!shouldShow) { - return null; - } - - const tagError = getErrorForField( - 'tag', - { - tagListIndex: index, - tagListName: name, - }, - hasDependentTags, - tagForDisplay, - ); - const tagCopyValue = !canEdit ? tagForDisplay : undefined; - - return ( - - previousTagLength} - description={name ?? translate('common.tag')} - title={tagForDisplay} - numberOfLinesTitle={2} - interactive={canEdit} - shouldShowRightIcon={canEdit} - titleStyle={styles.flex1} - onPress={() => { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - if (shouldShowTagDisabledAlert) { - showTagDisabledAlert(index); - return; - } - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - }} - brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={tagError} - shouldShowBasicTitle - shouldShowDescriptionOnTop - copyValue={tagCopyValue} - copyable={!!tagCopyValue} - /> - - ); - }); - - const parentReportDerivedName = parentReport?.reportID ? derivedReportNames?.[parentReport.reportID] : undefined; - const reportNameToDisplay = isFromMergeTransaction ? (updatedTransaction?.reportName ?? translate('common.none')) : getReportName(parentReport, parentReportDerivedName); - const shouldShowReport = !!parentReportID || (isFromMergeTransaction && !!reportNameToDisplay); - const reportCopyValue = !canEditReport && reportNameToDisplay !== translate('common.none') ? reportNameToDisplay : undefined; - const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(updatedTransaction ?? transaction); - - // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - const {wideRHPRouteKeys} = useWideRHPState(); - const route = useRoute(); - // The receipt is hidden only for the MoneyRequestView rendered inside a wide RHP, where the wide RHP's left - // receipt panel already shows it. Instances mounted on other screens (e.g. the central report pane behind - // the RHP) must keep their inline receipt, so the check is scoped to this view's own route key. - const isInWideRHP = wideRHPRouteKeys.includes(route.key); - - // If the view is readonly, we don't need the transactionThread dependency - if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { +import MoneyRequestReceiptView from './MoneyRequestReceiptView'; +import AmountField from './MoneyRequestView/components/AmountField'; +import AttendeesField from './MoneyRequestView/components/AttendeesField'; +import BillableField from './MoneyRequestView/components/BillableField'; +import CardField from './MoneyRequestView/components/CardField'; +import CategoryField from './MoneyRequestView/components/CategoryField'; +import CompanyCardViolationMessage from './MoneyRequestView/components/CompanyCardViolationMessage'; +import DateField from './MoneyRequestView/components/DateField'; +import DescriptionField from './MoneyRequestView/components/DescriptionField'; +import DistanceRequestFields from './MoneyRequestView/components/DistanceRequestFields'; +import MerchantField from './MoneyRequestView/components/MerchantField'; +import PerDiemOutOfPolicyBanner from './MoneyRequestView/components/PerDiemOutOfPolicyBanner'; +import ReimbursableField from './MoneyRequestView/components/ReimbursableField'; +import ReportField from './MoneyRequestView/components/ReportField'; +import TagField from './MoneyRequestView/components/TagField'; +import TaxAmountField from './MoneyRequestView/components/TaxAmountField'; +import TaxRateField from './MoneyRequestView/components/TaxRateField'; +import TripField from './MoneyRequestView/components/TripField'; +import VendorField from './MoneyRequestView/components/VendorField'; +import ViewTripDetailsAction from './MoneyRequestView/components/ViewTripDetailsAction'; +import useMoneyRequestViewData from './MoneyRequestView/useMoneyRequestViewData'; + +function MoneyRequestView(props: MoneyRequestViewProps) { + const data = useMoneyRequestViewData(props); + + if (data.isLoading) { return ; } - return ( - - {shouldShowAnimatedBackground && } - <> - {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( - - )} - {isCustomUnitOutOfPolicy && isPerDiemRequest && ( - - - - {translate('violations.customUnitOutOfPolicy')} - - - )} - - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (shouldShowSplitIndicator && isSplitAvailable) { - initSplitExpense( - transaction, - transactionThreadReport, - splitEffectivePolicy, - selfDMReportID, - restrictedActionPolicyID, - personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - ); - return; - } - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - '', - '', - getReportRHPActiveRoute(), - ), - ); - }} - brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('amount')} - copyValue={amountCopyValue} - copyable={!!amountCopyValue} - /> - - {!shouldHideEmptyDescription && ( - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} - brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('comment')} - numberOfLinesTitle={0} - copyValue={descriptionCopyValue} - copyable={!!descriptionCopyValue} - /> - - )} - {isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( - distanceRequestFields - ) : ( - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - wrapperStyle={[styles.taskDescriptionMenuItem]} - furtherDetailsComponent={shouldShowGoogleMerchantSearchLink ? renderGoogleMerchantSearchLink() : undefined} - brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('merchant')} - numberOfLinesTitle={0} - copyValue={merchantCopyValue} - copyable={!!merchantCopyValue} - /> - - )} - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('date')} - copyValue={dateCopyValue} - copyable={!!dateCopyValue} - /> - - {!!shouldShowCategory && ( - - { - if (shouldShowCategoryDisabledAlert) { - showCategoryDisabledAlert(); - return; - } - - if (shouldNavigateToUpgradePath && transactionThreadReport) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, - upgradeBackTo: createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - }), - ), - ); - } else if (!policy && shouldSelectPolicy) { - Navigation.navigate( - ROUTES.SET_DEFAULT_WORKSPACE.getRoute( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ), - ); - } else { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ); - } - }} - brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('category')} - copyValue={categoryCopyValue} - copyable={!!categoryCopyValue} - /> - - )} - {shouldShowVendor && ( - - { - if (!transactionThreadReport?.reportID) { - return; - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - getReportRHPActiveRoute(), - ), - ); - }} - brickRoadIndicator={getErrorForField('vendor') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('vendor')} - /> - - )} - {shouldShowTag && tagList} - {!!shouldShowCard && ( - - - - )} - {shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('tax')} - copyValue={taxRateCopyValue} - copyable={!!taxRateCopyValue} - /> - - )} - {shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } + const shouldShowDistanceFields = + data.isManualDistanceRequest || data.isGPSDistanceRequest || data.isOdometerDistanceRequest || (data.isMapDistanceRequest && data.transaction?.comment?.waypoints); - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - copyValue={taxAmountCopyValue} - copyable={!!taxAmountCopyValue} - /> - - )} - {shouldShowAttendees && ( - - 1 && formattedPerAttendeeAmount - ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` - : '' - }`} - descriptionTextStyle={styles.textLabelSupportingNormal} - titleComponent={ - Array.isArray(actualAttendees) ? ( - ({ - avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', - accountID: a?.accountID, - email: a?.email, - }))} - maxVisible={canEdit ? undefined : actualAttendees.length} - /> - ) : undefined - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - onPress={() => { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('attendees')} - interactive={canEdit} - shouldShowRightIcon={canEdit} - copyValue={attendeesCopyValue} - copyable={!!attendeesCopyValue} - /> - - )} - {shouldShowReimbursable && ( - - - - {Str.UCFirst(translate('iou.reimbursable'))} - - - - - )} - {shouldShowBillable && ( - - - - {translate('common.billable')} - - {!!getErrorForField('billable') && ( - - )} - - - - )} - {shouldShowReport && ( - - { - if (!canEditReport || !transactionThreadReport) { - return; - } - if (shouldNavigateToUpgradePath) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - iouType, - action: CONST.IOU.ACTION.EDIT, - transactionID: transaction?.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.REPORTS, - }), - ), - ); - return; - } - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - interactive={canEditReport} - shouldRenderAsHTML - copyValue={reportCopyValue} - copyable={!!reportCopyValue} - /> - - )} - {shouldShowTripRoomLink && ( - <> - { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(tripRoomReportID, undefined, undefined, Navigation.getActiveRoute())); - }} - interactive - /> - - - )} - {/* Note: "View trip details" should be always the last item */} - {shouldShowViewTripDetails && ( - { - const reservations = transaction?.receipt?.reservationList?.length ?? 0; - if (reservations > 1) { - Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID, getReportRHPActiveRoute())); - } - Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0, getReportRHPActiveRoute())); - }} - /> - )} - - {hasRequiredCompanyCardViolation && ( - - )} - + return ( + + {data.shouldShowAnimatedBackground && } + {(!data.isInWideRHP || data.isSmallScreenWidth || data.isFromReviewDuplicates || data.isFromMergeTransaction) && ( + + )} + {data.isCustomUnitOutOfPolicy && data.isPerDiemRequest && } + + {!data.shouldHideEmptyDescription && } + {shouldShowDistanceFields ? : } + + {!!data.shouldShowCategory && } + + {data.shouldShowVendor && } + {!!data.shouldShowTag && } + {!!data.shouldShowCard && } + {data.shouldShowTax && } + {data.shouldShowTax && } + {data.shouldShowAttendees && } + {data.shouldShowReimbursable && } + {data.shouldShowBillable && } + {data.shouldShowReport && } + {data.shouldShowTripRoomLink && } + {data.shouldShowViewTripDetails && } + {data.hasRequiredCompanyCardViolation && } ); } +MoneyRequestView.displayName = 'MoneyRequestView'; + export default MoneyRequestView; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/AmountField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/AmountField.tsx new file mode 100644 index 000000000000..17a242e56f7f --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/AmountField.tsx @@ -0,0 +1,35 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import CONST from '@src/CONST'; + +type AmountFieldProps = { + data: MoneyRequestViewData; +}; + +function AmountField({data}: AmountFieldProps) { + return ( + + + + ); +} + +AmountField.displayName = 'AmountField'; + +export default AmountField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/AttendeesField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/AttendeesField.tsx new file mode 100644 index 000000000000..977b1e53c3ac --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/AttendeesField.tsx @@ -0,0 +1,53 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; +import UserPills from '@components/UserPills'; + +import CONST from '@src/CONST'; + +type AttendeesFieldProps = { + data: MoneyRequestViewData; +}; + +function AttendeesField({data}: AttendeesFieldProps) { + return ( + + 1 && data.formattedPerAttendeeAmount + ? `${CONST.DOT_SEPARATOR} ${data.formattedPerAttendeeAmount} ${data.translate('common.perPerson')}` + : '' + }`} + descriptionTextStyle={data.styles.textLabelSupportingNormal} + titleComponent={ + Array.isArray(data.actualAttendees) ? ( + ({ + avatar: a?.avatarUrl, + displayName: a?.displayName ?? a?.email ?? '', + accountID: a?.accountID, + email: a?.email, + }))} + maxVisible={data.canEdit ? undefined : data.actualAttendees.length} + /> + ) : undefined + } + style={[data.styles.moneyRequestMenuItem]} + titleStyle={data.styles.flex1} + onPress={data.onAttendeesPress} + brickRoadIndicator={data.getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={data.getErrorForField('attendees')} + interactive={data.canEdit} + shouldShowRightIcon={data.canEdit} + copyValue={data.attendeesCopyValue} + copyable={!!data.attendeesCopyValue} + /> + + ); +} + +AttendeesField.displayName = 'AttendeesField'; + +export default AttendeesField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/BillableField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/BillableField.tsx new file mode 100644 index 000000000000..5c3bf17f1e31 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/BillableField.tsx @@ -0,0 +1,53 @@ +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import ViolationMessages from '@components/ViolationMessages'; + +import {View} from 'react-native'; + +type BillableFieldProps = { + data: MoneyRequestViewData; +}; + +function BillableField({data}: BillableFieldProps) { + return ( + + + + {data.translate('common.billable')} + + {!!data.getErrorForField('billable') && ( + + )} + + + + ); +} + +BillableField.displayName = 'BillableField'; + +export default BillableField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/CardField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/CardField.tsx new file mode 100644 index 000000000000..24107acb44ea --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/CardField.tsx @@ -0,0 +1,27 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +type CardFieldProps = { + data: MoneyRequestViewData; +}; + +function CardField({data}: CardFieldProps) { + return ( + + + + ); +} + +CardField.displayName = 'CardField'; + +export default CardField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/CategoryField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/CategoryField.tsx new file mode 100644 index 000000000000..1e646b3d93dd --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/CategoryField.tsx @@ -0,0 +1,33 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import CONST from '@src/CONST'; + +type CategoryFieldProps = { + data: MoneyRequestViewData; +}; + +function CategoryField({data}: CategoryFieldProps) { + return ( + + + + ); +} + +CategoryField.displayName = 'CategoryField'; + +export default CategoryField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/CompanyCardViolationMessage.tsx b/src/components/ReportActionItem/MoneyRequestView/components/CompanyCardViolationMessage.tsx new file mode 100644 index 000000000000..860906121de1 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/CompanyCardViolationMessage.tsx @@ -0,0 +1,20 @@ +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +type CompanyCardViolationMessageProps = { + data: MoneyRequestViewData; +}; + +function CompanyCardViolationMessage({data}: CompanyCardViolationMessageProps) { + return ( + + ); +} + +CompanyCardViolationMessage.displayName = 'CompanyCardViolationMessage'; + +export default CompanyCardViolationMessage; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/DateField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/DateField.tsx new file mode 100644 index 000000000000..58cd6a67e26b --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/DateField.tsx @@ -0,0 +1,33 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import CONST from '@src/CONST'; + +type DateFieldProps = { + data: MoneyRequestViewData; +}; + +function DateField({data}: DateFieldProps) { + return ( + + + + ); +} + +DateField.displayName = 'DateField'; + +export default DateField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/DescriptionField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/DescriptionField.tsx new file mode 100644 index 000000000000..ce1f363bddd8 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/DescriptionField.tsx @@ -0,0 +1,35 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import CONST from '@src/CONST'; + +type DescriptionFieldProps = { + data: MoneyRequestViewData; +}; + +function DescriptionField({data}: DescriptionFieldProps) { + return ( + + + + ); +} + +DescriptionField.displayName = 'DescriptionField'; + +export default DescriptionField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/DistanceRequestFields.tsx b/src/components/ReportActionItem/MoneyRequestView/components/DistanceRequestFields.tsx new file mode 100644 index 000000000000..5301f9e9707f --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/DistanceRequestFields.tsx @@ -0,0 +1,51 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import CONST from '@src/CONST'; + +type DistanceRequestFieldsProps = { + data: MoneyRequestViewData; +}; + +function DistanceRequestFields({data}: DistanceRequestFieldsProps) { + return ( + <> + + + + + + + + ); +} + +DistanceRequestFields.displayName = 'DistanceRequestFields'; + +export default DistanceRequestFields; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/MerchantField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/MerchantField.tsx new file mode 100644 index 000000000000..251fbe450ae7 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/MerchantField.tsx @@ -0,0 +1,70 @@ +import Icon from '@components/Icon'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; +import Text from '@components/Text'; + +import {openExternalLink} from '@libs/actions/Link'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +type MerchantFieldProps = { + data: MoneyRequestViewData; +}; + +function MerchantField({data}: MerchantFieldProps) { + return ( + + { + event?.stopPropagation(); + openExternalLink(`${CONST.GOOGLE_SEARCH_URL}${encodeURIComponent(data.originalMerchantForGoogleSearch)}`); + }} + style={[data.styles.flexRow, data.styles.alignItemsCenter, data.styles.mt1, data.styles.alignSelfStart]} + > + + {data.translate('common.googleThisMerchant', { + merchant: data.originalMerchantForGoogleSearch, + })} + + + + ) : undefined + } + brickRoadIndicator={data.getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={data.getErrorForField('merchant')} + numberOfLinesTitle={0} + copyValue={data.merchantCopyValue} + copyable={!!data.merchantCopyValue} + /> + + ); +} + +MerchantField.displayName = 'MerchantField'; + +export default MerchantField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/PerDiemOutOfPolicyBanner.tsx b/src/components/ReportActionItem/MoneyRequestView/components/PerDiemOutOfPolicyBanner.tsx new file mode 100644 index 000000000000..338becebb6f4 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/PerDiemOutOfPolicyBanner.tsx @@ -0,0 +1,32 @@ +import Icon from '@components/Icon'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; +import Text from '@components/Text'; + +import {View} from 'react-native'; + +type PerDiemOutOfPolicyBannerProps = { + data: MoneyRequestViewData; +}; + +function PerDiemOutOfPolicyBanner({data}: PerDiemOutOfPolicyBannerProps) { + return ( + + + + {data.translate('violations.customUnitOutOfPolicy')} + + + ); +} + +PerDiemOutOfPolicyBanner.displayName = 'PerDiemOutOfPolicyBanner'; + +export default PerDiemOutOfPolicyBanner; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/ReimbursableField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/ReimbursableField.tsx new file mode 100644 index 000000000000..8e1d205bc95c --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/ReimbursableField.tsx @@ -0,0 +1,39 @@ +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; + +import {Str} from 'expensify-common'; +import {View} from 'react-native'; + +type ReimbursableFieldProps = { + data: MoneyRequestViewData; +}; + +function ReimbursableField({data}: ReimbursableFieldProps) { + return ( + + + + {Str.UCFirst(data.translate('iou.reimbursable'))} + + + + + ); +} + +ReimbursableField.displayName = 'ReimbursableField'; + +export default ReimbursableField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/ReportField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/ReportField.tsx new file mode 100644 index 000000000000..e3dffedb97e4 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/ReportField.tsx @@ -0,0 +1,30 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +type ReportFieldProps = { + data: MoneyRequestViewData; +}; + +function ReportField({data}: ReportFieldProps) { + return ( + + + + ); +} + +ReportField.displayName = 'ReportField'; + +export default ReportField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/TagField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/TagField.tsx new file mode 100644 index 000000000000..8b58540226cb --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/TagField.tsx @@ -0,0 +1,91 @@ +import HighlightableMenuItemWithTopDescription from '@components/HighlightableMenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import {shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; +import {getTagForDisplay} from '@libs/TransactionUtils'; + +import Navigation from '@navigation/Navigation'; + +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +type TagFieldProps = { + data: MoneyRequestViewData; +}; + +function TagField({data}: TagFieldProps) { + return data.policyTagLists.map(({name, orderWeight, tags}, index) => { + const tagForDisplay = getTagForDisplay(data.updatedTransaction ?? data.transaction, index); + let shouldShow = false; + if (data.hasDependentTags) { + shouldShow = shouldShowDependentTagList(index, data.transactionTag, tags); + } else { + shouldShow = !!tagForDisplay || (data.canEdit && hasEnabledOptions(tags)); + } + + if (!shouldShow) { + return null; + } + + const tagError = data.getErrorForField( + 'tag', + { + tagListIndex: index, + tagListName: name, + }, + data.hasDependentTags, + tagForDisplay, + ); + const tagCopyValue = !data.canEdit ? tagForDisplay : undefined; + + return ( + + data.previousTagLength} + description={name ?? data.translate('common.tag')} + title={tagForDisplay} + numberOfLinesTitle={2} + interactive={data.canEdit} + shouldShowRightIcon={data.canEdit} + titleStyle={data.styles.flex1} + onPress={() => { + if (!data.transaction?.transactionID || !data.transactionThreadReport?.reportID) { + return; + } + if (data.shouldShowTagDisabledAlert) { + data.showTagDisabledAlert(index); + return; + } + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( + CONST.IOU.ACTION.EDIT, + data.iouType, + orderWeight, + data.transaction.transactionID, + data.transactionThreadReport.reportID, + ), + ), + ); + }} + brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={tagError} + shouldShowBasicTitle + shouldShowDescriptionOnTop + copyValue={tagCopyValue} + copyable={!!tagCopyValue} + /> + + ); + }); +} + +TagField.displayName = 'TagField'; + +export default TagField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/TaxAmountField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/TaxAmountField.tsx new file mode 100644 index 000000000000..1d9b385d9147 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/TaxAmountField.tsx @@ -0,0 +1,29 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +type TaxAmountFieldProps = { + data: MoneyRequestViewData; +}; + +function TaxAmountField({data}: TaxAmountFieldProps) { + return ( + + + + ); +} + +TaxAmountField.displayName = 'TaxAmountField'; + +export default TaxAmountField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/TaxRateField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/TaxRateField.tsx new file mode 100644 index 000000000000..c10ec4541ec4 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/TaxRateField.tsx @@ -0,0 +1,33 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import CONST from '@src/CONST'; + +type TaxRateFieldProps = { + data: MoneyRequestViewData; +}; + +function TaxRateField({data}: TaxRateFieldProps) { + return ( + + + + ); +} + +TaxRateField.displayName = 'TaxRateField'; + +export default TaxRateField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/TripField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/TripField.tsx new file mode 100644 index 000000000000..e332030cee1d --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/TripField.tsx @@ -0,0 +1,30 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import {View} from 'react-native'; + +type TripFieldProps = { + data: MoneyRequestViewData; +}; + +function TripField({data}: TripFieldProps) { + return ( + <> + + + + ); +} + +TripField.displayName = 'TripField'; + +export default TripField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/VendorField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/VendorField.tsx new file mode 100644 index 000000000000..be52571073de --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/VendorField.tsx @@ -0,0 +1,31 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +import CONST from '@src/CONST'; + +type VendorFieldProps = { + data: MoneyRequestViewData; +}; + +function VendorField({data}: VendorFieldProps) { + return ( + + + + ); +} + +VendorField.displayName = 'VendorField'; + +export default VendorField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/ViewTripDetailsAction.tsx b/src/components/ReportActionItem/MoneyRequestView/components/ViewTripDetailsAction.tsx new file mode 100644 index 000000000000..2b05547a2a4c --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/ViewTripDetailsAction.tsx @@ -0,0 +1,20 @@ +import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import type {MoneyRequestViewData} from '@components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData'; + +type ViewTripDetailsActionProps = { + data: MoneyRequestViewData; +}; + +function ViewTripDetailsAction({data}: ViewTripDetailsActionProps) { + return ( + + ); +} + +ViewTripDetailsAction.displayName = 'ViewTripDetailsAction'; + +export default ViewTripDetailsAction; diff --git a/src/components/ReportActionItem/MoneyRequestView/types.ts b/src/components/ReportActionItem/MoneyRequestView/types.ts new file mode 100644 index 000000000000..648ef1fd62ef --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/types.ts @@ -0,0 +1,31 @@ +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +type MoneyRequestViewProps = { + /** The report currently being looked at */ + transactionThreadReport?: OnyxEntry; + + parentReportID?: string; + + /** Policy that the report belongs to */ + expensePolicy: OnyxEntry; + + /** Whether we should display the animated banner above the component */ + shouldShowAnimatedBackground: boolean; + + /** Whether we should show Money Request with disabled all fields */ + readonly?: boolean; + + /** whether this report is from review duplicates */ + isFromReviewDuplicates?: boolean; + + /** Updated transaction to show in duplicate & merge transaction flow */ + updatedTransaction?: OnyxEntry; + + /** Merge transaction ID to show in merge transaction flow */ + mergeTransactionID?: string; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {MoneyRequestViewProps}; diff --git a/src/components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData.ts b/src/components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData.ts new file mode 100644 index 000000000000..9c447e0516ac --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData.ts @@ -0,0 +1,1338 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import {usePersonalDetails, usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; +import {useSearchResultsContext} from '@components/Search/SearchContext'; +import {useWideRHPState} from '@components/WideRHPContextProvider'; + +import useActiveRoute from '@hooks/useActiveRoute'; +import useAttendees from '@hooks/useAttendees'; +import useCardFeedErrors from '@hooks/useCardFeedErrors'; +import useConfirmModal from '@hooks/useConfirmModal'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; +import useEnvironment from '@hooks/useEnvironment'; +import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactions from '@hooks/useReportTransactions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRestrictedActionPolicyID from '@hooks/useRestrictedActionPolicyID'; +import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionViolations from '@hooks/useTransactionViolations'; +import type {ViolationField} from '@hooks/useViolations'; +import useViolations from '@hooks/useViolations'; + +import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyRequestReimbursable, updateMoneyRequestTag, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; +import initSplitExpense from '@libs/actions/SplitExpenses'; +import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; +import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils'; +import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils'; +import {getRateFromMerchant} from '@libs/MergeTransactionUtils'; +import {isBillableEnabledOnPolicy, isSingleTransactionReport} from '@libs/MoneyRequestReportUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import Parser from '@libs/Parser'; +import { + canSubmitPerDiemExpenseFromWorkspace, + findVendorByID, + getDistanceRateCustomUnitRate, + getLengthOfTag, + getPerDiemCustomUnit, + getPolicyByCustomUnitID, + getTagLists, + hasDependentTags as hasDependentTagsPolicyUtils, + hasVendorFeature, + isAttendeeTrackingEnabled, + isGroupPolicyByType, + isPerDiemEnabled, + isPolicyAccessible, + isTaxTrackingEnabled, + resolveCurrentTaxCode, + isXeroActiveMatchingSource, +} from '@libs/PolicyUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getReportName} from '@libs/ReportNameUtils'; +import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils'; +import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; +import { + canEditFieldOfMoneyRequest, + canEditMoneyRequest, + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + getTransactionDetails, + getTripIDFromTransactionParentReportID, + isExpenseReport, + isInvoiceReport, + isOpenReport, + isReportApproved, + isSettled as isSettledReportUtils, + isTrackExpenseReportNew, + shouldEnableNegative, +} from '@libs/ReportUtils'; +import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; +import { + getAttendeesListDisplayString, + getBillable, + getCurrency, + getDescription, + getDistanceInMeters, + getFormattedCreated, + getOriginalAmountForDisplay, + getOriginalTransactionWithSplitInfo, + getReimbursable, + getTaxName, + hasMissingSmartscanFields, + hasReservationList, + hasRoute as hasRouteTransactionUtils, + isFromCreditCardImport as isCardTransactionTransactionUtils, + isCategoryBeingAnalyzed, + isCustomUnitRateIDForP2P, + isDistanceRequest as isDistanceRequestTransactionUtils, + isDistanceTypeRequest, + isExpenseUnreported as isExpenseUnreportedTransactionUtils, + isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils, + isManagedCardTransaction as isManagedCardTransactionTransactionUtils, + isManualDistanceRequest as isManualDistanceRequestTransactionUtils, + isMapDistanceRequest as isMapDistanceRequestTransactionUtils, + isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, + isPerDiemRequest as isPerDiemRequestTransactionUtils, + isScanning, + isTimeRequest as isTimeRequestTransactionUtils, + isTransactionPendingDelete, + shouldShowAttendees as shouldShowAttendeesTransactionUtils, +} from '@libs/TransactionUtils'; +import {isInvalidMerchantValue} from '@libs/ValidationUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; + +import Navigation from '@navigation/Navigation'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {useRoute} from '@react-navigation/native'; +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {policyTypeSelector} from '@selectors/Policy'; +import {Str} from 'expensify-common'; +import {useState} from 'react'; +// Use the original useOnyx hook to get the real-time data from Onyx and not from the snapshot +// eslint-disable-next-line no-restricted-imports +import {useOnyx as originalUseOnyx} from 'react-native-onyx'; + +import type {MoneyRequestViewProps} from './types'; + +const perDiemPoliciesSelector = (policies: OnyxCollection) => { + return Object.fromEntries( + Object.entries(policies ?? {}).filter(([, policyItem]) => { + const perDiemCustomUnit = getPerDiemCustomUnit(policyItem); + const hasPolicyPerDiemRates = !isEmptyObject(perDiemCustomUnit?.rates); + + return isPerDiemEnabled(policyItem) && hasPolicyPerDiemRates; + }), + ); +}; + +type MoneyRequestViewData = ReturnType; + +function useMoneyRequestViewData({ + transactionThreadReport, + parentReportID, + expensePolicy, + shouldShowAnimatedBackground, + readonly = false, + updatedTransaction, + isFromReviewDuplicates = false, + mergeTransactionID, +}: MoneyRequestViewProps) { + const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase', 'NewWindow']); + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + const {isOffline} = useNetwork(); + const {environmentURL} = useEnvironment(); + const {translate, toLocaleDigit, localeCompare, dateFnsLocale} = useLocalize(); + const {convertToDisplayString, getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); + const {getReportRHPActiveRoute} = useActiveRoute(); + const {showConfirmModal} = useConfirmModal(); + const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); + + const {currentSearchResults} = useSearchResultsContext(); + + // When this component is used when merging from the search page, we might not have the parent report stored in the main collection + const [parentReportFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); + const parentReport = parentReportFromOnyx ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; + const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); + + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); + const parentReportAction = transactionThreadReport?.parentReportActionID ? parentReportActions?.[transactionThreadReport.parentReportActionID] : undefined; + + const isFromMergeTransaction = !!mergeTransactionID; + const linkedTransactionID = parentReportAction && isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; + const [onyxTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(linkedTransactionID)}`); + const transaction = updatedTransaction ?? onyxTransaction; + const isExpenseUnreported = isExpenseUnreportedTransactionUtils(transaction); + const personalPolicy = usePersonalPolicy(); + const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy, shouldNavigateToUpgradePath} = usePolicyForMovingExpenses(); + const isTimeRequest = isTimeRequestTransactionUtils(transaction); + + const [policiesWithPerDiem] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: perDiemPoliciesSelector, + }); + const splitEffectivePolicy = useSplitEffectivePolicy(transactionThreadReport, undefined, transaction); + const isPerDiemRequest = isPerDiemRequestTransactionUtils(transaction); + const perDiemOriginalPolicy = getPolicyByCustomUnitID(transaction, policiesWithPerDiem); + + const customUnitRateID = isDistanceRequestTransactionUtils(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; + const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(expensePolicy, customUnitRateID); + const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + + let policy: OnyxEntry; + let policyID: string | undefined; + // If the expense is unreported the policy should be the user's default policy, if the expense is a per diem request and is unreported + // the policy should be the one where the per diem rates are enabled, otherwise it should be the expense's report policy + if (isExpenseUnreported && !isPerDiemRequest) { + policy = policyForMovingExpenses; + policyID = policyForMovingExpensesID; + } else if (isExpenseUnreported && isPerDiemRequest) { + policy = perDiemOriginalPolicy; + policyID = perDiemOriginalPolicy?.id; + } else { + policy = expensePolicy; + policyID = parentReport?.policyID; + } + + // Use the report's real policy, not `policy` above (swapped to an unrelated workspace for + // unreported expenses), else self-DM split editing wrongly redirects to RESTRICTED_ACTION. + const restrictedActionPolicyID = useRestrictedActionPolicyID(expensePolicy); + + const allPolicyCategories = usePolicyCategories(); + const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; + const targetPolicyID = updatedTransaction?.reportID ? parentReport?.policyID : policyID; + const allPolicyTags = usePolicyTags(); + const policyTagList = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`]; + const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); + + const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${getNonEmptyStringOnyxID(linkedTransactionID)}`); + const transactionViolations = useTransactionViolations(transaction?.transactionID, true, distanceOriginalPolicy ?? policy); + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); + const personalDetailsList = usePersonalDetails(); + const currentUserAccountIDParam = currentUserPersonalDetails.accountID; + const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isP2PDistanceRequest = isCustomUnitRateIDForP2P(transaction); + const moneyRequestReport = parentReport; + const parentReportTransactions = useReportTransactions(moneyRequestReport?.reportID); + // Exclude transactions pending deletion so the report is recognized as single-expense immediately after deleting one of its expenses, + // instead of waiting for the optimistic delete to be removed from Onyx (https://github.com/Expensify/App/issues/91058). + // While offline the deleted expense is still rendered, so keep counting it to stay consistent with the visible transaction list. + const visibleParentReportTransactions = parentReportTransactions.filter((t) => isOffline || !isTransactionPendingDelete(t)); + const isApproved = isReportApproved({report: moneyRequestReport}); + const isInvoice = isInvoiceReport(moneyRequestReport); + const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); + + let iouType: ValueOf; + if (isTrackExpense) { + iouType = CONST.IOU.TYPE.TRACK; + } else if (isInvoice) { + iouType = CONST.IOU.TYPE.INVOICE; + } else { + iouType = CONST.IOU.TYPE.SUBMIT; + } + + const allowNegativeAmount = shouldEnableNegative(parentReport, policy, iouType); + + const { + created: transactionDate, + amount: transactionAmount, + taxAmount: transactionTaxAmount, + currency: transactionCurrency, + comment: transactionDescription, + merchant: transactionMerchant, + reimbursable: transactionReimbursable, + billable: transactionBillable, + category: transactionCategory, + tag: transactionTag, + originalCurrency: transactionOriginalCurrency, + postedDate: transactionPostedDate, + convertedAmount: transactionConvertedAmount, + } = getTransactionDetails(transaction, undefined, undefined, allowNegativeAmount, false) ?? {}; + const transactionAttendees = useAttendees(transaction); + const isEmptyMerchant = isInvalidMerchantValue(transactionMerchant); + const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); + const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); + const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); + const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); + const isMapDistanceRequest = isMapDistanceRequestTransactionUtils(transaction) || isDistanceTypeRequest(transaction); + const isTransactionScanning = isScanning(updatedTransaction ?? transaction); + const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); + + const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees; + const actualAttendees = enrichAndSortAttendees(rawActualAttendees, loginToAccountIDMap, personalDetailsList, localeCompare); + + // Use the updated transaction amount in merge flow to have correct positive/negative sign + const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount; + const actualCurrency = updatedTransaction ? getCurrency(updatedTransaction) : transactionCurrency; + const shouldDisplayTransactionAmount = (isDistanceRequest && hasRoute) || !isDistanceRequest; + const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount, actualCurrency) : ''; + const formattedPerAttendeeAmount = + shouldDisplayTransactionAmount && actualAmount !== undefined ? convertToDisplayString(actualAmount / (transactionAttendees?.length ?? 1), actualCurrency) : ''; + + const transactionOriginalAmount = transaction && getOriginalAmountForDisplay(transaction, isExpenseReport(moneyRequestReport)); + const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); + const isFromCardImport = isCardTransactionTransactionUtils(transaction); + const cardProgramName = getCompanyCardDescription(translate, transaction?.cardName, transaction?.cardID, nonPersonalAndWorkspaceCards); + const shouldShowCard = isFromCardImport && cardProgramName; + + const taxRates = policy?.taxRates; + const formattedTaxAmount = + updatedTransaction?.taxAmount !== undefined + ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency) + : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); + // Skip a zero converted tax (e.g. tax exempt) so we don't render a redundant "Converted 0.00". + const formattedConvertedTaxAmount = transaction?.convertedTaxAmount ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : ''; + + const taxRatesDescription = taxRates?.name; + + const baseTransaction = updatedTransaction ?? transaction; + const {taxCode, taxValue} = baseTransaction ?? {}; + + const taxRateTitle = getTaxName(policy, baseTransaction, isExpenseUnreported); + const resolvedTaxCode = taxCode ? resolveCurrentTaxCode(policy, taxCode) : undefined; + const selectedPolicyTaxValue = resolvedTaxCode ? policy?.taxRates?.taxes?.[resolvedTaxCode]?.value : undefined; + const hasTaxValueChanged = taxCode && taxValue !== undefined ? selectedPolicyTaxValue !== taxValue : false; + + const actualTransactionDate = isFromMergeTransaction && updatedTransaction ? getFormattedCreated(updatedTransaction) : transactionDate; + const fallbackTaxRateTitle = transaction?.taxValue; + + const isSettled = isSettledReportUtils(moneyRequestReport); + const isCancelled = moneyRequestReport?.isCancelledIOU; + const isChatReportArchived = useReportIsArchived(moneyRequestReport?.chatReportID); + const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const pendingAction = transaction?.pendingAction; + const shouldShowPaid = isSettled && transactionReimbursable && !pendingAction; + + // Flags for allowing or disallowing editing an expense + // Used for non-restricted fields such as: description, category, tag, billable, etc... + const isReportArchived = useReportIsArchived(transactionThreadReport?.reportID); + const isEditable = !!canUserPerformWriteActionReportUtils(transactionThreadReport, isReportArchived) && !readonly; + const canEdit = + isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, moneyRequestReport, policy, parentReportActions) && isEditable; + const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(transactionThreadReport?.policyID)}`; + const {personalCardsWithBrokenConnection} = useCardFeedErrors(); + const connectionLink = getBrokenConnectionUrlToFixPersonalCard(personalCardsWithBrokenConnection, environmentURL); + const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); + const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); + const [reportPolicyType] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`, {selector: policyTypeSelector}); + const hasMultipleSplits = useHasMultipleSplitChildren(transaction?.comment?.originalTransactionID); + const isReportOpen = isOpenReport(moneyRequestReport); + const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); + const isSplitAvailable = + moneyRequestReport && + transaction && + isSplitAction(moneyRequestReport, [transaction], originalTransaction, currentUserPersonalDetails.login ?? '', currentUserPersonalDetails.accountID, policy); + + const canEditTaxFields = canEdit && !isDistanceRequest; + const canEditAmount = + !isGPSDistanceRequest && + isEditable && + (canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, + isChatReportArchived, + reportNameValuePairs, + transaction, + }) || + (shouldShowSplitIndicator && isSplitAvailable)); + const canEditMerchant = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }); + + const canEditDate = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }); + + const canEditDistanceOrRate = isPolicyAccessible(policy, currentUserEmailParam) || isTrackExpense || isP2PDistanceRequest; + + const canEditDistance = + !isGPSDistanceRequest && + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.DISTANCE, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }) && + canEditDistanceOrRate; + + const canEditDistanceRate = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }) && + canEditDistanceOrRate; + + const canEditReport = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, + isChatReportArchived, + outstandingReportsByPolicyID, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }) && + (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(policy) || (isExpenseUnreported && !!perDiemOriginalPolicy)); + + // A flag for verifying that the current report is a sub-report of a expense chat + // if the policy of the report is either Collect or Control, then this report must be tied to expense chat + const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); + const policyTagLists = getTagLists(policyTagList); + + const category = transactionCategory ?? ''; + const categoryForDisplay = isCategoryMissing(category) ? '' : category; + + // Flags for showing categories and tags + // transactionCategory can be an empty string + + const shouldShowCategory = + (isPolicyExpenseChat && (categoryForDisplay || hasEnabledOptions(policyCategories ?? {}))) || + (isExpenseUnreported && (!policyForMovingExpenses || hasEnabledOptions(policyCategories ?? {}))); + // transactionTag can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldShowTag = (isPolicyExpenseChat || isExpenseUnreported) && (transactionTag || (canEdit && hasEnabledTags(policyTagLists))); + // Surface a delete confirmation (like tax) when the value is stale and there's nothing valid to select, instead of + // navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully emptied, + // so also cover "no enabled tags remain". Applies to multi-level tags too - deleting clears the whole tag value. + const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; + const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag; + const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); + const isCurrentTransactionReimbursableDifferentFromPolicyDefault = + policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; + const shouldShowReimbursable = + (isPolicyExpenseChat || (isExpenseUnreported && !!policy)) && + (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && + !isManagedCardTransactionTransactionUtils(transaction) && + !isInvoice; + const canEditReimbursable = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }); + const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); + + const transactionVendor = transaction?.comment?.vendor; + + // The title prefers any matched vendor name (active vendor-matching integration first, then a + // permissive cross-connection lookup), then the display name persisted on the transaction at + // match/assign time. When no connection has the vendor any more and no name was persisted, fall + // back to the raw externalID so admins still see a stable identifier for what was assigned — the + // red INACTIVE_VENDOR violation (from ViolationsUtils, scoped to the active integration) provides + // the "Vendor no longer valid" indicator separately. + const matchedVendorName = findVendorByID(policy, transactionVendor?.externalID)?.name; + let transactionVendorName = ''; + if (matchedVendorName) { + transactionVendorName = matchedVendorName; + } else if (transactionVendor?.name) { + transactionVendorName = transactionVendor.name; + } else if (transactionVendor?.externalID) { + transactionVendorName = transactionVendor.externalID; + } + const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice; + const vendorFieldLabel = isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'); + + const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); + const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; + + const transactionTripID = transaction?.comment?.tripID; + + // Trip rooms are the grandparent report, so check that first before scanning the collection. + const grandparentReportID = parentReport?.parentReportID; + const tripRoomReportSelector = (reports: OnyxCollection): OnyxEntry => { + if (!transactionTripID || !reports) { + return undefined; + } + const grandparent = grandparentReportID ? reports[`${ONYXKEYS.COLLECTION.REPORT}${grandparentReportID}`] : undefined; + const match = + grandparent?.tripData?.tripID === transactionTripID ? grandparent : Object.values(reports).find((candidateReport) => candidateReport?.tripData?.tripID === transactionTripID); + if (!match?.reportID) { + return undefined; + } + return match; + }; + const [tripRoomReport] = originalUseOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: tripRoomReportSelector}); + const tripRoomReportID = tripRoomReport?.reportID; + + const derivedReportNames = useDerivedReportNamesByReportIDs([tripRoomReportID, parentReport?.reportID]); + + const tripRoomDerivedName = tripRoomReportID ? derivedReportNames?.[tripRoomReportID] : undefined; + const tripRoomName = tripRoomReport ? getReportName(tripRoomReport, tripRoomDerivedName) : undefined; + const shouldShowTripRoomLink = !!tripRoomReportID && !!tripRoomName; + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + + const {getViolationsForField} = useViolations(transactionViolations ?? [], isTransactionScanning || !isGroupPolicyByType(reportPolicyType)); + const hasViolations = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => + getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0; + const isMarkAsCash = parentReport && currentUserEmailParam ? isMarkAsCashActionForTransaction(currentUserEmailParam, parentReport, transactionViolations, policy) : false; + // Need to return undefined when we have pendingAction to avoid the duplicate pending action + const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => (pendingAction ? undefined : transaction?.pendingFields?.[fieldPath]); + + const isTaxEnabled = isTaxTrackingEnabled(isPolicyExpenseChat || isExpenseUnreported, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); + const shouldShowTaxDisabledAlert = !isTaxEnabled && !!transaction?.taxCode && !isTimeRequest && !isPerDiemRequest; + const shouldShowTax = isFromMergeTransaction ? !!transaction?.taxName : isTaxEnabled || shouldShowTaxDisabledAlert; + + let amountDescription = `${translate('iou.amount')}`; + let dateDescription = `${translate('common.date')}`; + + const { + unit, + rate, + name: rateName, + } = DistanceRequestUtils.getRate({ + transaction: updatedTransaction ?? transaction, + policy: distanceOriginalPolicy ?? policy, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, + }); + const distance = getDistanceInMeters(transactionBackup ?? updatedTransaction ?? transaction, unit); + const currency = transactionCurrency ?? CONST.CURRENCY.USD; + const hasRequiredCompanyCardViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.COMPANY_CARD_REQUIRED); + const isCustomUnitOutOfPolicy = + (transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && !rate)) && !isTrackExpense; + const calculateFromTransactionData = isTrackExpense && !rate; + const distanceUnit = calculateFromTransactionData ? transaction?.comment?.customUnit?.distanceUnit : unit; + const distanceRate = calculateFromTransactionData ? (transactionAmount ?? 0) / (transaction?.comment?.customUnit?.quantity ?? 1) : rate; + let rateToDisplay = DistanceRequestUtils.getRateForExpenseDisplay( + rateName, + isCustomUnitOutOfPolicy, + distanceUnit, + distanceRate, + currency, + translate, + toLocaleDigit, + getCurrencySymbol, + isOffline, + ); + + const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + // Commuter exclusions are a workspace control, so they are never surfaced on a personal (self-DM or P2P) expense, + // even if one carries the fields from a workspace rate it was created with. + const commuterExclusionData = + transaction?.comment?.originalTransactionID || !isPolicyExpenseChat + ? null + : DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, distanceUnitValue); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, translate, false, isManualDistanceRequest, commuterExclusionData); + const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate); + + let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; + let amountTitle = formattedTransactionAmount?.toString() || ''; + if (isTransactionScanning) { + merchantTitle = translate('iou.receiptStatusTitle'); + amountTitle = translate('iou.receiptStatusTitle'); + } + + const updatedTransactionDescription = getDescription(updatedTransaction) || undefined; + const shouldHideEmptyDescription = (isFromReviewDuplicates || isFromMergeTransaction) && !(updatedTransactionDescription ?? transactionDescription); + const isEmptyUpdatedMerchant = isInvalidMerchantValue(updatedTransaction?.modifiedMerchant); + const updatedMerchantTitle = isEmptyUpdatedMerchant ? '' : (updatedTransaction?.modifiedMerchant ?? merchantTitle); + const originalMerchantForGoogleSearch = isInvalidMerchantValue(onyxTransaction?.merchant) ? '' : Str.recapitalize(onyxTransaction?.merchant ?? ''); + const shouldShowGoogleMerchantSearchLink = !!originalMerchantForGoogleSearch && !isTransactionScanning && isFromCardImport; + + const shouldShowConvertedAmount = + transactionConvertedAmount && + currency !== moneyRequestReport?.currency && + !isFromCardImport && + transaction?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && + !isFromMergeTransaction && + !isFromReviewDuplicates && + !getPendingFieldAction('amount') && + !pendingAction; + + if (isFromCardImport) { + if (transactionPostedDate) { + dateDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.posted')} ${transactionPostedDate}`; + } + if (formattedOriginalAmount) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.purchase')} ${formattedOriginalAmount}`; + } + if (isCancelled) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; + } + } else if (isCancelled) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; + } else if (isApproved) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.approved')}`; + } else if (shouldShowPaid) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.settledExpensify')}`; + } + if (shouldShowSplitIndicator) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.split')}`; + } + if (shouldShowConvertedAmount) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`; + } + const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; + if (!isCurrentTransactionReimbursable && isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; + } + + // Show the converted tax here since the redundant report-level Tax total is hidden for a single-expense report. + let taxAmountDescription = translate('iou.taxAmount'); + if (shouldShowConvertedAmount && formattedConvertedTaxAmount) { + taxAmountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${formattedConvertedTaxAmount}`; + } + + if (isFromMergeTransaction && !rateName) { + // Because we lack the necessary data in policy.customUnits to determine the rate in merge flow, + // We need to extract the rate from the merchant string + // See https://github.com/Expensify/App/pull/71675#issuecomment-3425488228 for more information + rateToDisplay = getRateFromMerchant(updatedMerchantTitle); + } + + const hasErrors = hasMissingSmartscanFields(transaction, transactionReport); + const isMissingAttendeesViolation = getIsMissingAttendeesViolation( + policyCategories, + updatedTransaction?.category ?? categoryForDisplay, + actualAttendees, + currentUserPersonalDetails, + isAttendeeTrackingEnabled(policy), + policy?.type === CONST.POLICY.TYPE.CORPORATE, + ); + + const getErrorForField = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { + // Checks applied when creating a new expense + // NOTE: receipt field can return multiple violations, so we need to handle it separately + const fieldChecks: Partial> = { + merchant: { + isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, + translationPath: canEditMerchant ? 'common.error.enterMerchant' : 'common.error.missingMerchantName', + }, + date: { + isError: transactionDate === '', + translationPath: canEditDate ? 'common.error.enterDate' : 'common.error.missingDate', + }, + }; + + const {isError, translationPath} = fieldChecks[field] ?? {}; + + if (readonly) { + return ''; + } + + // Return form errors if there are any + if (hasErrors && isError && translationPath) { + return translate(translationPath); + } + + if (isCustomUnitOutOfPolicy && field === 'customUnitRateID') { + return translate('violations.customUnitOutOfPolicy'); + } + + // Return violations if there are any + if (field !== 'merchant' && hasViolations(field, data, policyHasDependentTags, tagValue)) { + const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); + return `${violations + .map((violation) => { + const cardID = violation.data?.cardID; + const card = cardID ? cardList?.[cardID] : undefined; + return ViolationsUtils.getViolationTranslation({ + dateFnsLocale, + violation, + translate, + convertToDisplayString, + canEdit, + companyCardPageURL, + connectionLink, + card, + isMarkAsCash, + routeDistanceMeters: transaction?.comment?.customUnit?.routeDistanceMeters, + distanceUnit: transaction?.comment?.customUnit?.distanceUnit, + }); + }) + .join('. ')}.`; + } + + if (field === 'attendees' && isMissingAttendeesViolation) { + return translate('violations.missingAttendees'); + } + + return ''; + }; + + const saveBillable = (newBillable: boolean) => { + // If the value hasn't changed, don't request to save changes on the server and just close the modal + if (newBillable === getBillable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + updateMoneyRequestBillable({ + transactionID: transaction.transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + value: newBillable, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + isOffline, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }; + + const saveReimbursable = (newReimbursable: boolean) => { + // If the value hasn't changed, don't request to save changes on the server and just close the modal + if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + updateMoneyRequestReimbursable({ + transactionID: transaction.transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + value: newReimbursable, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + isOffline, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }; + + const showTaxDisabledAlert = () => { + showConfirmModal({ + title: translate('iou.taxDisabledAlert.title'), + prompt: translate('iou.taxDisabledAlert.prompt'), + confirmText: translate('iou.taxDisabledAlert.confirmText'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !canEditTaxFields) { + return; + } + + updateMoneyRequestTaxRate({ + transactionID: transaction?.transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + taxCode: '', + taxValue: '', + taxAmount: 0, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }); + }; + + const showCategoryDisabledAlert = () => { + const transactionID = transaction?.transactionID; + if (!transactionID) { + return; + } + showConfirmModal({ + title: translate('iou.categoryDisabledAlert.title'), + prompt: translate('iou.categoryDisabledAlert.prompt'), + confirmText: translate('iou.categoryDisabledAlert.confirmText'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !canEdit) { + return; + } + + updateMoneyRequestCategory({ + transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + category: '', + policy, + policyTagList, + policyCategories, + policyRecentlyUsedCategories: undefined, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }); + }; + + const showTagDisabledAlert = (tagListIndex: number) => { + const transactionID = transaction?.transactionID; + if (!transactionID) { + return; + } + showConfirmModal({ + title: translate('iou.tagDisabledAlert.title'), + prompt: translate('iou.tagDisabledAlert.prompt'), + confirmText: translate('iou.tagDisabledAlert.confirmText'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !canEdit) { + return; + } + + // Clear only the pressed level so the other levels of a multi-level tag are kept. + const updatedTag = insertTagIntoTransactionTagsString(transactionTag ?? '', '', tagListIndex, policy?.hasMultipleTagLists ?? false); + updateMoneyRequestTag({ + transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + tag: updatedTag, + policy, + policyTagList, + policyRecentlyUsedTags: undefined, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + isOffline, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }); + }; + + const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; + const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; + const amountCopyValue = !canEditAmount ? amountTitle : undefined; + const descriptionHTML = updatedTransactionDescription ?? transactionDescription; + const descriptionCopyValue = !canEdit && descriptionHTML ? Parser.htmlToText(descriptionHTML) : undefined; + const merchantCopyValue = !canEditMerchant ? updatedMerchantTitle : undefined; + const dateCopyValue = !canEditDate ? transactionDate : undefined; + const categoryValue = updatedTransaction?.category ?? categoryForDisplay; + const decodedCategoryName = getDecodedLeafCategoryName(categoryValue); + const categoryCopyValue = !canEdit ? decodedCategoryName : undefined; + const cardCopyValue = cardProgramName; + const taxRateValue = hasTaxValueChanged ? taxValue : (transaction?.taxName ?? taxRateTitle ?? fallbackTaxRateTitle ?? ''); + const taxRateCopyValue = !canEditTaxFields ? taxRateValue : undefined; + const taxAmountTitle = formattedTaxAmount ? formattedTaxAmount.toString() : ''; + const taxAmountCopyValue = !canEditTaxFields ? taxAmountTitle : undefined; + + const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTagList); + + const [previousTransactionTag, setPreviousTransactionTag] = useState(transactionTag); + const [previousTag, setPreviousTag] = useState(undefined); + const [currentTransactionTag, setCurrentTransactionTag] = useState(undefined); + if (transactionTag !== previousTransactionTag) { + setPreviousTransactionTag(transactionTag); + setPreviousTag(previousTransactionTag); + setCurrentTransactionTag(transactionTag); + } + const previousTagLength = getLengthOfTag(previousTag ?? ''); + const currentTagLength = getLengthOfTag(currentTransactionTag ?? ''); + + // actualAttendees is already sorted by enrichAndSortAttendees above; pass without localeCompare to preserve that order while stripping the SMS domain. + const getAttendeesTitle = Array.isArray(actualAttendees) ? getAttendeesListDisplayString(actualAttendees) : ''; + const attendeesCopyValue = !canEdit ? getAttendeesTitle : undefined; + + const parentReportDerivedName = parentReport?.reportID ? derivedReportNames?.[parentReport.reportID] : undefined; + const reportNameToDisplay = isFromMergeTransaction ? (updatedTransaction?.reportName ?? translate('common.none')) : getReportName(parentReport, parentReportDerivedName); + const shouldShowReport = !!parentReportID || (isFromMergeTransaction && !!reportNameToDisplay); + const reportCopyValue = !canEditReport && reportNameToDisplay !== translate('common.none') ? reportNameToDisplay : undefined; + const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(updatedTransaction ?? transaction); + + // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const {wideRHPRouteKeys} = useWideRHPState(); + const route = useRoute(); + // The receipt is hidden only for the MoneyRequestView rendered inside a wide RHP, where the wide RHP's left + // receipt panel already shows it. Instances mounted on other screens (e.g. the central report pane behind + // the RHP) must keep their inline receipt, so the check is scoped to this view's own route key. + const isInWideRHP = wideRHPRouteKeys.includes(route.key); + + const isLoading = (!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID; + + const onAmountPress = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (shouldShowSplitIndicator && isSplitAvailable) { + initSplitExpense( + transaction, + transactionThreadReport, + splitEffectivePolicy, + selfDMReportID, + restrictedActionPolicyID, + personalPolicy?.outputCurrency, + getCurrencyDecimals, + getCurrencySymbol, + ); + return; + } + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID, '', '', getReportRHPActiveRoute()), + ); + }; + + const onDescriptionPress = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onMerchantPress = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onDatePress = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onCategoryPress = () => { + if (shouldShowCategoryDisabledAlert) { + showCategoryDisabledAlert(); + return; + } + + if (!transaction?.transactionID) { + return; + } + + if (shouldNavigateToUpgradePath && transactionThreadReport) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, + upgradeBackTo: createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + }), + ), + ); + } else if (!policy && shouldSelectPolicy) { + Navigation.navigate( + ROUTES.SET_DEFAULT_WORKSPACE.getRoute( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + ), + ); + } else { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + ); + } + }; + + const onDistancePress = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (isOdometerDistanceRequest) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)); + return; + } + + if (isManualDistanceRequest) { + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + }; + + const onDistanceRatePress = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (isTrackExpense) { + if (shouldNavigateToUpgradePath && transactionThreadReport) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.DISTANCE_RATES, + }), + ), + ); + return; + } + if (!policy && shouldSelectPolicy) { + Navigation.navigate( + ROUTES.SET_DEFAULT_WORKSPACE.getRoute( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), + ), + ), + ); + return; + } + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + }; + + const onTaxRatePress = () => { + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + + if (!transaction?.transactionID) { + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onTaxAmountPress = () => { + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + + if (!transaction?.transactionID) { + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onReportPress = () => { + if (!canEditReport || !transactionThreadReport || !transaction?.transactionID) { + return; + } + if (shouldNavigateToUpgradePath) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + iouType, + action: CONST.IOU.ACTION.EDIT, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.REPORTS, + }), + ), + ); + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onTripRoomPress = () => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(tripRoomReportID, undefined, undefined, Navigation.getActiveRoute())); + }; + + const onViewTripDetailsPress = () => { + if (!transaction?.transactionID) { + return; + } + const reservations = transaction?.receipt?.reservationList?.length ?? 0; + if (reservations > 1) { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID))); + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0))); + }; + + const onAttendeesPress = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID))); + }; + + const onVendorPress = () => { + if (!transactionThreadReport?.reportID || !transaction?.transactionID) { + return; + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID, getReportRHPActiveRoute()), + ); + }; + + return { + // Loading / guard + isLoading, + + // Styles / localize + styles, + theme, + StyleUtils, + icons, + translate, + + // Core data + transaction, + transactionThreadReport, + parentReport, + policy, + policyID, + iouType, + transactionReimbursable, + transactionBillable, + transactionDescription, + updatedTransactionDescription, + readonly, + updatedTransaction, + mergeTransactionID, + isFromReviewDuplicates, + shouldShowAnimatedBackground, + + // Derived booleans + isDistanceRequest, + isManualDistanceRequest, + isGPSDistanceRequest, + isOdometerDistanceRequest, + isMapDistanceRequest, + isPerDiemRequest, + isFromCardImport, + isFromMergeTransaction, + isTransactionScanning, + isCustomUnitOutOfPolicy, + hasRequiredCompanyCardViolation, + shouldShowPaid, + isInWideRHP, + isSmallScreenWidth, + shouldHideEmptyDescription, + shouldShowCategoryAnalyzing, + + // Editing permissions + canEdit, + canEditAmount, + canEditMerchant, + canEditDate, + canEditDistance, + canEditDistanceRate, + canEditTaxFields, + canEditReport, + canEditReimbursable, + + // Display values + amountTitle, + amountDescription, + actualTransactionDate, + transactionDate, + dateDescription, + updatedMerchantTitle, + merchantTitle, + distanceToDisplay, + distanceToDisplayDescription, + distanceToDisplayHintText, + rateToDisplay, + decodedCategoryName, + taxRateValue, + taxAmountTitle, + taxAmountDescription, + taxRatesDescription, + reportNameToDisplay, + reportCopyValue, + tripRoomName, + tripRoomReportID, + transactionVendorName, + vendorFieldLabel, + cardProgramName, + cardCopyValue, + getAttendeesTitle, + formattedPerAttendeeAmount, + actualAttendees, + + // Visibility flags + shouldShowCategory, + shouldShowTag, + shouldShowBillable, + shouldShowReimbursable, + shouldShowTax, + shouldShowTaxDisabledAlert, + shouldShowAttendees, + shouldShowVendor, + shouldShowReport, + shouldShowTripRoomLink, + shouldShowViewTripDetails, + shouldShowCard, + shouldShowGoogleMerchantSearchLink, + shouldShowTagDisabledAlert, + originalMerchantForGoogleSearch, + + // Violation / error helpers + getViolationsForField, + companyCardPageURL, + connectionLink, + isMarkAsCash, + + // Copy values + amountCopyValue, + descriptionCopyValue, + merchantCopyValue, + dateCopyValue, + categoryCopyValue, + distanceCopyValue, + distanceRateCopyValue, + taxRateCopyValue, + taxAmountCopyValue, + attendeesCopyValue, + + // Callbacks / actions + saveBillable, + saveReimbursable, + showTagDisabledAlert, + getErrorForField, + getPendingFieldAction, + onAmountPress, + onDescriptionPress, + onMerchantPress, + onDatePress, + onCategoryPress, + onDistancePress, + onDistanceRatePress, + onTaxRatePress, + onTaxAmountPress, + onReportPress, + onTripRoomPress, + onViewTripDetailsPress, + onAttendeesPress, + onVendorPress, + + // Tag rendering data + hasDependentTags, + policyTagLists, + transactionTag, + previousTagLength, + currentTagLength, + }; +} + +export default useMoneyRequestViewData; +export type {MoneyRequestViewData}; diff --git a/src/components/ReportActionItem/MovedTransactionAction.tsx b/src/components/ReportActionItem/MovedTransactionAction.tsx index 5e4a28ae305f..500724b1cc82 100644 --- a/src/components/ReportActionItem/MovedTransactionAction.tsx +++ b/src/components/ReportActionItem/MovedTransactionAction.tsx @@ -2,12 +2,12 @@ import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Parser from '@libs/Parser'; -import {getOriginalMessage, hasReasoning} from '@libs/ReportActionsUtils'; -import {getMovedTransactionMessage} from '@libs/ReportUtils'; +import {hasReasoning} from '@libs/ReportActionsUtils'; +import {getMovedTransactionMessage, parseMovedTransactionReportIDs} from '@libs/ReportUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; @@ -30,16 +30,16 @@ type MovedTransactionActionProps = { function MovedTransactionAction({action, originalReport}: MovedTransactionActionProps) { const {translate} = useLocalize(); - const movedTransactionOriginalMessage = getOriginalMessage(action); - const fromReportID = movedTransactionOriginalMessage?.fromReportID; + const {fromReportID, toReportID, displayReportID} = parseMovedTransactionReportIDs(action); const [fromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`); + const [displayReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${displayReportID}`); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`); - const reportAttributes = useReportAttributes(); const isPendingDelete = fromReport?.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const message = getMovedTransactionMessage(translate, action, reportAttributes); + const derivedReportName = useDerivedReportNameByReportID(displayReportID); + const message = getMovedTransactionMessage({translate, movedReport: displayReport, fromReportID, toReportID, derivedReportName}); if (hasReasoning(action)) { return ( diff --git a/src/components/ReportActionItem/TripDetailsView.tsx b/src/components/ReportActionItem/TripDetailsView.tsx index 548f964b3edd..78a2fc6058f3 100644 --- a/src/components/ReportActionItem/TripDetailsView.tsx +++ b/src/components/ReportActionItem/TripDetailsView.tsx @@ -14,6 +14,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import StringUtils from '@libs/StringUtils'; @@ -22,7 +23,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {ReservationData} from '@src/libs/TripReservationUtils'; import {formatCancelledDescription, formatTransitLocationLabel, getPNRReservationDataFromTripReport, getTripReservationCode, getTripReservationIcon} from '@src/libs/TripReservationUtils'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; import type {Reservation} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; @@ -170,9 +171,7 @@ function ReservationView({reservation, transactionID, tripRoomReportID, sequence iconStyles={[StyleUtils.getTripReservationIconContainer(false), styles.mr3, shouldCenterIcon && styles.alignSelfCenter]} secondaryIconFill={theme.icon} onPress={() => - Navigation.navigate( - ROUTES.TRAVEL_TRIP_DETAILS.getRoute(tripRoomReportID, transactionID, String(reservation.reservationID), sequenceIndex, Navigation.getReportRHPActiveRoute()), - ) + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.getRoute(tripRoomReportID, transactionID, String(reservation.reservationID), sequenceIndex))) } /> ); diff --git a/src/components/ReportActionItem/UnreportedTransactionAction.tsx b/src/components/ReportActionItem/UnreportedTransactionAction.tsx index 066e78010f1d..c2b3b4de2cfa 100644 --- a/src/components/ReportActionItem/UnreportedTransactionAction.tsx +++ b/src/components/ReportActionItem/UnreportedTransactionAction.tsx @@ -2,12 +2,12 @@ import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Parser from '@libs/Parser'; -import {getOriginalMessage, hasReasoning} from '@libs/ReportActionsUtils'; -import {getUnreportedTransactionMessage} from '@libs/ReportUtils'; +import {hasReasoning} from '@libs/ReportActionsUtils'; +import {getUnreportedTransactionMessage, parseMovedTransactionReportIDs} from '@libs/ReportUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; @@ -29,16 +29,15 @@ type UnreportedTransactionActionProps = { }; function UnreportedTransactionAction({action, originalReport}: UnreportedTransactionActionProps) { - const unreportedTransactionOriginalMessage = getOriginalMessage(action); - const fromReportID = unreportedTransactionOriginalMessage?.fromReportID; + const {fromReportID} = parseMovedTransactionReportIDs(action); const {translate} = useLocalize(); const [fromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`); - const reportAttributes = useReportAttributes(); const isPendingDelete = fromReport?.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const unreportedTransactionMessage = getUnreportedTransactionMessage(translate, action, reportAttributes); + const derivedReportName = useDerivedReportNameByReportID(fromReportID); + const unreportedTransactionMessage = getUnreportedTransactionMessage({translate, fromReport, fromReportID, derivedReportName}); if (hasReasoning(action)) { return ( diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index e412feb7168a..9363fbbc30ff 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -64,6 +64,9 @@ type SearchAutocompleteListProps = { /** Value of TextInput */ autocompleteQueryValue: string; + /** Immediate (non-debounced) query from the input for UI-only behavior */ + inputQueryValue?: string; + /** Callback to trigger search action * */ handleSearch: (value: string) => void; @@ -165,6 +168,7 @@ function SearchRouterItem(props: UserListItemProps | Searc function SearchAutocompleteList({ autocompleteQueryValue, + inputQueryValue, handleSearch, searchQueryItems, getAdditionalSections, @@ -200,6 +204,15 @@ function SearchAutocompleteList({ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const effectiveInputQueryValue = inputQueryValue ?? autocompleteQueryValue; + const hasEffectiveInputQuery = effectiveInputQueryValue.trim() !== ''; + // hasEffectiveInputQuery reflects the immediate input (used to hide recent searches the moment the user types). + // hasActiveSearchResults additionally requires the debounced autocompleteQueryValue to be non-empty, i.e. the + // filtered searchOptions/recentReportsOptions actually reflect the typed query. Gating the results layout on this + // (rather than the immediate value) keeps the sections in sync with the data they render: during the debounce + // window we keep showing recent chats instead of briefly rendering the previous/unfiltered rows under the search + // layout and then reflowing once the debounced query catches up. + const hasActiveSearchResults = hasEffectiveInputQuery && autocompleteQueryValue.trim() !== ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -287,9 +300,14 @@ function SearchAutocompleteList({ ]); const [isInitialRender, setIsInitialRender] = useState(true); - const prevQueryRef = useRef(autocompleteQueryValue); + const prevQueryRef = useRef(effectiveInputQueryValue); const innerListRef = useRef(null); const hasSetInitialFocusRef = useRef(false); + // Tracks the row key we last focused programmatically (reset-to-top below), so the auto-highlight + // effect can tell whether the user has since navigated away with the arrow keys. Without this, + // auto-highlight would silently snap focus back once the debounce settles even if the user had + // already moved to a different row (e.g. Ask Concierge) in the meantime. + const lastProgrammaticFocusKeyRef = useRef(undefined); // Callback ref to set both inner ref and forward to external ref const setListRef = (instance: SelectionListWithSectionsHandle | null) => { @@ -303,28 +321,6 @@ function SearchAutocompleteList({ } }; - // Reset focus when query changes to prevent stale focus on wrong items - useEffect(() => { - if (isInitialRender) { - return; - } - - const queryChanged = prevQueryRef.current !== autocompleteQueryValue; - prevQueryRef.current = autocompleteQueryValue; - - if (queryChanged) { - if (autocompleteQueryValue === '') { - // When query is cleared, reset the initial focus guard so the initial focus - // effect can re-fire and correctly focus the first focusable item (skipping section headers). - hasSetInitialFocusRef.current = false; - } else { - // When query changes to a non-empty value, focus on the search query item (index 0) and scroll to top. - // The highlight effect below switches focus to the first result when there's a good match. - innerListRef.current?.updateAndScrollToFocusedIndex(0, true); - } - } - }, [autocompleteQueryValue, isInitialRender]); - // Track external text input focus to prevent list items from stealing focus while typing useEffect(() => { if (!textInputRef?.current) { @@ -341,7 +337,7 @@ function SearchAutocompleteList({ // Note: We can't easily subscribe to focus/blur events on the ref, so we update on query changes // which happen when the user types (meaning input is focused) - }, [textInputRef, autocompleteQueryValue]); + }, [textInputRef, effectiveInputQueryValue]); const autocompleteSuggestions = useAutocompleteSuggestions({ autocompleteQueryValue, @@ -415,10 +411,14 @@ function SearchAutocompleteList({ ]); const recentReportsOptions = useMemo(() => { - if (autocompleteQueryValue.trim() === '') { + if (!hasActiveSearchResults) { return searchOptions.recentReports; } + // searchOptions/autocompleteQueryValue are debounced. For a query -> query change this still returns the + // previous query's matches during the debounce window (rows stay visible, preserving focus/Enter/arrow keys). + // For the empty -> query transition hasActiveSearchResults is false until the debounced query lands, so this + // returns recent chats instead of unfiltered rows, avoiding the stale-then-filtered reflow. const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, autocompleteQueryValue, { sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, @@ -430,7 +430,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, searchOptions]); + }, [autocompleteQueryValue, hasActiveSearchResults, searchOptions]); // Locked rank map (stable key -> originalIndex) capturing the order of locally-known // results at the moment the query changes. Recomputed only when the query changes, so server @@ -461,6 +461,12 @@ function SearchAutocompleteList({ setFrozenLocalRank(buildRankMap(recentReportsOptions)); } + // Callers that pass a distinct inputQueryValue (e.g. SearchRouter) already debounce autocompleteQueryValue + // upstream, so firing handleSearch immediately here avoids stacking a second debounce on top and doubling + // the delay. Callers that don't pass inputQueryValue (e.g. the Spend page header) still get the local + // debounce below so they don't fire a server request on every keystroke. + const hasUpstreamDebounce = inputQueryValue !== undefined; + const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { return; @@ -470,8 +476,17 @@ function SearchAutocompleteList({ }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); useEffect(() => { + if (!handleSearch || !autocompleteQueryWithoutFilters) { + return; + } + + if (hasUpstreamDebounce) { + handleSearch(autocompleteQueryWithoutFilters); + return; + } + debounceHandleSearch(); - }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); + }, [autocompleteQueryWithoutFilters, debounceHandleSearch, handleSearch, hasUpstreamDebounce]); /* Sections generation */ const {sections, styledRecentReports, suggestionsCount} = useMemo(() => { @@ -497,7 +512,7 @@ function SearchAutocompleteList({ } } - if (!autocompleteQueryValue && recentSearchesData && recentSearchesData.length > 0) { + if (!hasEffectiveInputQuery && recentSearchesData && recentSearchesData.length > 0) { pushSection({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); } @@ -523,8 +538,9 @@ function SearchAutocompleteList({ /> ); - if (autocompleteQueryValue.trim() === '') { - // Empty query: single "Recent chats" section + if (!hasActiveSearchResults) { + // No active (debounced) query yet: single "Recent chats" section. This also covers the debounce window + // right after the user starts typing, so we keep recent chats visible instead of flashing search rows. if (!isLoadingOptions) { pushSection({title: translate('search.recentChats'), data: nextStyledRecentReports, sectionIndex: sectionIndex++}); } else { @@ -600,7 +616,8 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ - autocompleteQueryValue, + hasEffectiveInputQuery, + hasActiveSearchResults, autocompleteSuggestions, expensifyIcons, frozenLocalRank, @@ -638,9 +655,78 @@ function SearchAutocompleteList({ useDebouncedAccessibilityAnnouncement(noResultsFoundText, shouldAnnounceNoResults, autocompleteQueryValue); const recentReportKeys = new Set(styledRecentReports.map((report) => report.keyForList)); - const {firstRecentReportText, firstRecentReportFlatIndex, defaultFocusedKey, defaultFocusedFlatIndex} = getAutocompleteInitialFocus(sections, recentReportKeys); + const {firstRecentReportKey, firstRecentReportText, firstRecentReportFlatIndex, defaultFocusedKey, defaultFocusedFlatIndex} = getAutocompleteInitialFocus(sections, recentReportKeys); const normalizedReferenceText = firstRecentReportText.toLowerCase(); + // Stable across renders while the query is non-empty (searchQueryItems is a fresh array reference on every + // SearchRouter render since it isn't memoized there, but its first item's key is always this same constant). + // Depending on this value instead of the raw array keeps the effects below from re-running on every keystroke. + const searchQueryRowKey = searchQueryItems?.at(0)?.keyForList; + + // Reset focus when query changes to prevent stale focus on wrong items. + useEffect(() => { + if (isInitialRender) { + return; + } + + const queryChanged = prevQueryRef.current !== effectiveInputQueryValue; + prevQueryRef.current = effectiveInputQueryValue; + + if (!queryChanged) { + return; + } + + if (effectiveInputQueryValue === '') { + // When query is cleared, reset the initial focus guard so the initial focus + // effect can re-fire and correctly focus the first focusable item (skipping section headers). + hasSetInitialFocusRef.current = false; + return; + } + + // autocompleteQueryValue (debounced) can already equal the freshly typed text the moment the query + // changes, not just once it "catches up" later. This happens on a fast clear + retype of the exact same + // text: clearing doesn't reset the debounce hook's internal value, so retyping the same text is a no-op + // update and the debounced prop never changes again -- nothing would re-run the highlight effect below + // afterward. Deciding the correct focus target here, synchronously from this render's own props (rather + // than by reading the list's focus state back, which lags a render behind our own updates), is what + // prevents focus from being left stranded on the search-query row in that case. + const isDebounceSettled = autocompleteQueryValue.trim() === effectiveInputQueryValue.trim(); + if (isDebounceSettled && shouldHighlightFirstItem && firstRecentReportFlatIndex !== -1 && shouldHighlight(normalizedReferenceText, autocompleteQueryValue)) { + lastProgrammaticFocusKeyRef.current = firstRecentReportKey; + innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, true); + return; + } + + // The debounce hasn't caught up yet for this keystroke. If focus is already resting on the match from a + // previous, already-settled keystroke, and the freshly typed text still matches that same row, keep focus + // there instead of bouncing to the query row and waiting out a fresh debounce window -- this is what + // prevents a visible flicker when continuing to type past an already-highlighted match (production has no + // debounce gap here, so it never loses the highlight in this case either). + if ( + shouldHighlightFirstItem && + firstRecentReportFlatIndex !== -1 && + lastProgrammaticFocusKeyRef.current === firstRecentReportKey && + shouldHighlight(normalizedReferenceText, effectiveInputQueryValue) + ) { + return; + } + + // Otherwise the debounce is still pending and the match is no longer valid for this keystroke: focus the + // search query item (index 0) and scroll to top. The highlight effect below switches focus to the first + // result once a good match settles. + lastProgrammaticFocusKeyRef.current = searchQueryRowKey; + innerListRef.current?.updateAndScrollToFocusedIndex(0, true); + }, [ + autocompleteQueryValue, + effectiveInputQueryValue, + firstRecentReportFlatIndex, + firstRecentReportKey, + isInitialRender, + normalizedReferenceText, + searchQueryRowKey, + shouldHighlightFirstItem, + ]); + // When options initialize after the list is already mounted, initiallyFocusedItemKey has no effect // because useState(initialFocusedIndex) in useArrowKeyFocusManager only reads the initial value. // Imperatively focus the default row once options become available (desktop only). @@ -650,19 +736,35 @@ function SearchAutocompleteList({ } hasSetInitialFocusRef.current = true; + // Track whatever we actually focused (the contextual "Search in " suggestion when present, else the + // first recent report) so the auto-highlight effect below can tell whether the user has since navigated + // away, versus us having landed on a row other than firstRecentReportKey to begin with. + lastProgrammaticFocusKeyRef.current = defaultFocusedKey; innerListRef.current?.updateAndScrollToFocusedIndex(defaultFocusedFlatIndex, false); - }, [isLoadingOptions, defaultFocusedFlatIndex, shouldUseNarrowLayout]); + }, [isLoadingOptions, defaultFocusedFlatIndex, defaultFocusedKey, shouldUseNarrowLayout]); useEffect(() => { - const targetText = autocompleteQueryValue; + if (!shouldHighlightFirstItem || firstRecentReportFlatIndex === -1 || !shouldHighlight(normalizedReferenceText, autocompleteQueryValue)) { + return; + } - if (!shouldHighlightFirstItem || firstRecentReportFlatIndex === -1 || !shouldHighlight(normalizedReferenceText, targetText)) { + // Only suppress the auto-highlight when the user has manually moved focus to some *other* real row + // (e.g. arrow-keyed to Ask Concierge). The reset effect above always keeps lastProgrammaticFocusKeyRef in + // sync with wherever it last placed focus (the search-query row while unsettled, or the first result once + // settled), so comparing against it alone is enough to detect a real, user-initiated divergence -- this + // effect is what promotes focus onto the first result once the debounce genuinely settles *later*, the + // common, non-coalesced typing flow. + const currentFocusedKey = innerListRef.current?.getFocusedOption?.()?.keyForList; + const isOnProgrammaticTarget = lastProgrammaticFocusKeyRef.current === undefined || currentFocusedKey === lastProgrammaticFocusKeyRef.current; + if (!isOnProgrammaticTarget) { return; } + // Focus the header-aware flat index of the first result. A fixed index (e.g. searchQueryItems.length) // lands on the "Recent chats" section header row after the two-section switcher was introduced. + lastProgrammaticFocusKeyRef.current = firstRecentReportKey; innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, true); - }, [autocompleteQueryValue, firstRecentReportFlatIndex, normalizedReferenceText, shouldHighlightFirstItem]); + }, [autocompleteQueryValue, firstRecentReportFlatIndex, firstRecentReportKey, normalizedReferenceText, shouldHighlightFirstItem]); if (isLoading) { return ( diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 3556c4cc3113..ee5f66145356 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type {SearchChartProps} from './types'; -function SearchBarChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { +function SearchBarChart({data, getLabel, getShortLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { const {getCurrencyDecimals} = useCurrencyListActions(); const chartData: ChartDataPoint[] = data.map((item) => { const currency = item.currency ?? 'USD'; @@ -18,6 +18,7 @@ function SearchBarChart({data, getLabel, getFilterQuery, onItemPress, isLoading, return { label: getLabel(item), + shortLabel: getShortLabel?.(item), total: totalInDisplayUnits, }; }); diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 69650d56bfb4..f5df6f65b5c4 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -54,7 +54,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar const {preferredLocale} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); - const {getLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy]; + const {getLabel, getShortLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy]; const ChartComponent = CHART_VIEW_TO_COMPONENT[view]; const handleItemPress = (filterQuery: string) => { @@ -90,6 +90,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar StringUtils.normalize(getLabel(item))} + getShortLabel={getShortLabel} getFilterQuery={getFilterQuery} onItemPress={handleItemPress} isLoading={isLoading} diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index 0e19a3755587..41a9424be910 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type {SearchChartProps} from './types'; -function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { +function SearchLineChart({data, getLabel, getShortLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { const {getCurrencyDecimals} = useCurrencyListActions(); const chartData: ChartDataPoint[] = data.map((item) => { const currency = item.currency ?? 'USD'; @@ -18,6 +18,7 @@ function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading return { label: getLabel(item), + shortLabel: getShortLabel?.(item), total: totalInDisplayUnits, }; }); diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 9b1954d33503..36bca1806d6f 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -191,7 +191,7 @@ function ExpenseReportListItemInner({ const relevantViolations = (transaction.violations ?? []).filter( (violation) => !isViolationDismissed(transaction, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, submitterLogin, policyForViolations) && - shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transaction), + shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', currentUserDetails.accountID, false, transaction), ); const violations = syncMissingAttendeesViolation( diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx index 29898abe96b9..eaad08b0a54e 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -185,7 +185,7 @@ function TransactionListItemInner({ const onyxViolations = (transactionViolationsForRow ?? []).filter( (violation: TransactionViolation) => !isViolationDismissed(transactionItem, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, submitterLogin, policyForViolations) && - shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transactionItem), + shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', currentUserDetails.accountID, false, transactionItem), ); const isInvoice = isInvoiceReport(reportForViolations) || reportForViolations.type === CONST.REPORT.TYPE.INVOICE; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index aa6db677b362..cb350c9c2b18 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -418,6 +418,9 @@ type TransactionMonthGroupListItemType = TransactionGroupListItemType & {grouped /** Final and formatted "month" value used for displaying */ formattedMonth: string; + /** Compact "month" value used where space is tight, e.g. chart axis labels */ + shortFormattedMonth: string; + /** Key used for sorting */ sortKey: number; }; @@ -448,6 +451,9 @@ type TransactionTagGroupListItemType = TransactionGroupListItemType & {groupedBy type TransactionWeekGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.WEEK} & SearchWeekGroup & { /** Final and formatted "week" value used for displaying */ formattedWeek: string; + + /** Compact "week" value used where space is tight, e.g. chart axis labels */ + shortFormattedWeek: string; }; type TransactionYearGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.YEAR} & SearchYearGroup & { @@ -462,6 +468,9 @@ type TransactionQuarterGroupListItemType = TransactionGroupListItemType & {group /** Final and formatted "quarter" value used for displaying */ formattedQuarter: string; + /** Compact "quarter" value used where space is tight, e.g. chart axis labels */ + shortFormattedQuarter: string; + /** Sort key for sorting */ sortKey: number; }; diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 48986aa1ab4e..17a854333fd2 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -153,10 +153,9 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla return [query, substitutions]; }); - // The actual input text that the user sees - const [textInputValue, , setTextInputValue] = useDebouncedState(initialQuery, 500); - // The input text that was last used for autocomplete; needed for the SearchAutocompleteList when browsing list via arrow keys - const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(initialQuery); + const [textInputValue, setTextInputValue] = useState(initialQuery); + // Debounced value gates expensive filtering in the autocomplete list + const [, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState(initialQuery, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: initialQuery.length, end: initialQuery.length}); useEffect(() => { @@ -354,7 +353,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setAutocompleteSubstitutions(updatedSubstitutionsMap); } }, - [autocompleteSubstitutions, setTextInputValue, textInputValue], + [autocompleteSubstitutions, setAutocompleteQueryValue, setTextInputValue, textInputValue], ); const submitSearch = useCallback( @@ -379,7 +378,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setTextInputValue(''); setAutocompleteQueryValue(''); }, - [autocompleteSubstitutions, currentUserAccountID, onRouterClose, setTextInputValue, setShouldResetSearchQuery, isFromSearchPageSearchButton, policies], + [autocompleteSubstitutions, currentUserAccountID, onRouterClose, setAutocompleteQueryValue, setTextInputValue, setShouldResetSearchQuery, isFromSearchPageSearchButton, policies], ); const onListItemPress = useCallback( @@ -506,6 +505,18 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla onSearchQueryChange={onSearchQueryChange} onSubmit={() => { const focusedOption = listRef.current?.getFocusedOption?.(); + const isInputAheadOfDebounce = !!textInputValue && textInputValue !== debouncedAutocompleteQueryValue; + + // During the debounce window, keep keyboard behavior for focused search rows + // (e.g. Ask Concierge / typed query row), but avoid stale non-search row submits. + if (isInputAheadOfDebounce) { + if (focusedOption && isSearchQueryItem(focusedOption)) { + onListItemPress(focusedOption); + return; + } + submitSearch(textInputValue); + return; + } if (!focusedOption) { submitSearch(textInputValue); @@ -526,7 +537,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> string; + + /** Returns the compact label for chart axes, or undefined to fall back to `getLabel` */ + getShortLabel?: (item: GroupedItem) => string | undefined; + + /** Builds the query fragment appended to the current query to drill into a group's transactions */ getFilterQuery: (item: GroupedItem) => string; }; @@ -61,6 +69,7 @@ const CHART_GROUP_BY_CONFIG: Record = { [CONST.SEARCH.GROUP_BY.MONTH]: { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '', + getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? item.shortFormattedMonth : undefined), getFilterQuery: (item: GroupedItem) => { const monthItem = item as TransactionMonthGroupListItemType; const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month); @@ -70,6 +79,7 @@ const CHART_GROUP_BY_CONFIG: Record = { [CONST.SEARCH.GROUP_BY.WEEK]: { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '', + getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.shortFormattedWeek : undefined), getFilterQuery: (item: GroupedItem) => { const weekItem = item as TransactionWeekGroupListItemType; const {start, end} = DateUtils.getWeekDateRange(weekItem.week); @@ -88,6 +98,7 @@ const CHART_GROUP_BY_CONFIG: Record = { [CONST.SEARCH.GROUP_BY.QUARTER]: { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '', + getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? item.shortFormattedQuarter : undefined), getFilterQuery: (item: GroupedItem) => { const quarterItem = item as TransactionQuarterGroupListItemType; const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter); diff --git a/src/components/Search/getAutocompleteInitialFocus.ts b/src/components/Search/getAutocompleteInitialFocus.ts index a421f8e1bed1..5c158ac29379 100644 --- a/src/components/Search/getAutocompleteInitialFocus.ts +++ b/src/components/Search/getAutocompleteInitialFocus.ts @@ -16,6 +16,10 @@ type InitialFocusSection = { }; type InitialFocusTarget = { + /** Key of the first recent report row, or undefined when none is rendered. Distinct from defaultFocusedKey, + * which may point at the "Search in " suggestion instead -- callers that auto-highlight the first + * recent report match need this key specifically, not whatever the initial-open focus target is. */ + firstRecentReportKey: string | undefined; /** Text of the first recent report row, used to decide whether a typed query highlights it. */ firstRecentReportText: string; /** Header-aware flat index of the first recent report row, or -1 when none is rendered. */ @@ -65,6 +69,7 @@ function getAutocompleteInitialFocus(sections: InitialFocusSection[], recentRepo } return { + firstRecentReportKey, firstRecentReportText, firstRecentReportFlatIndex, // Prefer the "Search in " suggestion so Enter searches the current chat rather than opening a recent one. diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 64ddbaedb3ed..a255ff30f41a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -448,6 +448,9 @@ type SearchChartProps = { /** Function to extract label from grouped item */ getLabel: (item: GroupedItem) => string; + /** Function to extract the compact axis label from grouped item. When it returns undefined, `getLabel` is used. */ + getShortLabel?: (item: GroupedItem) => string | undefined; + /** Function to build filter query from grouped item */ getFilterQuery: (item: GroupedItem) => string; diff --git a/src/components/SubStepForms/InternationalBankAccountDetailsStep.tsx b/src/components/SubStepForms/InternationalBankAccountDetailsStep.tsx new file mode 100644 index 000000000000..c326d001a657 --- /dev/null +++ b/src/components/SubStepForms/InternationalBankAccountDetailsStep.tsx @@ -0,0 +1,107 @@ +/** + * Shared step for collecting international bank account details (IBAN + SWIFT/BIC), used by both the USD and the + * Corpay international personal bank account flows so they present the same copy and layout. + */ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import type {SubPageProps} from '@hooks/useSubPage/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; + +import React from 'react'; +import {View} from 'react-native'; + +const IBAN_INPUT_ID = 'iban'; +const SWIFT_CODE_INPUT_ID = 'swiftCode'; + +type InternationalBankAccountDetailsStepProps = Pick & { + /** The ID of the form */ + formID: TFormID; + + /** The validation function to call when the form is submitted */ + validate: (values: FormOnyxValues) => FormInputErrors; + + /** A function to call when the form is submitted */ + onSubmit: (values: FormOnyxValues) => void; + + /** Pre-filled IBAN value, if one is already known */ + ibanDefaultValue?: string; + + /** Pre-filled SWIFT/BIC code value, if one is already known */ + swiftCodeDefaultValue?: string; + + /** Whether the IBAN input is non-editable because it was already collected on the bank details page */ + isIBANDisabled?: boolean; + + /** Whether the SWIFT/BIC input is non-editable because it was already collected on the bank details page */ + isSwiftCodeDisabled?: boolean; +}; + +function InternationalBankAccountDetailsStep({ + formID, + validate, + onSubmit, + ibanDefaultValue = '', + swiftCodeDefaultValue = '', + isIBANDisabled = false, + isSwiftCodeDisabled = false, + isEditing, +}: InternationalBankAccountDetailsStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + + return ( + + + {translate('bankAccount.internationalBankAccountDetailsTitle')} + {translate('bankAccount.internationalBankAccountDetailsSubtitle')} + + + + + ); +} + +InternationalBankAccountDetailsStep.displayName = 'InternationalBankAccountDetailsStep'; + +export default InternationalBankAccountDetailsStep; +export {IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID}; diff --git a/src/components/SubmitPlanWelcomeModal.tsx b/src/components/SubmitPlanWelcomeModal.tsx index 2a9c62237503..e15ad71c7262 100644 --- a/src/components/SubmitPlanWelcomeModal.tsx +++ b/src/components/SubmitPlanWelcomeModal.tsx @@ -20,7 +20,7 @@ import React from 'react'; import {View} from 'react-native'; import CenteredModalLayout from './CenteredModalLayout'; -import FeatureTrainingContent from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import Text from './Text'; const FEATURE_TRANSLATION_KEYS: TranslationPaths[] = [ @@ -39,8 +39,6 @@ function SubmitPlanWelcomeModal() { const {firstName, lastName} = useCurrentUserPersonalDetails(); const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); - // Whenever this modal is removed from the navigation stack (confirmed, dismissed, or closed), - // persist that the user has seen it so it never triggers again. useBeforeRemove(() => { setSubmitMigrationModalShown(); }); @@ -48,9 +46,6 @@ function SubmitPlanWelcomeModal() { const handleClose = () => Navigation.goBack(); const handleConfirm = () => { - // The user has already completed onboarding, so we skip CompleteGuidedSetup and just create the - // Submit workspace. autoCreateSubmitWorkspace then dismisses this modal and navigates to Categories - // with #admins in the RHP, which triggers the useBeforeRemove persistence above. autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); }; @@ -58,40 +53,40 @@ function SubmitPlanWelcomeModal() { - - - {FEATURE_TRANSLATION_KEYS.map((translationKey) => ( - - {'\u2022 '} - {translate(translationKey)} + + + + {translate('submitPlanWelcomeModal.title')} + {translate('submitPlanWelcomeModal.description')} + + {FEATURE_TRANSLATION_KEYS.map((translationKey) => ( + + {'• '} + {translate(translationKey)} + + ))} - ))} - - + + {translate('submitPlanWelcomeModal.dismissText')} + {translate('submitPlanWelcomeModal.confirmText')} + + ); } diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 0df586b70057..2b286dc78bf4 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -1,3 +1,52 @@ -import BaseTextInput from './implementation'; +import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; + +import variables from '@styles/variables'; + +import type {TextInputKeyPressEvent, TextInputSubmitEditingEvent} from 'react-native'; + +import React from 'react'; + +import type {BaseTextInputProps} from './types'; + +import BaseTextInputImplementation from './implementation'; + +/** + * `autoGrowSingleLine` grows the input up to `variables.textInputAutoGrowMaxHeight` then scrolls internally, while the + * return key (incl. Shift+Enter) submits instead of inserting a line break. Only web passes a keyboard event to the + * keypress handler, so on native the wiring is a no-op and submitting is driven by `submitBehavior`/`returnKeyType`. + * Pass `useAutoFocusInput(true)` when auto-focusing so the caret lands after an existing value. + */ +function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInputProps) { + // react-native-web treats Shift+Enter as a line break rather than a submit, so submit it manually — but only + // when there is a submit handler, otherwise there is nothing to preventDefault for. + const submitOnShiftEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event) || event.key !== 'Enter' || !event.shiftKey || isEnterWhileComposition(event) || !props.onSubmitEditing) { + return; + } + + event.preventDefault(); + + // The submit handler ignores the event payload, so the cast is safe. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + props.onSubmitEditing(event as unknown as TextInputSubmitEditingEvent); + }; + + const handleKeyPress = (event: TextInputKeyPressEvent) => { + props.onKeyPress?.(event); + submitOnShiftEnter(event); + }; + + return ( + + ); +} export default BaseTextInput; diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 50a05bdf2bcc..a0f3608c8012 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -89,6 +89,12 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & */ maxAutoGrowHeight?: number; + /** + * Lets a single-line value (title, name) grow like a multiline box, while the return key submits + * instead of inserting a line break. Implies `autoGrowHeight`. + */ + autoGrowSingleLine?: boolean; + /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; diff --git a/src/components/TimeModalPicker.tsx b/src/components/TimeModalPicker.tsx index 135bf534ec87..17b372a20c7f 100644 --- a/src/components/TimeModalPicker.tsx +++ b/src/components/TimeModalPicker.tsx @@ -1,3 +1,4 @@ +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; @@ -34,8 +35,9 @@ type TimeModalPickerProps = { function TimeModalPicker({value, errorText, label, onInputChange = () => {}, ref}: TimeModalPickerProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const [isPickerVisible, setIsPickerVisible] = useState(false); - const currentTime = value ? DateUtils.extractTime12Hour(value) : undefined; + const currentTime = value ? DateUtils.getTime12HourWithTranslatedPeriod(translate, value) : undefined; const hidePickerModal = () => { setIsPickerVisible(false); diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index c27b37694980..b569350f1969 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -34,9 +34,12 @@ type WorkspaceMemberRoleListProps = { navigateBackTo?: Route; isLoading?: boolean; onSelectRole?: (value: ListItemType) => void; + + /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be an Admin or Payments Admin) */ + allowedRoles?: Array>; }; -function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}}: WorkspaceMemberRoleListProps) { +function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}, allowedRoles = undefined}: WorkspaceMemberRoleListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); @@ -86,7 +89,9 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo }, ]; - const availableRoleItems: ListItemType[] = workspaceRoles.filter((item) => canMemberAssignRole(policy, currentUserLogin, item.value)); + const availableRoleItems: ListItemType[] = workspaceRoles.filter( + (item) => canMemberAssignRole(policy, currentUserLogin, item.value) && (!allowedRoles || allowedRoles.includes(item.value)), + ); return ( <> diff --git a/src/hooks/useCanEnrollNewExpensifyCardProgram.ts b/src/hooks/useCanEnrollNewExpensifyCardProgram.ts new file mode 100644 index 000000000000..a6852b408728 --- /dev/null +++ b/src/hooks/useCanEnrollNewExpensifyCardProgram.ts @@ -0,0 +1,21 @@ +import CONST from '@src/CONST'; + +import useExpensifyCardUkEuSupported from './useExpensifyCardUkEuSupported'; +import usePolicy from './usePolicy'; + +/** + * Whether the workspace currency allows enrollment in a new Expensify Card program. + * Linking an existing card feed is not restricted by currency. + * + * Also returns `isUkEuCurrencySupported` so that callers which need both values do not have to call + * `useExpensifyCardUkEuSupported` a second time. + */ +export default function useCanEnrollNewExpensifyCardProgram(policyID?: string) { + const policy = usePolicy(policyID); + const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID); + + return { + canEnrollNewCardProgram: policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported, + isUkEuCurrencySupported, + }; +} diff --git a/src/hooks/useCancelSendMessageSpanOnSkeleton.ts b/src/hooks/useCancelSendMessageSpanOnSkeleton.ts index 40b8f948c00e..ea319a33a4a0 100644 --- a/src/hooks/useCancelSendMessageSpanOnSkeleton.ts +++ b/src/hooks/useCancelSendMessageSpanOnSkeleton.ts @@ -28,7 +28,7 @@ function useCancelSendMessageSpanOnSkeleton(reportID: string | undefined, skelet // `client.on` returns an unsubscribe function, used as the effect cleanup. return client.on('spanStart', (span) => { const {op, data} = Sentry.spanToJSON(span); - if ((op !== CONST.TELEMETRY.SPAN_SEND_MESSAGE && op !== CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE) || data[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID] !== reportID) { + if (op !== CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE || data[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID] !== reportID) { return; } // Defer so activeSpans has registered the span (set right after the `startInactiveSpan` that emits this). diff --git a/src/hooks/useCommuterExclusionGuard.ts b/src/hooks/useCommuterExclusionGuard.ts index e1ba09a158d1..524e85128739 100644 --- a/src/hooks/useCommuterExclusionGuard.ts +++ b/src/hooks/useCommuterExclusionGuard.ts @@ -1,9 +1,17 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import Navigation from '@libs/Navigation/Navigation'; +import {getCurrentAddress} from '@libs/PersonalDetailsUtils'; import {isCommuterExclusionEnabled} from '@libs/PolicyDistanceRatesUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import type Policy from '@src/types/onyx/Policy'; +import type PrivatePersonalDetails from '@src/types/onyx/PrivatePersonalDetails'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useCallback} from 'react'; @@ -22,62 +30,118 @@ type UseCommuterExclusionGuardParams = { /** Whether the current flow is for an odometer distance request */ isOdometerDistanceRequest?: boolean; + + /** Whether the current flow is for any distance request */ + isDistanceRequest?: boolean; }; -type PoliciesWithCommuterExclusions = Record; +type PolicyWithCommuterExclusions = { + method: NonNullable['method']; + name: Policy['name']; +}; + +type PoliciesWithCommuterExclusions = Record; + +type CommuterExclusionGuardReason = 'mapOrGpsRequired' | 'homeAddressRequired'; const policiesWithCommuterExclusionsSelector = (policies: OnyxCollection): PoliciesWithCommuterExclusions => Object.values(policies ?? {}).reduce((acc, policy) => { if (isCommuterExclusionEnabled(policy)) { - acc[policy.id] = true; + acc[policy.id] = { + method: policy.commuterExclusions.method, + name: policy.name, + }; } return acc; }, {}); +const hasHomeAddressSelector = (privatePersonalDetails: OnyxEntry) => !!getCurrentAddress(privatePersonalDetails)?.street?.trim(); + /** - * Returns a guard function that blocks manual/odometer distance flows for policies - * that have commuter exclusions configured. Callers can pass an override policy ID - * when checking a newly selected workspace before committing it. + * Returns a guard function that blocks unsupported distance flows for policies with + * commuter exclusions configured. Callers can pass an override policy ID when + * checking a newly selected workspace before committing it. * - * When a block occurs, it surfaces a modal explaining that only map/GPS distance - * is supported (because exclusions are computed from the mapped route) and returns - * true so callers can early return. + * When a block occurs, it surfaces the relevant modal and returns true so callers + * can early return. */ -function useCommuterExclusionGuard({policyID, isManualDistanceRequest = false, isOdometerDistanceRequest = false}: UseCommuterExclusionGuardParams) { +function useCommuterExclusionGuard({policyID, isManualDistanceRequest = false, isOdometerDistanceRequest = false, isDistanceRequest = false}: UseCommuterExclusionGuardParams) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {showConfirmModal} = useConfirmModal(); const illustrations = useMemoizedLazyIllustrations(['HouseWithMap']); const [policiesWithCommuterExclusions] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesWithCommuterExclusionsSelector}); + const [hasHomeAddress] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {selector: hasHomeAddressSelector}); - return useCallback( - (...policyIDsToCheck: [string?]) => { - const policyIDToCheck = policyIDsToCheck.length > 0 ? policyIDsToCheck[0] : policyID; - if (!isManualDistanceRequest && !isOdometerDistanceRequest) { - return false; + const getGuardReason = useCallback( + (policyIDToCheck: string | undefined): CommuterExclusionGuardReason | undefined => { + if (!policyIDToCheck || !policiesWithCommuterExclusions?.[policyIDToCheck]) { + return; } - if (!policyIDToCheck || !policiesWithCommuterExclusions?.[policyIDToCheck]) { - return false; + if (isManualDistanceRequest || isOdometerDistanceRequest) { + return 'mapOrGpsRequired'; } - showConfirmModal({ - title: translate('distance.error.mapOrGpsDistanceRequired.title'), + if (isDistanceRequest && policiesWithCommuterExclusions[policyIDToCheck].method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !hasHomeAddress) { + return 'homeAddressRequired'; + } + }, + [hasHomeAddress, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, policiesWithCommuterExclusions], + ); + + const showGuardModal = useCallback( + (reason: CommuterExclusionGuardReason, policyIDToCheck: string | undefined) => { + const baseModalProps = { + image: illustrations.HouseWithMap, titleStyles: styles.textHeadline, - prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), promptStyles: styles.textSupporting, - confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, - image: illustrations.HouseWithMap, shouldUseSuccessStyleForConfirm: true, shouldFitImageToContainer: true, imageStyles: styles.commuterExclusionStaticIllustration, + shouldShowDismissIcon: false, + }; + if (reason === 'homeAddressRequired') { + showConfirmModal({ + ...baseModalProps, + title: translate('iou.homeAddressRequired.title'), + prompt: translate('iou.homeAddressRequired.prompt', {workspaceName: policyIDToCheck ? (policiesWithCommuterExclusions?.[policyIDToCheck]?.name ?? '') : ''}), + confirmText: translate('iou.homeAddressRequired.cta'), + }).then(({action: modalAction}) => { + if (modalAction !== ModalActions.CONFIRM) { + return; + } + Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(INPUT_IDS.ADDRESS_LINE_1)); + }); + return; + } + + showConfirmModal({ + ...baseModalProps, + title: translate('distance.error.mapOrGpsDistanceRequired.title'), + prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), + confirmText: translate('common.buttonConfirm'), }); + }, + [illustrations.HouseWithMap, policiesWithCommuterExclusions, showConfirmModal, styles, translate], + ); + const blockDistanceRequestIfNeeded = useCallback( + (...policyIDsToCheck: [string?]) => { + const policyIDToCheck = policyIDsToCheck.length > 0 ? policyIDsToCheck[0] : policyID; + const reason = getGuardReason(policyIDToCheck); + if (!reason) { + return false; + } + + showGuardModal(reason, policyIDToCheck); return true; }, - [policyID, isManualDistanceRequest, isOdometerDistanceRequest, policiesWithCommuterExclusions, showConfirmModal, translate, styles, illustrations.HouseWithMap], + [getGuardReason, policyID, showGuardModal], ); + + return blockDistanceRequestIfNeeded; } export default useCommuterExclusionGuard; diff --git a/src/hooks/useDefaultExpensePolicy.tsx b/src/hooks/useDefaultExpensePolicy.tsx index f9a3b0a68ec7..2cf0ea1d88d5 100644 --- a/src/hooks/useDefaultExpensePolicy.tsx +++ b/src/hooks/useDefaultExpensePolicy.tsx @@ -1,3 +1,4 @@ +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import {isGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -20,7 +21,7 @@ function getSingleGroupPolicyID(policies: OnyxCollection, login: string) let singlePolicyID: string | undefined; for (const policy of Object.values(policies)) { - if (!policy || !isGroupPolicy(policy) || !isPolicyAccessible(policy, login)) { + if (!policy || !isGroupPolicy(policy) || !isPolicyAccessible(policy, login) || isTeachersUnitePolicyID(policy.id)) { continue; } if (!singlePolicyID) { @@ -48,11 +49,12 @@ export default function useDefaultExpensePolicy() { // Per-key lookup for the single group policy (only fires when that specific policy changes) const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`); - if (isRestrictedToPreferredPolicy && isGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login)) { + // Teachers Unite only supports expenses via split expense , so it can never be the default destination for a new expense. + if (isRestrictedToPreferredPolicy && isGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login) && !isTeachersUnitePolicyID(preferredPolicy?.id)) { return preferredPolicy; } - if (isGroupPolicy(activePolicy) && isPolicyAccessible(activePolicy, login)) { + if (isGroupPolicy(activePolicy) && isPolicyAccessible(activePolicy, login) && !isTeachersUnitePolicyID(activePolicy?.id)) { return activePolicy; } diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 3157e7bc17d4..7fd1b0044761 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -56,6 +56,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import {useRef} from 'react'; +import useCommuterExclusionGuard from './useCommuterExclusionGuard'; import useConfirmModal from './useConfirmModal'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; @@ -290,6 +291,12 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic } }; + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); const addExpenseDropdownOptions = getAddExpenseDropdownOptions({ translate, icons: useMemoizedLazyExpensifyIcons(['Plus', 'ReceiptPlus', 'Location', 'Feed', 'ArrowRight']), @@ -301,6 +308,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic ownerBillingGracePeriodEnd, lastDistanceExpenseType, currentUserAccountID: accountID, + blockDistanceRequestIfNeeded, }); const expensifyIcons = useMemoizedLazyExpensifyIcons([ diff --git a/src/hooks/useLoadDepositAccountSetup.ts b/src/hooks/useLoadDepositAccountSetup.ts new file mode 100644 index 000000000000..4ba821654b95 --- /dev/null +++ b/src/hooks/useLoadDepositAccountSetup.ts @@ -0,0 +1,55 @@ +import {openDepositAccountSetup} from '@userActions/BankAccounts'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import {useRoute} from '@react-navigation/native'; +import {useEffect, useEffectEvent, useState} from 'react'; + +import useOnyx from './useOnyx'; + +/** + * Fetches the reimbursement countries for the user's policies (OpenDepositAccountSetup) and reports whether that + * fetch is in flight. + * + * useShouldCollectInternationalDepositDetails decides whether to collect international deposit details from this data, + * and that decision determines which steps the flow shows, so callers must gate rendering on the returned flag. + * + * Each page of the flow is a separate navigation (a fresh mount of the screen, not an in-place update), so the request + * can't simply be tied to the mount or it would fire on every next/back. Instead it fires when either: + * - this is the flow-entry mount (no subPage in the URL yet), so every new visit to the flow gets fresh countries, or + * - the loading flag is still undefined, which covers landing mid-flow via a deeplink or a reload. + * + * The flag is RAM-only, so a reload starts from undefined and fetches again. It is set to `true` optimistically while + * the request is in flight and `false` once it settles. + */ +function useLoadDepositAccountSetup(): boolean { + const route = useRoute(); + // Frozen at the first render: useSubPage redirects the entry mount by setting the subPage param, which would + // otherwise flip this to false part-way through the same mount. + const [isFlowEntryMount] = useState(() => !(route.params as {subPage?: string} | undefined)?.subPage); + const [isLoadingDepositAccountSetup, metadata] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP); + const isReadingFlagFromOnyx = isLoadingOnyxValue(metadata); + + // Reading the flag through an effect event keeps it out of the effect's dependencies, so the request the flag + // itself triggers can't feed back and re-run the effect. + const loadDepositAccountSetup = useEffectEvent(() => { + if (!isFlowEntryMount && isLoadingDepositAccountSetup !== undefined) { + return; + } + openDepositAccountSetup(); + }); + + useEffect(() => { + // Until Onyx has read the key, an undefined flag only means "not read yet" - deciding now would fire a + // duplicate request on every mid-flow mount. + if (isReadingFlagFromOnyx) { + return; + } + loadDepositAccountSetup(); + }, [isReadingFlagFromOnyx]); + + return isLoadingDepositAccountSetup ?? true; +} + +export default useLoadDepositAccountSetup; diff --git a/src/hooks/useOutstandingReports.ts b/src/hooks/useOutstandingReports.ts index 0b5fe3af1f6d..67e9011aee53 100644 --- a/src/hooks/useOutstandingReports.ts +++ b/src/hooks/useOutstandingReports.ts @@ -1,3 +1,4 @@ +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import {getOutstandingReportsForUser, isSelfDM} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -28,7 +29,8 @@ export default function useOutstandingReports(selectedReportID: string | undefin if (shouldUseAllPolicies) { const result = []; for (const policyID of Object.values(allPoliciesID ?? {})) { - if (!policyID || policyID === personalPolicyID) { + // Teachers Unite only supports expenses via split expense, so its reports can never be a move-expense destination. + if (!policyID || policyID === personalPolicyID || isTeachersUnitePolicyID(policyID)) { continue; } @@ -38,5 +40,9 @@ export default function useOutstandingReports(selectedReportID: string | undefin return result; } + if (isTeachersUnitePolicyID(selectedPolicyID)) { + return []; + } + return getOutstandingReportsForUser(selectedPolicyID, ownerAccountID, reportNameValuePairs, outstandingReportsByPolicyID?.[selectedPolicyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, isEditing); } diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index 73a317f8b39e..c127dbf4f998 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -105,7 +105,8 @@ function useParticipantSubmission({ // explicit useMemo is needed here. const transactionIDs = draftTransactions?.map((transaction) => transaction.transactionID); const [transactions] = useTransactionsByID(transactionIDs); - const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({ + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + isDistanceRequest: isDistanceRequest(initialTransaction), isManualDistanceRequest: isManualDistanceRequest(initialTransaction), isOdometerDistanceRequest: isOdometerDistanceRequest(initialTransaction), }); @@ -238,7 +239,7 @@ function useParticipantSubmission({ // Block selecting a workspace with commuter exclusions before participants/workspace are committed. const selectedPolicyID = firstParticipant?.policyID ?? (firstParticipant?.reportID ? getReportOrDraftReport(firstParticipant.reportID)?.policyID : undefined); - if (blockManualOrOdometerDistanceRequestIfNeeded(selectedPolicyID)) { + if (blockDistanceRequestIfNeeded(selectedPolicyID)) { return; } diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 801bece7e8d6..be09beaa39f9 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -1,6 +1,7 @@ import {useActivePolicyContext} from '@components/ActivePolicyProvider'; import {useSession} from '@components/OnyxListItemProvider'; +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import {canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -30,6 +31,8 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string isPolicyMemberByRole(policy) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + // Teachers Unite doesn't support reimbursement, so it can never be a destination for moving/reporting an expense. + !isTeachersUnitePolicyID(policy?.id) && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(policy)) && (!isTimeRequest || isTimeTrackingEnabled(policy)) ); @@ -123,7 +126,12 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; } - if (activePolicy && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && (!isTimeRequest || isTimeTrackingEnabled(activePolicy))) { + if ( + activePolicy && + !isTeachersUnitePolicyID(activePolicy.id) && + (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && + (!isTimeRequest || isTimeTrackingEnabled(activePolicy)) + ) { return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; } diff --git a/src/hooks/useReportSubmitToPopover.tsx b/src/hooks/useReportSubmitToPopover.tsx index 181c1f363670..a842f2cffed6 100644 --- a/src/hooks/useReportSubmitToPopover.tsx +++ b/src/hooks/useReportSubmitToPopover.tsx @@ -32,7 +32,7 @@ import useWindowDimensions from './useWindowDimensions'; const popoverDimensions = { width: CONST.POPOVER_DROPDOWN_WIDTH, height: CONST.POPOVER_DROPDOWN_MAX_HEIGHT, - minHeight: CONST.POPOVER_REPORT_SUBMIT_TO_CONTENT_HEIGHT, + portraitHeight: CONST.POPOVER_REPORT_SUBMIT_TO_CONTENT_HEIGHT, }; const DEFAULT_ANCHOR_ALIGNMENT = { @@ -68,7 +68,9 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = const submitToPopoverContentHeight = useMemo(() => { if (!isInLandscapeMode) { - return popoverDimensions.height; + // Portrait uses the dedicated fixed height for this popover (`POPOVER_REPORT_SUBMIT_TO_CONTENT_HEIGHT`), + // so the intent stays explicit rather than silently coupling to the generic dropdown max. + return popoverDimensions.portraitHeight; } const contentHeightLandscapeMode = calculateModalHeightInLandscapeMode(windowHeight, topSafeAreaInset, keyboardActiveHeight); @@ -230,10 +232,13 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = [StyleUtils, isSmallScreenWidth, popoverContainerStyle, viewportOffsetTop, windowHeight], ); + // No height in portrait: the wrapper `View` below owns it. This container is the modal's own box, so a height set + // here does not bound the content (the wrapper's `flex1` had nothing to resolve against, which let the content + // overflow the fixed box and pushed the Confirm button out of view). const innerContainerStyle = useMemo( () => ({ ...popoverContainerStyle, - ...(isInLandscapeMode ? styles.getPopoverMaxHeight(windowHeight, true) : {minHeight: popoverDimensions.minHeight}), + ...(isInLandscapeMode ? styles.getPopoverMaxHeight(windowHeight, true) : {}), }), [popoverContainerStyle, isInLandscapeMode, windowHeight, styles], ); @@ -260,14 +265,20 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = shouldSkipRemeasurement shouldDisplayBelowModals shouldUseModalPaddingStyle + // `FixedFooter` inside the list already applies the bottom safe area on top of its own 20px, so let it + // own that spacing entirely. Without this the narrow bottom-docked modal adds its own 16px plus a + // second safe area inset (both helpers sum rather than max), leaving far too much room under Confirm. + enableEdgeToEdgeBottomSafeAreaPadding avoidKeyboard shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={false} > ) => + Object.values(policies ?? {}).some((policy) => { + if (!policy?.reimbursement?.enabled) { + return false; + } + const countries = Object.keys(policy.reimbursement.countries ?? {}); + return countries.length > 0 && !countries.includes(bankCountry); + }); + const [shouldCollectInternationalDepositDetails] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); + return !!shouldCollectInternationalDepositDetails; +} + +export default useShouldCollectInternationalDepositDetails; diff --git a/src/hooks/useSingleExecution/index.native.ts b/src/hooks/useSingleExecution/index.native.ts index 2cd703367408..55d1f63092a9 100644 --- a/src/hooks/useSingleExecution/index.native.ts +++ b/src/hooks/useSingleExecution/index.native.ts @@ -12,12 +12,17 @@ export default function useSingleExecution() { const [isExecuting, setIsExecuting] = useState(false); const isExecutingRef = useRef(undefined); const transitionHandleRef = useRef(null); + const executionGenerationRef = useRef(0); isExecutingRef.current = isExecuting; useEffect( () => () => { transitionHandleRef.current?.cancel(); + transitionHandleRef.current = null; + executionGenerationRef.current += 1; + isExecutingRef.current = false; + setIsExecuting(false); }, [], ); @@ -32,16 +37,27 @@ export default function useSingleExecution() { setIsExecuting(true); isExecutingRef.current = true; + const executionGeneration = executionGenerationRef.current; const execution = action(...params); // Re-enables the button once the predicted (or actual) transition triggered by this press // ends - or immediately, if the press wasn't predicted to cause one. transitionHandleRef.current = runAfterPredictedTransition(() => { - if (!(execution instanceof Promise)) { + const releaseIfCurrent = () => { + if (executionGenerationRef.current !== executionGeneration) { + return; + } + + isExecutingRef.current = false; setIsExecuting(false); + }; + + if (!(execution instanceof Promise)) { + releaseIfCurrent(); return; } + execution.finally(() => { - setIsExecuting(false); + releaseIfCurrent(); }); }); }, diff --git a/src/languages/de.ts b/src/languages/de.ts index 516fbfb24471..88237b4c0d5f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -979,6 +979,7 @@ const translations: TranslationDeepObject = { subtitle: 'Wallet', }, validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, + addHomeAddress: {title: 'Fügen Sie Ihre Privatadresse zur Entfernungserfassung hinzu', subtitle: 'Konto', cta: 'Adresse hinzufügen'}, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Ihr Geschäftskonto wurde gesperrt', @@ -1292,6 +1293,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Zeitaufwand erstellen', }, iou: { + homeAddressRequired: { + title: 'Privatadresse ist erforderlich', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` + : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. Dieser Arbeitsbereich verwendet diese Adresse für Pendlerabzüge.', + cta: 'Heimatadresse hinzufügen', + }, amount: 'Betrag', percent: 'Prozent', date: 'Datum', @@ -3631,7 +3640,8 @@ ${amount} für ${merchant} – ${date}`, legalName: 'Rechtlicher Name', legalFirstName: 'Rechtlicher Vorname', legalLastName: 'Rechtlicher Nachname', - address: 'Adresse', + address: 'Privatadresse', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} verwendet diese Adresse für Pendlerausnahmen.`, error: { dateShouldBeBefore: (dateString: string) => `Datum muss vor dem ${dateString} liegen`, dateShouldBeAfter: (dateString: string) => `Datum muss nach ${dateString} liegen`, @@ -3755,6 +3765,11 @@ ${amount} für ${merchant} – ${date}`, thisBankAccount: 'Dieses Bankkonto wird für Geschäftszahlungen in deinem Workspace verwendet', accountNumber: 'Kontonummer', routingNumber: 'Routing-Nummer', + internationalBankAccountDetails: 'Internationale Bankkontodaten', + internationalBankAccountDetailsTitle: 'Wie lauten deine internationalen Kontodaten?', + internationalBankAccountDetailsSubtitle: 'Einer deiner Arbeitsbereiche benötigt internationale Kontodaten, um Erstattungen zu verarbeiten', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BIC-Code', chooseAnAccountBelow: 'Wähle unten ein Konto aus', addBankAccount: 'Bankkonto hinzufügen', chooseAnAccount: 'Konto auswählen', @@ -3805,6 +3820,8 @@ ${amount} für ${merchant} – ${date}`, restrictedBusiness: 'Bitte bestätigen Sie, dass das Unternehmen nicht auf der Liste der eingeschränkten Unternehmen steht.', routingNumber: 'Bitte geben Sie eine gültige Bankleitzahl ein', accountNumber: 'Bitte eine gültige Kontonummer eingeben', + iban: 'Bitte gib eine gültige IBAN ein', + swiftCode: 'Bitte gib einen gültigen SWIFT/BIC-Code ein', routingAndAccountNumberCannotBeSame: 'Bankleitzahl und Kontonummer dürfen nicht identisch sein', companyType: 'Bitte wähle einen gültigen Unternehmenstyp aus', tooManyAttempts: @@ -5921,10 +5938,16 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU addNewCard: { other: 'Sonstiges', fileImport: 'Transaktionen aus Datei importieren', - createFileFeedHelpText: `Bitte folge dieser Hilfsanleitung, um die Ausgaben deiner Firmenkarte zu importieren!`, + createFileFeedHelpText: { + instructionStart: 'Auf der nächsten Seite lädst du eine CSV-Datei mit deinen Kartentransaktionen hoch. ', + templateLink: 'Vorlage herunterladen', + instructionMiddle: ' oder sieh dir unsere ', + helpGuideLink: 'Hilfsanleitung', + instructionEnd: ' an, bevor du sie hochlädst.', + }, companyCardLayoutName: 'Name des Firmenkarten-Layouts', cardLayoutNameRequired: 'Der Name des Firmenkarten-Layouts ist erforderlich', - useAdvancedFields: 'Erweiterte Felder verwenden (nicht empfohlen)', + downloadTemplate: 'Vorlage herunterladen', cardProviders: { gl1025: 'American Express Corporate Cards', cdf: 'Mastercard Firmenkarten', @@ -6014,9 +6037,9 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU currency: 'Währung', ignore: 'Ignorieren', originalTransactionDate: 'Ursprüngliches Transaktionsdatum', - originalAmount: 'Ursprünglicher Betrag', - originalCurrency: 'Ursprüngliche Währung', - comment: 'Kommentar', + originalAmount: 'Kaufbetrag', + originalCurrency: 'Kaufwährung', + comment: 'Beschreibung', category: 'Kategorie', tag: 'Tag', }, @@ -6106,6 +6129,9 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU currentBalanceDescription: 'Der aktuelle Saldo ist die Summe aller verbuchten Expensify Karte-Transaktionen, die seit dem letzten Abrechnungsdatum erfolgt sind.', balanceWillBeSettledOn: (settlementDate: string) => `Der Saldo wird am ${settlementDate} ausgeglichen.`, settleBalance: 'Saldo ausgleichen', + settleBalanceConfirmationTitle: 'Saldo ausgleichen?', + settleBalanceConfirmationPrompt: + 'Dadurch wird dein aktueller Saldo am nächsten Werktag ausgeglichen. Nach erfolgreicher Abwicklung wird der Betrag deinem verbleibenden Limit wieder gutgeschrieben.', cardLimit: 'Kartenlimit', remaining: 'Verbleibend', remainingLimit: 'Verbleibendes Limit', @@ -7259,11 +7285,25 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, summaryDisabled: 'Kein Ausschluss von Pendelstrecken', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `${distance} ${unit} pro Abrechnung ausschließen`, optionDisabledTitle: 'Pendelfahrten nicht ausschließen', - optionDisabledHelp: 'Es wird kein Ausschluss für den Arbeitsweg angewendet.', + optionDisabledHelp: 'Keine Pendelfahrt wird von Anträgen abgezogen.', optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', - errors: {distanceMustBePositive: 'Die Entfernung muss eine positive ganze Zahl sein.', distanceTooLarge: 'Die Entfernung ist zu groß.'}, + summaryHomeAndOffice: 'Standort für Zuhause und Büro verwenden', + optionHomeAndOfficeTitle: 'Nach Wohnsitz und Büro berechnen', + optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuweisung der betreffenden Person, um Pendlerausschlüsse zu berechnen.', + workspaceAddressRequired: { + title: 'Nicht so schnell …', + promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ erst aktivieren, nachdem Sie zunächst einen Bürostandort hinzugefügt haben in', + linkText: 'Übersicht', + promptEnd: ' hinzugefügt haben.', + cta: 'Verstanden', + }, + errors: { + distanceMustBePositive: 'Die Entfernung muss eine positive ganze Zahl sein.', + invalidAddress: 'Bitte geben Sie eine gültige Adresse ein', + distanceTooLarge: 'Die Entfernung ist zu groß.', + }, }, distance: 'Entfernung', centrallyManage: 'Tarife zentral verwalten, in Meilen oder Kilometern nachverfolgen und eine Standardkategorie festlegen.', @@ -7361,6 +7401,10 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, yourWorkspace: `Dein Arbeitsbereich ist auf eine nicht unterstützte Währung eingestellt. Sieh dir die Liste der unterstützten Währungen an.`, chooseAnExisting: 'Wähle ein bestehendes Bankkonto zum Bezahlen von Ausgaben oder füge ein neues hinzu.', changeBankAccount: 'Bankkonto ändern', + updateCurrencyForExpensifyCard: 'Die Expensify Karte kann in USD ausgegeben werden. Bitte aktualisieren Sie diesen Workspace auf USD oder verwenden Sie einen anderen Workspace.', + updateCurrencyForExpensifyCardTitle: 'Expensify Karte bestellen', + euUkUpdateCurrencyForExpensifyCard: + 'Die Expensify Karte kann in USD, GBP und EUR ausgestellt werden. Bitte aktualisieren Sie diesen Workspace auf eine unterstützte Währung oder verwenden Sie einen anderen Workspace.', }, changeOwner: { changeOwnerPageTitle: 'Besitz übertragen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 1b951f9486e6..fa914d069e6e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -318,7 +318,7 @@ const translations: TranslationDeepObject = { recent: 'Πρόσφατα', all: 'Όλα', am: 'π.μ.', - pm: 'μμ.', + pm: 'μ.μ.', tbd: 'Θα καθοριστεί', selectCurrency: 'Επιλέξτε νόμισμα', selectSymbolOrCurrency: 'Επιλέξτε ένα σύμβολο ή νόμισμα', @@ -1020,6 +1020,7 @@ const translations: TranslationDeepObject = { subtitle: 'Λογαριασμός', cta: 'Επικυρώστε', }, + addHomeAddress: {title: 'Προσθέστε τη διεύθυνση κατοικίας σας για παρακολούθηση αποστάσεων', subtitle: 'Λογαριασμός', cta: 'Προσθήκη διεύθυνσης'}, fixFailedBilling: { title: 'Δεν μπορέσαμε να χρεώσουμε την αποθηκευμένη κάρτα σας', subtitle: 'Συνδρομή', @@ -1340,6 +1341,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Δημιουργία χρονοχρέωσης', }, iou: { + homeAddressRequired: { + title: 'Απαιτείται η διεύθυνση κατοικίας σας', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Το ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης.` + : 'Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Αυτός ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης.', + cta: 'Προσθήκη οικιακής διεύθυνσης', + }, amount: 'Ποσό', percent: 'Ποσοστό', date: 'Ημερομηνία', @@ -3678,7 +3687,8 @@ ${amount} για ${merchant} - ${date}`, legalName: 'Νομικό όνομα', legalFirstName: 'Επίσημο μικρό όνομα', legalLastName: 'Επώνυμο (όπως αναγράφεται στα επίσημα έγγραφα)', - address: 'Διεύθυνση', + address: 'Οικιακή διεύθυνση', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `Το ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για εξαιρέσεις μετακίνησης.`, error: { dateShouldBeBefore: (dateString: string) => `Η ημερομηνία πρέπει να είναι πριν από ${dateString}`, dateShouldBeAfter: (dateString: string) => `Η ημερομηνία πρέπει να είναι μετά από ${dateString}`, @@ -3801,6 +3811,11 @@ ${amount} για ${merchant} - ${date}`, return result; }, bankAccount: { + internationalBankAccountDetails: 'Στοιχεία διεθνούς τραπεζικού λογαριασμού', + internationalBankAccountDetailsTitle: 'Ποια είναι τα στοιχεία του διεθνούς λογαριασμού σας;', + internationalBankAccountDetailsSubtitle: 'Ένας από τους χώρους εργασίας σας χρειάζεται στοιχεία διεθνούς λογαριασμού για την επεξεργασία των αποζημιώσεων', + iban: 'IBAN', + swiftBicCode: 'Κωδικός SWIFT/BIC', bankInfo: 'Στοιχεία τράπεζας', confirmBankInfo: 'Επιβεβαίωση στοιχείων τράπεζας', manuallyAdd: 'Προσθέστε χειροκίνητα τον τραπεζικό σας λογαριασμό', @@ -3860,6 +3875,8 @@ ${amount} για ${merchant} - ${date}`, restrictedBusiness: 'Παρακαλούμε επιβεβαιώστε ότι η επιχείρηση δεν βρίσκεται στη λίστα με τις περιορισμένες επιχειρήσεις', routingNumber: 'Παρακαλούμε εισαγάγετε έναν έγκυρο αριθμό δρομολόγησης', accountNumber: 'Παρακαλούμε εισαγάγετε έναν έγκυρο αριθμό λογαριασμού', + iban: 'Παρακαλούμε εισαγάγετε έναν έγκυρο IBAN', + swiftCode: 'Παρακαλούμε εισαγάγετε έναν έγκυρο κωδικό SWIFT/BIC', routingAndAccountNumberCannotBeSame: 'Οι αριθμοί δρομολόγησης και λογαριασμού δεν μπορούν να είναι ίδιοι', companyType: 'Παρακαλώ επιλέξτε έναν έγκυρο τύπο εταιρείας', tooManyAttempts: 'Λόγω μεγάλου αριθμού προσπαθειών σύνδεσης, αυτή η επιλογή έχει απενεργοποιηθεί για 24 ώρες. Δοκιμάστε ξανά αργότερα ή εισαγάγετε τα στοιχεία χειροκίνητα.', @@ -6030,10 +6047,16 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ other: 'Άλλο', fileImport: 'Εισαγωγή συναλλαγών από αρχείο', fileImportDescription: 'Μια χειροκίνητη επιλογή αν η τράπεζά σας δεν μπορεί να στείλει ροή δεδομένων.', - createFileFeedHelpText: `Ακολουθήστε αυτόν τον οδηγό βοήθειας για να εισαχθούν οι δαπάνες των εταιρικών καρτών σας!`, + createFileFeedHelpText: { + instructionStart: 'Στην επόμενη σελίδα, θα ανεβάσετε ένα CSV με τις συναλλαγές της κάρτας σας. ', + templateLink: 'Κατεβάστε το πρότυπό μας', + instructionMiddle: ' ή δείτε τον ', + helpGuideLink: 'οδηγό βοήθειας', + instructionEnd: ' πριν το ανεβάσετε.', + }, companyCardLayoutName: 'Όνομα διάταξης εταιρικής κάρτας', cardLayoutNameRequired: 'Απαιτείται όνομα διάταξης εταιρικής κάρτας', - useAdvancedFields: 'Χρησιμοποιήστε προηγμένα πεδία (δεν συνιστάται)', + downloadTemplate: 'Κατεβάστε το πρότυπό μας', cardProviders: { gl1025: 'Εταιρικές κάρτες American Express', cdf: 'Επαγγελματικές κάρτες Mastercard', @@ -6125,9 +6148,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ currency: 'Νόμισμα', ignore: 'Αγνοήστε', originalTransactionDate: 'Αρχική ημερομηνία συναλλαγής', - originalAmount: 'Αρχικό ποσό', - originalCurrency: 'Αρχικό νόμισμα', - comment: 'Σχόλιο', + originalAmount: 'Ποσό αγοράς', + originalCurrency: 'Νόμισμα αγοράς', + comment: 'Περιγραφή', category: 'Κατηγορία', tag: 'Ετικέτα', cardName: 'Όνομα κάρτας', @@ -6220,6 +6243,8 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ 'Το τρέχον υπόλοιπο είναι το άθροισμα όλων των καταχωρημένων συναλλαγών της Κάρτας Expensify που έχουν πραγματοποιηθεί από την ημερομηνία της τελευταίας εκκαθάρισης.', balanceWillBeSettledOn: (settlementDate: string) => `Το υπόλοιπο θα διακανονιστεί στις ${settlementDate}`, settleBalance: 'Εξόφληση υπολοίπου', + settleBalanceConfirmationTitle: 'Εξόφληση υπολοίπου;', + settleBalanceConfirmationPrompt: 'Αυτό θα εξοφλήσει το τρέχον υπόλοιπό σας την επόμενη εργάσιμη ημέρα. Μόλις ολοκληρωθεί, το ποσό θα προστεθεί ξανά στο υπόλοιπο όριό σας.', cardLimit: 'Όριο κάρτας', remainingLimit: 'Υπόλοιπο ορίου', requestLimitIncrease: 'Αίτημα αύξησης ορίου', @@ -7501,13 +7526,24 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ title: 'Εξαίρεση μετακινήσεων από και προς την εργασία', summaryDisabled: 'Χωρίς εξαίρεση μετακίνησης', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Εξαίρεση ${distance} ${unit} ανά αίτημα`, + summaryHomeAndOffice: 'Χρησιμοποιήστε τις τοποθεσίες σπιτιού και γραφείου', optionDisabledTitle: 'Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία', - optionDisabledHelp: 'Δεν εφαρμόζεται καμία εξαίρεση μετακίνησης.', + optionDisabledHelp: 'Καμία μετακίνηση από και προς την εργασία δεν αφαιρείται από τις αιτήσεις.', optionFixedDistanceTitle: 'Εξαίρεση σταθερής απόστασης ανά αίτημα', optionFixedDistanceHelp: 'Αφαιρέστε την ίδια απόσταση μετακίνησης από κάθε αίτημα. Ιδανικό για μέλη που υποβάλλουν ένα αίτημα ανά εργάσιμη ημέρα.', + optionHomeAndOfficeTitle: 'Υπολογισμός ανά σπίτι και γραφείο', + optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας, τη ρύθμιση εργασίας και την ανάθεση γραφείου του μέλους για να υπολογίσετε τις εξαιρέσεις μετακίνησης.', distanceLabel: 'Απόσταση', + workspaceAddressRequired: { + title: 'Όχι τόσο γρήγορα...', + promptStart: 'Δεν μπορείτε να ενεργοποιήσετε τη ρύθμιση υπολογισμού ανά σπίτι και γραφείο μέχρι να προσθέσετε πρώτα μια τοποθεσία γραφείου στο', + linkText: 'Επισκόπηση', + promptEnd: '.', + cta: 'Εντάξει', + }, errors: { distanceMustBePositive: 'Η απόσταση πρέπει να είναι ένας θετικός ακέραιος αριθμός.', + invalidAddress: 'Παρακαλούμε εισαγάγετε έγκυρη διεύθυνση', distanceTooLarge: 'Η απόσταση είναι πολύ μεγάλη.', }, }, @@ -7613,6 +7649,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ notAllowedToAddBankAccount: 'Ο χώρος εργασίας σας είναι ρυθμισμένος σε μη υποστηριζόμενο νόμισμα. Επικοινωνήστε με έναν διαχειριστή χώρου εργασίας που έχει δικαίωμα να το αλλάξει.', changeBankAccount: 'Αλλαγή τραπεζικού λογαριασμού', + updateCurrencyForExpensifyCard: 'Η Κάρτα Expensify είναι διαθέσιμη για έκδοση σε USD. Ενημερώστε αυτόν τον χώρο εργασίας σε USD ή χρησιμοποιήστε έναν διαφορετικό χώρο εργασίας.', + updateCurrencyForExpensifyCardTitle: 'Αποκτήστε την Κάρτα Expensify', + euUkUpdateCurrencyForExpensifyCard: + 'Η Κάρτα Expensify είναι διαθέσιμη για έκδοση σε USD, GBP και EUR. Ενημερώστε αυτόν τον χώρο εργασίας σε ένα υποστηριζόμενο νόμισμα ή χρησιμοποιήστε έναν διαφορετικό χώρο εργασίας.', }, changeOwner: { changeOwnerPageTitle: 'Μεταφορά ιδιοκτήτη', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2c31da46b836..c38b485abeb6 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1060,6 +1060,11 @@ const translations = { subtitle: 'Account', cta: 'Validate', }, + addHomeAddress: { + title: 'Add your home address for distance tracking', + subtitle: 'Account', + cta: 'Add address', + }, fixFailedBilling: { title: "We couldn't bill your card on file", subtitle: 'Subscription', @@ -1372,6 +1377,14 @@ const translations = { createTimeExpense: 'Create time expense', }, iou: { + homeAddressRequired: { + title: 'Home address is required', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Before you track distance, you need to add your home address to your private profile. ${workspaceName} uses this address for commuter deductions.` + : 'Before you track distance, you need to add your home address to your private profile. This workspace uses this address for commuter deductions.', + cta: 'Add Home Address', + }, amount: 'Amount', percent: 'Percent', date: 'Date', @@ -3730,7 +3743,8 @@ const translations = { legalName: 'Legal name', legalFirstName: 'Legal first name', legalLastName: 'Legal last name', - address: 'Address', + address: 'Home address', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} uses this address for commuter exclusions.`, error: { dateShouldBeBefore: (dateString: string) => `Date should be before ${dateString}`, dateShouldBeAfter: (dateString: string) => `Date should be after ${dateString}`, @@ -3858,6 +3872,11 @@ const translations = { thisBankAccount: 'This bank account will be used for business payments on your workspace', accountNumber: 'Account number', routingNumber: 'Routing number', + internationalBankAccountDetails: 'International bank account details', + internationalBankAccountDetailsTitle: 'What are your international account details?', + internationalBankAccountDetailsSubtitle: 'One of your workspaces needs international account details to process reimbursements', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BIC code', chooseAnAccountBelow: 'Choose an account below', addBankAccount: 'Add bank account', chooseAnAccount: 'Choose an account', @@ -3907,6 +3926,8 @@ const translations = { restrictedBusiness: "Please confirm the business isn't on the list of restricted businesses", routingNumber: 'Please enter a valid routing number', accountNumber: 'Please enter a valid account number', + iban: 'Please enter a valid IBAN', + swiftCode: 'Please enter a valid SWIFT/BIC code', routingAndAccountNumberCannotBeSame: "Routing and account numbers can't match", companyType: 'Please select a valid company type', tooManyAttempts: 'Due to a high number of login attempts, this option has been disabled for 24 hours. Please try again later or enter details manually instead.', @@ -6017,10 +6038,16 @@ const translations = { other: 'Other', fileImport: 'Import transactions from file', fileImportDescription: "A manual option if your bank can't send a feed.", - createFileFeedHelpText: `Please follow this help guide to get your company card expenses imported!`, + createFileFeedHelpText: { + instructionStart: "On the next page, you'll upload a CSV of your card transactions. ", + templateLink: 'Download our template', + instructionMiddle: ' or check out our ', + helpGuideLink: 'help guide', + instructionEnd: ' before you upload.', + }, companyCardLayoutName: 'Company card layout name', cardLayoutNameRequired: 'The Company card layout name is required', - useAdvancedFields: 'Use advanced fields (not recommended)', + downloadTemplate: 'Download our template', cardProviders: { gl1025: 'American Express Corporate Cards', cdf: 'Mastercard Commercial Cards', @@ -6095,9 +6122,9 @@ const translations = { currency: 'Currency', ignore: 'Ignore', originalTransactionDate: 'Original transaction date', - originalAmount: 'Original amount', - originalCurrency: 'Original currency', - comment: 'Comment', + originalAmount: 'Purchase amount', + originalCurrency: 'Purchase currency', + comment: 'Description', category: 'Category', tag: 'Tag', }, @@ -6189,6 +6216,8 @@ const translations = { currentBalanceDescription: 'Current balance is the sum of all posted Expensify Card transactions that have occurred since the last settlement date.', balanceWillBeSettledOn: (settlementDate: string) => `Balance will be settled on ${settlementDate}`, settleBalance: 'Settle balance', + settleBalanceConfirmationTitle: 'Settle balance?', + settleBalanceConfirmationPrompt: 'This will settle your current balance in the next business day. Once successful, the amount will be added back to your remaining limit.', cardLimit: 'Card limit', remaining: 'Remaining', remainingLimit: 'Remaining limit', @@ -7448,13 +7477,24 @@ const translations = { title: 'Exclude commutes', summaryDisabled: 'No commute exclusion', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclude ${distance} ${unit} per claim`, + summaryHomeAndOffice: 'Use home and office locations', optionDisabledTitle: 'Do not exclude commutes', - optionDisabledHelp: 'No commute exclusion is applied.', + optionDisabledHelp: 'No commute is removed from claims.', optionFixedDistanceTitle: 'Exclude a fixed distance per claim', optionFixedDistanceHelp: 'Remove the same commute distance from each claim. Best for members who submit one claim per workday.', + optionHomeAndOfficeTitle: 'Calculate by home and office', + optionHomeAndOfficeHelp: 'Use member’s home address, work arrangement, and office assignment to calculate commute exclusions.', distanceLabel: 'Distance', + workspaceAddressRequired: { + title: 'Not so fast...', + promptStart: 'You can’t enable the calculate by home and office setting until you first add an office location in ', + linkText: 'Overview', + promptEnd: '.', + cta: 'Got it', + }, errors: { distanceMustBePositive: 'Distance must be a positive whole number.', + invalidAddress: 'Please enter a valid address', distanceTooLarge: 'Distance is too large.', }, }, @@ -7551,6 +7591,10 @@ const translations = { areYouSure: 'Are you sure?', workspaceCurrency: 'Workspace currency', updateCurrencyPrompt: 'It looks like your workspace is currently set to a different currency than USD. Please click the button below to update your currency to USD now.', + updateCurrencyForExpensifyCard: 'The Expensify Card is available to issue in USD. Please update this workspace to USD or use a different workspace.', + euUkUpdateCurrencyForExpensifyCard: + 'The Expensify Card is available to issue in USD, GBP, and EUR. Please update this workspace to a supported currency or use a different workspace.', + updateCurrencyForExpensifyCardTitle: 'Get the Expensify Card', updateToUSD: 'Update to USD', updateWorkspaceCurrency: 'Update workspace currency', workspaceCurrencyNotSupported: 'Workspace currency not supported', diff --git a/src/languages/es.ts b/src/languages/es.ts index b5c0c4c16b3f..6a6652872c24 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9,7 +9,6 @@ * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ - import CONST from '@src/CONST'; import type {OriginalMessageReportPreview, OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; @@ -975,6 +974,7 @@ const translations: TranslationDeepObject = { subtitle: 'Cuenta', cta: 'Validar', }, + addHomeAddress: {title: 'Añade tu dirección de casa para el seguimiento de distancia', subtitle: 'Cuenta', cta: 'Añadir dirección'}, fixFailedBilling: { title: 'No pudimos cobrar a la tarjeta registrada.', subtitle: 'Suscripción', @@ -1289,6 +1289,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Crear gasto de tiempo', }, iou: { + homeAddressRequired: { + title: 'La dirección de domicilio es obligatoria', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Antes de organizar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} usa esta dirección para las deducciones por desplazamientos al trabajo.` + : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo utiliza esta dirección para las deducciones por desplazamientos al trabajo.', + cta: 'Añadir dirección de domicilio', + }, amount: 'Importe', percent: 'Porcentaje', date: 'Fecha', @@ -3543,7 +3551,8 @@ ${amount} para ${merchant} - ${date}`, legalName: 'Nombre completo', legalFirstName: 'Nombre legal', legalLastName: 'Apellidos legales', - address: 'Dirección', + address: 'Dirección particular', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa esta dirección para las exclusiones de desplazamientos al trabajo.`, error: { dateShouldBeBefore: (dateString) => `La fecha debe ser anterior a ${dateString}`, dateShouldBeAfter: (dateString) => `La fecha debe ser posterior a ${dateString}`, @@ -3678,6 +3687,11 @@ ${amount} para ${merchant} - ${date}`, thisBankAccount: 'Esta cuenta bancaria se utilizará para pagos comerciales en tu espacio de trabajo', accountNumber: 'Número de cuenta', routingNumber: 'Número de ruta', + internationalBankAccountDetails: 'Datos de la cuenta bancaria internacional', + internationalBankAccountDetailsTitle: '¿Cuáles son los datos de tu cuenta internacional?', + internationalBankAccountDetailsSubtitle: 'Uno de tus espacios de trabajo necesita datos de cuenta internacional para procesar reembolsos', + iban: 'IBAN', + swiftBicCode: 'Código SWIFT/BIC', chooseAnAccountBelow: 'Elige una cuenta a continuación', addBankAccount: 'Añadir cuenta bancaria', chooseAnAccount: 'Elige una cuenta', @@ -3727,6 +3741,8 @@ ${amount} para ${merchant} - ${date}`, restrictedBusiness: 'Por favor, confirma que la empresa no está en la lista de negocios restringidos', routingNumber: 'Por favor, introduce un número de ruta válido', accountNumber: 'Por favor, introduce un número de cuenta válido', + iban: 'Por favor, introduce un IBAN válido', + swiftCode: 'Por favor, introduce un código SWIFT/BIC válido', routingAndAccountNumberCannotBeSame: 'Los números de ruta y de cuenta no pueden ser iguales', companyType: 'Por favor, selecciona un tipo de compañía válido', tooManyAttempts: @@ -5814,10 +5830,16 @@ ${amount} para ${merchant} - ${date}`, other: 'Otros', fileImport: 'Importar transacciones desde un archivo', fileImportDescription: 'Una opción manual si tu banco no puede enviar un feed.', - createFileFeedHelpText: `Siga este guía de ayuda para importar las transacciones de su tarjeta de empresa!`, + createFileFeedHelpText: { + instructionStart: 'En la página siguiente, subirás un CSV con las transacciones de tu tarjeta. ', + templateLink: 'Descarga nuestra plantilla', + instructionMiddle: ' o consulta nuestra ', + helpGuideLink: 'guía de ayuda', + instructionEnd: ' antes de subirlo.', + }, companyCardLayoutName: 'Nombre del layout de la tarjeta de empresa', cardLayoutNameRequired: 'El nombre del layout de la tarjeta de empresa es requerido', - useAdvancedFields: 'Usar campos avanzados (no recomendado)', + downloadTemplate: 'Descarga nuestra plantilla', cardProviders: { gl1025: 'Tarjetas de empresa American Express', cdf: 'Tarjetas comerciales Mastercard', @@ -5889,9 +5911,9 @@ ${amount} para ${merchant} - ${date}`, currency: 'Moneda', ignore: 'Ignorar', originalTransactionDate: 'Fecha original de la transacción', - originalAmount: 'Importe original', - originalCurrency: 'Moneda original', - comment: 'Comentario', + originalAmount: 'Importe de la compra', + originalCurrency: 'Moneda de la compra', + comment: 'Descripción', category: 'Categoría', tag: 'Etiqueta', }, @@ -5987,6 +6009,8 @@ ${amount} para ${merchant} - ${date}`, 'El saldo actual es la suma de todas las transacciones contabilizadas con la Tarjeta Expensify que se han producido desde la última fecha de liquidación.', balanceWillBeSettledOn: (settlementDate) => `El saldo se liquidará el ${settlementDate}.`, settleBalance: 'Liquidar saldo', + settleBalanceConfirmationTitle: '¿Liquidar saldo?', + settleBalanceConfirmationPrompt: 'Esto liquidará tu saldo actual el próximo día hábil. Una vez completado, el importe se sumará de nuevo a tu límite restante.', cardLimit: 'Límite de la tarjeta', remaining: 'Restante', remainingLimit: 'Límite restante', @@ -7232,12 +7256,26 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, title: 'Excluir desplazamientos al trabajo', summaryDisabled: 'Sin exclusión por desplazamiento al trabajo', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, + summaryHomeAndOffice: 'Usa las ubicaciones de casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', - optionDisabledHelp: 'No se aplica ninguna exclusión de desplazamiento.', + optionDisabledHelp: 'No se ha eliminado ningún trayecto al trabajo de las reclamaciones.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', + optionHomeAndOfficeTitle: 'Calcular por casa y oficina', + optionHomeAndOfficeHelp: 'Utiliza la dirección de casa del miembro, su modalidad de trabajo y su asignación de oficina para calcular las exclusiones de desplazamiento.', distanceLabel: 'Distancia', - errors: {distanceMustBePositive: 'La distancia debe ser un número entero positivo.', distanceTooLarge: 'La distancia es demasiado grande.'}, + workspaceAddressRequired: { + title: 'No tan rápido...', + promptStart: 'No puedes habilitar la configuración de cálculo por casa y oficina hasta que primero añadas una ubicación de oficina en', + linkText: 'Resumen', + promptEnd: '.', + cta: 'Entendido', + }, + errors: { + distanceMustBePositive: 'La distancia debe ser un número entero positivo.', + invalidAddress: 'Por favor, introduce una dirección válida', + distanceTooLarge: 'La distancia es demasiado grande.', + }, }, distance: 'Distancia', centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto', @@ -7333,6 +7371,11 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, yourWorkspace: `Tu espacio de trabajo está configurado en una moneda no soportada. Consulta la lista de monedas soportadas.`, chooseAnExisting: 'Elige una cuenta bancaria existente para pagar gastos o añade una nueva.', changeBankAccount: 'Cambiar cuenta bancaria', + updateCurrencyForExpensifyCard: + 'La Tarjeta Expensify está disponible para emitirse en USD. Por favor, actualiza este espacio de trabajo a USD o usa un espacio de trabajo diferente.', + updateCurrencyForExpensifyCardTitle: 'Consigue la Tarjeta Expensify', + euUkUpdateCurrencyForExpensifyCard: + 'La Tarjeta Expensify está disponible para emitirse en USD, GBP y EUR. Actualiza este espacio de trabajo a una moneda compatible o utiliza un espacio de trabajo diferente.', }, changeOwner: { changeOwnerPageTitle: 'Transferir la propiedad', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 30eb674b55ee..47a18f0ce344 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -982,6 +982,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portefeuille', }, validateAccount: {title: 'Validez votre compte', subtitle: 'Compte', cta: 'Valider'}, + addHomeAddress: {title: 'Ajoutez votre adresse personnelle pour le suivi des distances', subtitle: 'Compte', cta: 'Ajouter une adresse'}, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Votre compte bancaire professionnel a été verrouillé', @@ -1296,6 +1297,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Créer une dépense de temps', }, iou: { + homeAddressRequired: { + title: 'L’adresse du domicile est obligatoire', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions de navette.` + : 'Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions liées aux trajets domicile-travail.', + cta: 'Ajouter une adresse domicile', + }, amount: 'Montant', percent: 'Pourcentage', date: 'Date', @@ -3639,7 +3648,8 @@ ${amount} pour ${merchant} - ${date}`, legalName: 'Nom légal', legalFirstName: 'Prénom légal', legalLastName: 'Nom de famille légal', - address: 'Adresse', + address: 'Adresse du domicile', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilise cette adresse pour les exclusions de déplacements domicile-travail.`, error: { dateShouldBeBefore: (dateString: string) => `La date doit être antérieure au ${dateString}`, dateShouldBeAfter: (dateString: string) => `La date doit être postérieure au ${dateString}`, @@ -3763,6 +3773,11 @@ ${amount} pour ${merchant} - ${date}`, thisBankAccount: 'Ce compte bancaire sera utilisé pour les paiements professionnels dans votre espace de travail', accountNumber: 'Numéro de compte', routingNumber: 'Numéro de routage', + internationalBankAccountDetails: 'Coordonnées bancaires internationales', + internationalBankAccountDetailsTitle: 'Quelles sont vos coordonnées bancaires internationales ?', + internationalBankAccountDetailsSubtitle: "L'un de vos espaces de travail a besoin de coordonnées bancaires internationales pour traiter les remboursements", + iban: 'IBAN', + swiftBicCode: 'Code SWIFT/BIC', chooseAnAccountBelow: 'Choisissez un compte ci-dessous', addBankAccount: 'Ajouter un compte bancaire', chooseAnAccount: 'Choisissez un compte', @@ -3813,6 +3828,8 @@ ${amount} pour ${merchant} - ${date}`, restrictedBusiness: 'Veuillez confirmer que l’entreprise ne figure pas sur la liste des entreprises restreintes', routingNumber: 'Veuillez saisir un numéro d’acheminement valide', accountNumber: 'Veuillez saisir un numéro de compte valide', + iban: 'Veuillez saisir un IBAN valide', + swiftCode: 'Veuillez saisir un code SWIFT/BIC valide', routingAndAccountNumberCannotBeSame: 'Le code de routage et le numéro de compte ne peuvent pas être identiques', companyType: 'Veuillez sélectionner un type d’entreprise valide', tooManyAttempts: @@ -5929,10 +5946,16 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. addNewCard: { other: 'Autre', fileImport: 'Importer des transactions depuis un fichier', - createFileFeedHelpText: `Veuillez suivre ce guide d’aide pour importer les dépenses de votre carte d’entreprise !`, + createFileFeedHelpText: { + instructionStart: 'Sur la page suivante, vous téléchargerez un CSV de vos transactions par carte. ', + templateLink: 'Télécharger notre modèle', + instructionMiddle: ' ou consultez notre ', + helpGuideLink: 'guide d’aide', + instructionEnd: ' avant de l’importer.', + }, companyCardLayoutName: 'Nom de la disposition de carte entreprise', cardLayoutNameRequired: 'Le nom du layout de la carte entreprise est requis', - useAdvancedFields: 'Utiliser les champs avancés (non recommandé)', + downloadTemplate: 'Télécharger notre modèle', cardProviders: { gl1025: 'Cartes American Express Corporate', cdf: 'Cartes commerciales Mastercard', @@ -6022,9 +6045,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. currency: 'Devise', ignore: 'Ignorer', originalTransactionDate: 'Date de transaction d’origine', - originalAmount: 'Montant d’origine', - originalCurrency: 'Devise d’origine', - comment: 'Commentaire', + originalAmount: 'Montant de l’achat', + originalCurrency: 'Devise de l’achat', + comment: 'Description', category: 'Catégorie', tag: 'Étiquette', }, @@ -6114,6 +6137,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. currentBalanceDescription: 'Le solde actuel est la somme de toutes les transactions Carte Expensify comptabilisées depuis la dernière date de règlement.', balanceWillBeSettledOn: (settlementDate: string) => `Le solde sera réglé le ${settlementDate}`, settleBalance: 'Régler le solde', + settleBalanceConfirmationTitle: 'Régler le solde ?', + settleBalanceConfirmationPrompt: + 'Cela réglera votre solde actuel le prochain jour ouvrable. Une fois l’opération réussie, le montant sera de nouveau ajouté à votre limite restante.', cardLimit: 'Plafond de carte', remaining: 'Restant', remainingLimit: 'Plafond restant', @@ -7282,11 +7308,25 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, summaryDisabled: 'Pas d’exclusion du trajet domicile-travail', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclure ${distance} ${unit} par demande`, optionDisabledTitle: 'Ne pas exclure les trajets domicile-travail', - optionDisabledHelp: 'Aucune exclusion de trajet domicile-travail n’est appliquée.', + optionDisabledHelp: 'Aucun trajet domicile-travail n’est retiré des demandes.', optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - errors: {distanceMustBePositive: 'La distance doit être un nombre entier positif.', distanceTooLarge: 'La distance est trop grande.'}, + summaryHomeAndOffice: 'Utiliser les emplacements domicile et bureau', + optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', + optionHomeAndOfficeHelp: 'Utiliser l’adresse personnelle du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet.', + workspaceAddressRequired: { + title: 'Pas si vite...', + promptStart: 'Vous ne pouvez pas activer le paramètre de calcul par domicile et bureau avant d’ajouter d’abord un lieu de travail dans', + linkText: 'Aperçu', + promptEnd: '.', + cta: 'Compris', + }, + errors: { + distanceMustBePositive: 'La distance doit être un nombre entier positif.', + invalidAddress: 'Veuillez saisir une adresse valide', + distanceTooLarge: 'La distance est trop grande.', + }, }, distance: 'Distance', centrallyManage: 'Gérez les taux de manière centralisée, suivez en miles ou en kilomètres et définissez une catégorie par défaut.', @@ -7383,6 +7423,11 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, yourWorkspace: `Votre espace de travail est défini sur une devise non prise en charge. Consultez la liste des devises prises en charge.`, chooseAnExisting: 'Choisissez un compte bancaire existant pour payer les dépenses ou ajoutez-en un nouveau.', changeBankAccount: 'Changer de compte bancaire', + updateCurrencyForExpensifyCard: + "La Carte Expensify est disponible à l'émission en USD. Veuillez mettre à jour cet espace de travail en USD ou utiliser un autre espace de travail.", + updateCurrencyForExpensifyCardTitle: 'Obtenir la Carte Expensify', + euUkUpdateCurrencyForExpensifyCard: + 'La Carte Expensify peut être émise en USD, GBP et EUR. Veuillez mettre à jour cet espace de travail vers une devise prise en charge ou utiliser un autre espace de travail.', }, changeOwner: { changeOwnerPageTitle: 'Transférer le responsable', diff --git a/src/languages/it.ts b/src/languages/it.ts index 1f1e1cbab42e..efc722a148f8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -980,6 +980,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portafoglio', }, validateAccount: {title: 'Conferma il tuo account', subtitle: 'Account', cta: 'Conferma'}, + addHomeAddress: {title: 'Aggiungi il tuo indirizzo di casa per il tracciamento delle distanze', subtitle: 'Account', cta: 'Aggiungi indirizzo'}, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, unlockBankAccount: { workspaceTitle: 'Il conto bancario della tua azienda è stato bloccato', @@ -1291,6 +1292,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Crea nota spese tempo', }, iou: { + homeAddressRequired: { + title: "L'indirizzo di casa è obbligatorio", + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Prima di registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le detrazioni per pendolarismo.` + : 'Prima di poter registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro utilizza questo indirizzo per le detrazioni per i pendolari.', + cta: 'Aggiungi indirizzo di casa', + }, amount: 'Importo', percent: 'Percentuale', date: 'Data', @@ -3619,7 +3628,8 @@ ${amount} per ${merchant} - ${date}`, legalName: 'Nome legale', legalFirstName: 'Nome legale di battesimo', legalLastName: 'Cognome legale', - address: 'Indirizzo', + address: 'Indirizzo di casa', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilizza questo indirizzo per le esclusioni pendolari.`, error: { dateShouldBeBefore: (dateString: string) => `La data deve essere precedente a ${dateString}`, dateShouldBeAfter: (dateString: string) => `La data deve essere successiva a ${dateString}`, @@ -3740,6 +3750,11 @@ ${amount} per ${merchant} - ${date}`, thisBankAccount: 'Questo conto bancario sarà utilizzato per i pagamenti aziendali nel tuo spazio di lavoro', accountNumber: 'Numero di conto', routingNumber: 'Numero di instradamento', + internationalBankAccountDetails: 'Dati del conto bancario internazionale', + internationalBankAccountDetailsTitle: 'Quali sono i dati del tuo conto internazionale?', + internationalBankAccountDetailsSubtitle: 'Uno dei tuoi spazi di lavoro richiede i dati del conto internazionale per elaborare i rimborsi', + iban: 'IBAN', + swiftBicCode: 'Codice SWIFT/BIC', chooseAnAccountBelow: 'Scegli un account qui sotto', addBankAccount: 'Aggiungi conto bancario', chooseAnAccount: 'Scegli un account', @@ -3789,6 +3804,8 @@ ${amount} per ${merchant} - ${date}`, restrictedBusiness: 'Conferma che l’azienda non sia nell’elenco delle attività soggette a restrizioni', routingNumber: 'Inserisci un numero di instradamento valido', accountNumber: 'Inserisci un numero di conto valido', + iban: 'Inserisci un IBAN valido', + swiftCode: 'Inserisci un codice SWIFT/BIC valido', routingAndAccountNumberCannotBeSame: 'I numeri di routing e di conto non possono coincidere', companyType: 'Seleziona un tipo di azienda valido', tooManyAttempts: 'A causa di un numero elevato di tentativi di accesso, questa opzione è stata disabilitata per 24 ore. Riprova più tardi o inserisci invece i dati manualmente.', @@ -5887,10 +5904,16 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. addNewCard: { other: 'Altro', fileImport: 'Importa transazioni da file', - createFileFeedHelpText: `Segui questa guida di supporto per importare le spese della carta aziendale!`, + createFileFeedHelpText: { + instructionStart: 'Nella pagina successiva, caricherai un CSV delle transazioni della tua carta. ', + templateLink: 'Scarica il nostro modello', + instructionMiddle: ' oppure consulta la nostra ', + helpGuideLink: 'guida di supporto', + instructionEnd: ' prima di caricarlo.', + }, companyCardLayoutName: 'Nome layout carta aziendale', cardLayoutNameRequired: 'Il nome del layout della carta aziendale è obbligatorio', - useAdvancedFields: 'Usa i campi avanzati (non consigliato)', + downloadTemplate: 'Scarica il nostro modello', cardProviders: { gl1025: 'Carte Corporate American Express', cdf: 'Carte Commerciali Mastercard', @@ -5979,9 +6002,9 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. currency: 'Valuta', ignore: 'Ignora', originalTransactionDate: 'Data originale della transazione', - originalAmount: 'Importo originale', - originalCurrency: 'Valuta originale', - comment: 'Commento', + originalAmount: 'Importo dell’acquisto', + originalCurrency: 'Valuta dell’acquisto', + comment: 'Descrizione', category: 'Categoria', tag: 'Tag', }, @@ -6069,6 +6092,9 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. currentBalanceDescription: 'Il saldo attuale è la somma di tutte le transazioni contabilizzate della Carta Expensify che si sono verificate dalla data dell’ultima liquidazione.', balanceWillBeSettledOn: (settlementDate: string) => `Il saldo sarà regolato il ${settlementDate}`, settleBalance: 'Saldo da saldare', + settleBalanceConfirmationTitle: 'Saldare il saldo?', + settleBalanceConfirmationPrompt: + 'Questa operazione salderà il tuo saldo attuale il giorno lavorativo successivo. Una volta completata, l’importo verrà aggiunto nuovamente al tuo limite rimanente.', cardLimit: 'Limite carta', remaining: 'Rimanente', remainingLimit: 'Limite rimanente', @@ -7228,11 +7254,25 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, summaryDisabled: 'Nessuna esclusione per il tragitto', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Escludi ${distance} ${unit} per rimborso`, optionDisabledTitle: 'Non escludere gli spostamenti casa-lavoro', - optionDisabledHelp: 'Non è applicata alcuna esclusione per il tragitto casa-lavoro.', + optionDisabledHelp: 'Nessun tragitto casa-lavoro viene rimosso dalle richieste.', optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', distanceLabel: 'Distanza', - errors: {distanceMustBePositive: 'La distanza deve essere un numero intero positivo.', distanceTooLarge: 'La distanza è troppo grande.'}, + summaryHomeAndOffice: 'Usa gli indirizzi di casa e ufficio', + optionHomeAndOfficeTitle: 'Calcola per casa e ufficio', + optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, la modalità di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni sul tragitto casa‑lavoro.', + workspaceAddressRequired: { + title: 'Non così in fretta...', + promptStart: 'Non puoi abilitare l’impostazione di calcolo per casa e ufficio finché non aggiungi prima una sede dell’ufficio in', + linkText: 'Panoramica', + promptEnd: '.', + cta: 'Ricevuto', + }, + errors: { + distanceMustBePositive: 'La distanza deve essere un numero intero positivo.', + invalidAddress: 'Inserisci un indirizzo valido', + distanceTooLarge: 'La distanza è troppo grande.', + }, }, distance: 'Distanza', centrallyManage: 'Gestisci centralmente le tariffe, monitora in miglia o chilometri e imposta una categoria predefinita.', @@ -7327,6 +7367,9 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, yourWorkspace: `La tua area di lavoro è impostata su una valuta non supportata. Visualizza l’elenco delle valute supportate.`, chooseAnExisting: 'Scegli un conto bancario esistente per pagare le spese oppure aggiungine uno nuovo.', changeBankAccount: 'Cambia conto bancario', + updateCurrencyForExpensifyCard: 'La Carta Expensify è disponibile per l’emissione in USD. Aggiorna questo workspace in USD oppure usa un altro workspace.', + updateCurrencyForExpensifyCardTitle: 'Ottieni la Carta Expensify', + euUkUpdateCurrencyForExpensifyCard: 'La Carta Expensify è disponibile in USD, GBP ed EUR. Aggiorna questo workspace a una valuta supportata oppure usa un altro workspace.', }, changeOwner: { changeOwnerPageTitle: 'Trasferisci proprietario', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4486e57ea32d..009346f794a2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -967,6 +967,7 @@ const translations: TranslationDeepObject = { }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, validateAccount: {title: 'アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, + addHomeAddress: {title: '距離の追跡用に自宅住所を追加してください', subtitle: 'アカウント', cta: '住所を追加'}, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, unlockBankAccount: { workspaceTitle: 'ビジネス用銀行口座がロックされました', @@ -1273,6 +1274,14 @@ const translations: TranslationDeepObject = { createTimeExpense: '時間経費を作成', }, iou: { + homeAddressRequired: { + title: '自宅住所は必須です', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除のためにこの住所を使用します。` + : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースでは、その住所を通勤控除に使用します。', + cta: '自宅住所を追加', + }, amount: '金額', percent: 'パーセント', date: '日付', @@ -3583,7 +3592,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' legalName: '法的氏名', legalFirstName: '法的な名', legalLastName: '法的な姓', - address: '住所', + address: '自宅住所', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} は通勤手当の除外にこの住所を使用します。`, error: { dateShouldBeBefore: (dateString: string) => `日付は${dateString}より前である必要があります`, dateShouldBeAfter: (dateString: string) => `日付は${dateString}より後の日付にしてください`, @@ -3702,6 +3712,11 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' thisBankAccount: 'この銀行口座は、ワークスペースでのビジネス支払いに使用されます', accountNumber: '口座番号', routingNumber: 'ルーティング番号', + internationalBankAccountDetails: '海外銀行口座情報', + internationalBankAccountDetailsTitle: 'あなたの海外口座情報を教えてください', + internationalBankAccountDetailsSubtitle: 'あなたのワークスペースの1つが、払い戻しを処理するために海外口座情報を必要としています', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BICコード', chooseAnAccountBelow: '下のアカウントを選択してください', addBankAccount: '銀行口座を追加', chooseAnAccount: 'アカウントを選択', @@ -3751,6 +3766,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' restrictedBusiness: 'ビジネスが制限対象の事業一覧に含まれていないことを確認してください', routingNumber: '有効なルーティング番号を入力してください', accountNumber: '有効な口座番号を入力してください', + iban: '有効なIBANを入力してください', + swiftCode: '有効なSWIFT/BICコードを入力してください', routingAndAccountNumberCannotBeSame: 'ルーティング番号と口座番号を同じにすることはできません', companyType: '有効な会社の種類を選択してください', tooManyAttempts: 'ログイン試行回数が多すぎるため、このオプションは24時間無効になっています。時間をおいてから再度お試しいただくか、代わりに詳細情報を手動で入力してください。', @@ -5817,10 +5834,16 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO addNewCard: { other: 'その他', fileImport: 'ファイルから取引をインポート', - createFileFeedHelpText: `会社カードの経費をインポートするには、このヘルプガイドに従ってください。`, + createFileFeedHelpText: { + instructionStart: '次のページで、カード取引のCSVをアップロードします。アップロードする前に、', + templateLink: 'テンプレートをダウンロード', + instructionMiddle: 'するか、', + helpGuideLink: 'ヘルプガイド', + instructionEnd: 'をご確認ください。', + }, companyCardLayoutName: '法人カードレイアウト名', cardLayoutNameRequired: '法人カードレイアウト名は必須です', - useAdvancedFields: '詳細フィールドを使用(非推奨)', + downloadTemplate: 'テンプレートをダウンロード', cardProviders: { gl1025: 'American Express コーポレートカード', cdf: 'Mastercard コマーシャルカード', @@ -5907,9 +5930,9 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO currency: '通貨', ignore: '無視', originalTransactionDate: '元の取引日', - originalAmount: '元の金額', - originalCurrency: '元の通貨', - comment: 'コメント', + originalAmount: '購入金額', + originalCurrency: '購入通貨', + comment: '説明', category: 'カテゴリ', tag: 'タグ', }, @@ -5997,6 +6020,8 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO currentBalanceDescription: '現在残高は、前回の精算日以降に発生し記帳されたすべての Expensify カード取引の合計です。', balanceWillBeSettledOn: (settlementDate: string) => `残高は${settlementDate}に精算されます`, settleBalance: '残高を清算', + settleBalanceConfirmationTitle: '残高を清算しますか?', + settleBalanceConfirmationPrompt: '現在の残高が翌営業日に清算されます。清算が完了すると、その金額は残りの利用限度額に戻されます。', cardLimit: 'カード上限', remaining: '残り', remainingLimit: '残りの上限', @@ -7137,11 +7162,21 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から summaryDisabled: '通勤除外なし', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `申請ごとに ${distance} ${unit} を除外します`, optionDisabledTitle: '通勤を除外しない', - optionDisabledHelp: '通勤除外は適用されていません。', + optionDisabledHelp: '通勤分は精算申請から除外されていません。', optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', - errors: {distanceMustBePositive: '距離は正の整数で入力してください。', distanceTooLarge: '距離が大きすぎます。'}, + summaryHomeAndOffice: '自宅と勤務先の所在地を使用する', + optionHomeAndOfficeTitle: '自宅とオフィスで按分計算します', + optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィス配属先を使用して、通勤の除外額を計算します。', + workspaceAddressRequired: { + title: 'ちょっとお待ちください…', + promptStart: '自宅とオフィスで計算する設定は、先にオフィスの所在地を追加しないと有効にできません', + linkText: '概要', + promptEnd: 'でオフィスの住所を追加してください。', + cta: '了解です', + }, + errors: {distanceMustBePositive: '距離は正の整数で入力してください。', invalidAddress: '有効な住所を入力してください', distanceTooLarge: '距離が大きすぎます。'}, }, distance: '距離', centrallyManage: '料金を一元管理し、マイルまたはキロメートルで追跡し、デフォルトのカテゴリを設定できます。', @@ -7235,6 +7270,9 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から yourWorkspace: `ご利用のワークスペースはサポートされていない通貨に設定されています。サポートされている通貨の一覧を表示します。`, chooseAnExisting: '既存の銀行口座を選択して経費を支払うか、新しい口座を追加してください。', changeBankAccount: '銀行口座を変更', + updateCurrencyForExpensifyCard: 'Expensify カードは USD でのみ発行できます。このワークスペースを USD に更新するか、別のワークスペースをご利用ください。', + updateCurrencyForExpensifyCardTitle: 'Expensify カードを申し込む', + euUkUpdateCurrencyForExpensifyCard: 'Expensify カードは USD、GBP、EUR で発行できます。このワークスペースを対応している通貨に更新するか、別のワークスペースを使用してください。', }, changeOwner: { changeOwnerPageTitle: '所有者を変更', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b89e745993d7..28fd7d9ad85d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -978,6 +978,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portemonnee', }, validateAccount: {title: 'Valideer je account', subtitle: 'Account', cta: 'Valideren'}, + addHomeAddress: {title: 'Voeg je thuisadres toe voor afstandsregistratie', subtitle: 'Account', cta: 'Adres toevoegen'}, fixFailedBilling: {title: 'We konden je kaart in ons bestand niet belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Je zakelijke bankrekening is geblokkeerd', @@ -1290,6 +1291,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Tijdkosten aanmaken', }, iou: { + homeAddressRequired: { + title: 'Woonadres is verplicht', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werk aftrek.` + : 'Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. Deze werkruimte gebruikt dit adres voor woon-werkverkeeraftrek.', + cta: 'Thuisadres toevoegen', + }, amount: 'Bedrag', percent: 'Procent', date: 'Datum', @@ -3619,7 +3628,8 @@ ${amount} voor ${merchant} - ${date}`, legalName: 'Wettelijke naam', legalFirstName: 'Juridische voornaam', legalLastName: 'Wettelijke achternaam', - address: 'Adres', + address: 'Adres thuis', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} gebruikt dit adres voor woon-werkuitzonderingen.`, error: { dateShouldBeBefore: (dateString: string) => `Datum moet vóór ${dateString} zijn`, dateShouldBeAfter: (dateString: string) => `Datum moet na ${dateString} liggen`, @@ -3739,6 +3749,11 @@ ${amount} voor ${merchant} - ${date}`, thisBankAccount: 'Deze bankrekening wordt gebruikt voor zakelijke betalingen in je workspace', accountNumber: 'Rekeningnummer', routingNumber: 'Routingnummer', + internationalBankAccountDetails: 'Internationale bankrekeninggegevens', + internationalBankAccountDetailsTitle: 'Wat zijn je internationale rekeninggegevens?', + internationalBankAccountDetailsSubtitle: 'Een van je werkruimtes heeft internationale rekeninggegevens nodig om onkostenvergoedingen te verwerken', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BIC-code', chooseAnAccountBelow: 'Kies een account hieronder', addBankAccount: 'Bankrekening toevoegen', chooseAnAccount: 'Kies een account', @@ -3788,6 +3803,8 @@ ${amount} voor ${merchant} - ${date}`, restrictedBusiness: 'Bevestig dat het bedrijf niet op de lijst met beperkte bedrijven staat', routingNumber: 'Voer een geldig routingnummer in', accountNumber: 'Voer een geldig rekeningnummer in', + iban: 'Voer een geldige IBAN in', + swiftCode: 'Voer een geldige SWIFT/BIC-code in', routingAndAccountNumberCannotBeSame: 'Routing- en rekeningnummers mogen niet hetzelfde zijn', companyType: 'Selecteer een geldig bedrijfstype', tooManyAttempts: 'Vanwege een groot aantal inlogpogingen is deze optie voor 24 uur uitgeschakeld. Probeer het later opnieuw of voer de gegevens in plaats daarvan handmatig in.', @@ -5879,10 +5896,16 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ addNewCard: { other: 'Anders', fileImport: 'Transacties uit bestand importeren', - createFileFeedHelpText: `Volg deze hulpgids om de uitgaven op je bedrijfskaarten te importeren!`, + createFileFeedHelpText: { + instructionStart: 'Op de volgende pagina upload je een CSV van je kaarttransacties. ', + templateLink: 'Download ons sjabloon', + instructionMiddle: ' of bekijk onze ', + helpGuideLink: 'hulpgids', + instructionEnd: ' voordat je uploadt.', + }, companyCardLayoutName: 'Naam van lay-out van bedrijfskaart', cardLayoutNameRequired: 'De naam van de lay-out van de bedrijfskaart is verplicht', - useAdvancedFields: 'Geavanceerde velden gebruiken (niet aanbevolen)', + downloadTemplate: 'Download ons sjabloon', cardProviders: { gl1025: 'American Express Corporate Cards', cdf: 'Mastercard Zakelijke Kaarten', @@ -5970,9 +5993,9 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ currency: 'Valuta', ignore: 'Negeren', originalTransactionDate: 'Oorspronkelijke transactiedatum', - originalAmount: 'Oorspronkelijk bedrag', - originalCurrency: 'Oorspronkelijke valuta', - comment: 'Opmerking', + originalAmount: 'Aankoopbedrag', + originalCurrency: 'Aankoopvaluta', + comment: 'Omschrijving', category: 'Categorie', tag: 'Label', }, @@ -6062,6 +6085,9 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ currentBalanceDescription: 'Het huidige saldo is de som van alle geboekte Expensify Kaart-transacties die hebben plaatsgevonden sinds de laatste afwikkelingsdatum.', balanceWillBeSettledOn: (settlementDate: string) => `Saldo wordt vereffend op ${settlementDate}`, settleBalance: 'Saldo vereffenen', + settleBalanceConfirmationTitle: 'Saldo vereffenen?', + settleBalanceConfirmationPrompt: + 'Hiermee wordt je huidige saldo op de volgende werkdag vereffend. Zodra dit is gelukt, wordt het bedrag weer aan je resterende limiet toegevoegd.', cardLimit: 'Kaartlimiet', remaining: 'Resterend', remainingLimit: 'Resterende limiet', @@ -7212,11 +7238,21 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, summaryDisabled: 'Geen woon-werkuitzondering', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Sluit ${distance} ${unit} per declaratie uit`, optionDisabledTitle: 'Woon-werkverkeer niet uitsluiten', - optionDisabledHelp: 'Er is geen woon-werkuitzondering toegepast.', + optionDisabledHelp: 'Er is geen woon-werkverkeer uit declaraties verwijderd.', optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - errors: {distanceMustBePositive: 'Afstand moet een positief geheel getal zijn.', distanceTooLarge: 'Afstand is te groot.'}, + summaryHomeAndOffice: 'Gebruik thuis- en kantoorlocaties', + optionHomeAndOfficeTitle: 'Berekenen per thuis en kantoor', + optionHomeAndOfficeHelp: 'Gebruik het thuisadres van het lid, de werkregeling en de kantoorindeling om woon-werkuitzonderingen te berekenen.', + workspaceAddressRequired: { + title: 'Niet zo snel...', + promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in', + linkText: 'Overzicht', + promptEnd: '.', + cta: 'Begrepen', + }, + errors: {distanceMustBePositive: 'Afstand moet een positief geheel getal zijn.', invalidAddress: 'Voer een geldig adres in', distanceTooLarge: 'Afstand is te groot.'}, }, distance: 'Afstand', centrallyManage: 'Beheer tarieven centraal, volg in mijlen of kilometers en stel een standaardcategorie in.', @@ -7311,6 +7347,10 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, yourWorkspace: `Je werkruimte is ingesteld op een niet-ondersteunde valuta. Bekijk de lijst met ondersteunde valuta's.`, chooseAnExisting: 'Kies een bestaande bankrekening om onkosten te betalen of voeg een nieuwe toe.', changeBankAccount: 'Bankrekening wijzigen', + updateCurrencyForExpensifyCard: 'De Expensify Kaart kan alleen in USD worden uitgegeven. Werk deze workspace bij naar USD of gebruik een andere workspace.', + updateCurrencyForExpensifyCardTitle: 'Vraag de Expensify Kaart aan', + euUkUpdateCurrencyForExpensifyCard: + 'De Expensify Kaart kan worden uitgegeven in USD, GBP en EUR. Werk deze werkruimte bij naar een ondersteunde valuta of gebruik een andere werkruimte.', }, changeOwner: { changeOwnerPageTitle: 'Eigenaar overdragen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index bd6821eb7328..00287d1685b4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -986,6 +986,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portfel', }, validateAccount: {title: 'Zweryfikuj swoje konto', subtitle: 'Konto', cta: 'Zatwierdź'}, + addHomeAddress: {title: 'Dodaj swój adres domowy do śledzenia odległości', subtitle: 'Konto', cta: 'Dodaj adres'}, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, unlockBankAccount: { workspaceTitle: 'Twoje firmowe konto bankowe zostało zablokowane', @@ -1319,6 +1320,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Utwórz wydatek czasowy', }, iou: { + homeAddressRequired: { + title: 'Adres zamieszkania jest wymagany', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy.` + : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. To miejsce pracy używa tego adresu do odliczeń dojazdów.', + cta: 'Dodaj adres domowy', + }, amount: 'Kwota', percent: 'Procent', date: 'Data', @@ -3647,7 +3656,8 @@ ${amount} dla ${merchant} - ${date}`, legalName: 'Imię i nazwisko (pełne)', legalFirstName: 'Imię (zgodnie z dokumentem tożsamości)', legalLastName: 'Nazwisko zgodne z dokumentami', - address: 'Adres', + address: 'Adres domowy', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} używa tego adresu do wyłączeń dla dojeżdżających do pracy.`, error: { dateShouldBeBefore: (dateString: string) => `Data powinna być wcześniejsza niż ${dateString}`, dateShouldBeAfter: (dateString: string) => `Data powinna być po ${dateString}`, @@ -3766,6 +3776,11 @@ ${amount} dla ${merchant} - ${date}`, thisBankAccount: 'To konto bankowe będzie używane do płatności firmowych w Twoim obszarze roboczym', accountNumber: 'Numer konta', routingNumber: 'Numer rozliczeniowy', + internationalBankAccountDetails: 'Dane międzynarodowego konta bankowego', + internationalBankAccountDetailsTitle: 'Jakie są dane Twojego konta międzynarodowego?', + internationalBankAccountDetailsSubtitle: 'Jedna z Twoich przestrzeni roboczych potrzebuje danych konta międzynarodowego do przetwarzania zwrotów kosztów', + iban: 'IBAN', + swiftBicCode: 'Kod SWIFT/BIC', chooseAnAccountBelow: 'Wybierz konto poniżej', addBankAccount: 'Dodaj konto bankowe', chooseAnAccount: 'Wybierz konto', @@ -3815,6 +3830,8 @@ ${amount} dla ${merchant} - ${date}`, restrictedBusiness: 'Potwierdź, że firma nie znajduje się na liście działalności objętych ograniczeniami', routingNumber: 'Wprowadź prawidłowy numer rozliczeniowy', accountNumber: 'Wprowadź prawidłowy numer konta', + iban: 'Wprowadź prawidłowy numer IBAN', + swiftCode: 'Wprowadź prawidłowy kod SWIFT/BIC', routingAndAccountNumberCannotBeSame: 'Numer rozliczeniowy i numer konta nie mogą być takie same', companyType: 'Wybierz prawidłowy typ firmy', tooManyAttempts: 'Ze względu na dużą liczbę prób logowania ta opcja została wyłączona na 24 godziny. Spróbuj ponownie później lub wprowadź dane ręcznie.', @@ -5904,10 +5921,16 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy addNewCard: { other: 'Inne', fileImport: 'Importuj transakcje z pliku', - createFileFeedHelpText: `Postępuj zgodnie z tym przewodnikiem pomocy, aby zaimportować wydatki z kart firmowych!`, + createFileFeedHelpText: { + instructionStart: 'Na następnej stronie prześlesz plik CSV z transakcjami karty. ', + templateLink: 'Pobierz nasz szablon', + instructionMiddle: ' lub sprawdź nasz ', + helpGuideLink: 'przewodnik pomocy', + instructionEnd: ' przed przesłaniem.', + }, companyCardLayoutName: 'Nazwa układu karty firmowej', cardLayoutNameRequired: 'Nazwa układu karty firmowej jest wymagana', - useAdvancedFields: 'Użyj pól zaawansowanych (niezalecane)', + downloadTemplate: 'Pobierz nasz szablon', cardProviders: { gl1025: 'Karty korporacyjne American Express', cdf: 'Firmowe karty Mastercard', @@ -5995,9 +6018,9 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy currency: 'Waluta', ignore: 'Ignoruj', originalTransactionDate: 'Data pierwotnej transakcji', - originalAmount: 'Kwota pierwotna', - originalCurrency: 'Pierwotna waluta', - comment: 'Komentarz', + originalAmount: 'Kwota zakupu', + originalCurrency: 'Waluta zakupu', + comment: 'Opis', category: 'Kategoria', tag: 'Tag', }, @@ -6084,6 +6107,9 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy currentBalanceDescription: 'Bieżące saldo to suma wszystkich zaksięgowanych transakcji Kartą Expensify, które miały miejsce od ostatniej daty rozliczenia.', balanceWillBeSettledOn: (settlementDate: string) => `Saldo zostanie rozliczone w dniu ${settlementDate}`, settleBalance: 'Ureguluj saldo', + settleBalanceConfirmationTitle: 'Uregulować saldo?', + settleBalanceConfirmationPrompt: + 'Spowoduje to uregulowanie bieżącego salda w następnym dniu roboczym. Po pomyślnym zakończeniu kwota zostanie ponownie dodana do pozostałego limitu.', cardLimit: 'Limit karty', remaining: 'Pozostało', remainingLimit: 'Pozostały limit', @@ -7233,11 +7259,21 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, summaryDisabled: 'Bez wykluczenia dojazdów', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Wyklucz ${distance} ${unit} na zgłoszenie`, optionDisabledTitle: 'Nie wykluczaj dojazdów', - optionDisabledHelp: 'Nie zastosowano wyłączenia dojazdów.', + optionDisabledHelp: 'Żaden dojazd do pracy nie jest usuwany z rozliczeń.', optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', - errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', distanceTooLarge: 'Odległość jest zbyt duża.'}, + summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', + optionHomeAndOfficeTitle: 'Obliczaj według domu i biura', + optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisania do biura, żeby obliczyć wyłączenia z dojazdów.', + workspaceAddressRequired: { + title: 'Nie tak szybko…', + promptStart: 'Nie możesz włączyć ustawienia obliczania według domu i biura, dopóki najpierw nie dodasz lokalizacji biura w', + linkText: 'Przegląd', + promptEnd: '.', + cta: 'Jasne', + }, + errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Wpisz prawidłowy adres', distanceTooLarge: 'Odległość jest zbyt duża.'}, }, distance: 'Dystans', centrallyManage: 'Centralnie zarządzaj stawkami, śledź w milach lub kilometrach i ustaw domyślną kategorię.', @@ -7333,6 +7369,9 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, yourWorkspace: `Twoje miejsce pracy jest ustawione na nieobsługiwaną walutę. Zobacz listę obsługiwanych walut.`, chooseAnExisting: 'Wybierz istniejące konto bankowe do opłacania wydatków lub dodaj nowe.', changeBankAccount: 'Zmień konto bankowe', + updateCurrencyForExpensifyCard: 'Karta Expensify jest dostępna do wydania w USD. Zmień tę przestrzeń roboczą na USD lub użyj innej przestrzeni roboczej.', + updateCurrencyForExpensifyCardTitle: 'Zdobądź Kartę Expensify', + euUkUpdateCurrencyForExpensifyCard: 'Karta Expensify jest dostępna do wydania w USD, GBP i EUR. Zmień walutę tego workspace’a na obsługiwaną albo użyj innego workspace’a.', }, changeOwner: { changeOwnerPageTitle: 'Przenieś właściciela', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 58e5daad22c5..361133ed32fc 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -978,6 +978,7 @@ const translations: TranslationDeepObject = { subtitle: 'Carteira', }, validateAccount: {title: 'Valide sua conta', subtitle: 'Conta', cta: 'Validar'}, + addHomeAddress: {title: 'Adicione seu endereço residencial para rastrear distâncias', subtitle: 'Conta', cta: 'Adicionar endereço'}, fixFailedBilling: {title: 'Não foi possível cobrar o cartão cadastrado', subtitle: 'Assinatura'}, unlockBankAccount: { workspaceTitle: 'Sua conta bancária comercial foi bloqueada', @@ -1290,6 +1291,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Criar despesa de tempo', }, iou: { + homeAddressRequired: { + title: 'Endereço residencial é obrigatório', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` + : 'Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', + cta: 'Adicionar endereço residencial', + }, amount: 'Valor', percent: 'Porcentagem', date: 'Data', @@ -3609,7 +3618,8 @@ ${amount} para ${merchant} - ${date}`, legalName: 'Nome legal', legalFirstName: 'Primeiro nome legal', legalLastName: 'Sobrenome legal', - address: 'Endereço', + address: 'Endereço residencial', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa este endereço para exclusões de transporte diário.`, error: { dateShouldBeBefore: (dateString: string) => `A data deve ser anterior a ${dateString}`, dateShouldBeAfter: (dateString: string) => `A data deve ser posterior a ${dateString}`, @@ -3728,6 +3738,11 @@ ${amount} para ${merchant} - ${date}`, thisBankAccount: 'Esta conta bancária será usada para pagamentos empresariais no seu workspace', accountNumber: 'Número da conta', routingNumber: 'Número de roteamento', + internationalBankAccountDetails: 'Dados da conta bancária internacional', + internationalBankAccountDetailsTitle: 'Quais são os dados da sua conta internacional?', + internationalBankAccountDetailsSubtitle: 'Um dos seus espaços de trabalho precisa de dados de conta internacional para processar reembolsos', + iban: 'IBAN', + swiftBicCode: 'Código SWIFT/BIC', chooseAnAccountBelow: 'Escolha uma conta abaixo', addBankAccount: 'Adicionar conta bancária', chooseAnAccount: 'Escolha uma conta', @@ -3777,6 +3792,8 @@ ${amount} para ${merchant} - ${date}`, restrictedBusiness: 'Confirme que a empresa não está na lista de empresas restritas', routingNumber: 'Insira um número de roteamento válido', accountNumber: 'Insira um número de conta válido', + iban: 'Insira um IBAN válido', + swiftCode: 'Insira um código SWIFT/BIC válido', routingAndAccountNumberCannotBeSame: 'Os números de roteamento e da conta não podem ser iguais', companyType: 'Selecione um tipo de empresa válido', tooManyAttempts: 'Devido ao alto número de tentativas de login, esta opção foi desativada por 24 horas. Tente novamente mais tarde ou insira os dados manualmente.', @@ -5879,10 +5896,16 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS addNewCard: { other: 'Outro', fileImport: 'Importar transações do arquivo', - createFileFeedHelpText: `Siga este guia de ajuda para importar as despesas do cartão corporativo!`, + createFileFeedHelpText: { + instructionStart: 'Na próxima página, você enviará um CSV das transações do seu cartão. ', + templateLink: 'Baixe nosso modelo', + instructionMiddle: ' ou confira nosso ', + helpGuideLink: 'guia de ajuda', + instructionEnd: ' antes de enviar.', + }, companyCardLayoutName: 'Nome do layout do cartão corporativo', cardLayoutNameRequired: 'O nome do layout do cartão corporativo é obrigatório', - useAdvancedFields: 'Usar campos avançados (não recomendado)', + downloadTemplate: 'Baixe nosso modelo', cardProviders: { gl1025: 'Cartões Corporativos American Express', cdf: 'Cartões Comerciais Mastercard', @@ -5970,9 +5993,9 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS currency: 'Moeda', ignore: 'Ignorar', originalTransactionDate: 'Data original da transação', - originalAmount: 'Valor original', - originalCurrency: 'Moeda original', - comment: 'Comentário', + originalAmount: 'Valor da compra', + originalCurrency: 'Moeda da compra', + comment: 'Descrição', category: 'Categoria', tag: 'Etiqueta', }, @@ -6060,6 +6083,8 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS currentBalanceDescription: 'O saldo atual é a soma de todas as transações lançadas do Cartão Expensify que ocorreram desde a última data de liquidação.', balanceWillBeSettledOn: (settlementDate: string) => `O saldo será liquidado em ${settlementDate}`, settleBalance: 'Quitar saldo', + settleBalanceConfirmationTitle: 'Quitar saldo?', + settleBalanceConfirmationPrompt: 'Isso quitará seu saldo atual no próximo dia útil. Após a conclusão, o valor será adicionado novamente ao seu limite restante.', cardLimit: 'Limite do cartão', remaining: 'Restante', remainingLimit: 'Limite restante', @@ -7213,11 +7238,25 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, summaryDisabled: 'Sem exclusão de deslocamento', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por solicitação`, optionDisabledTitle: 'Não excluir deslocamentos de casa para o trabalho', - optionDisabledHelp: 'Nenhuma exclusão de deslocamento é aplicada.', + optionDisabledHelp: 'Nenhum deslocamento é removido das solicitações.', optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - errors: {distanceMustBePositive: 'A distância deve ser um número inteiro positivo.', distanceTooLarge: 'A distância é muito grande.'}, + summaryHomeAndOffice: 'Usar endereços de casa e escritório', + optionHomeAndOfficeTitle: 'Calcular por casa e escritório', + optionHomeAndOfficeHelp: 'Use o endereço residencial, o regime de trabalho e a designação de escritório da pessoa colaboradora para calcular as exclusões de deslocamento.', + workspaceAddressRequired: { + title: 'Calma lá...', + promptStart: 'Você não pode ativar a configuração de cálculo por casa e escritório até primeiro adicionar um endereço de escritório em', + linkText: 'Visão geral', + promptEnd: '.', + cta: 'Entendi', + }, + errors: { + distanceMustBePositive: 'A distância deve ser um número inteiro positivo.', + invalidAddress: 'Insira um endereço válido', + distanceTooLarge: 'A distância é muito grande.', + }, }, distance: 'Distância', centrallyManage: 'Gerencie tarifas centralmente, acompanhe em milhas ou quilômetros e defina uma categoria padrão.', @@ -7312,6 +7351,10 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, yourWorkspace: `Seu workspace está configurado para uma moeda não compatível. Veja a lista de moedas compatíveis.`, chooseAnExisting: 'Escolha uma conta bancária existente para pagar despesas ou adicione uma nova.', changeBankAccount: 'Alterar conta bancária', + updateCurrencyForExpensifyCard: 'O Cartão Expensify está disponível para emissão em USD. Atualize este workspace para USD ou use um workspace diferente.', + updateCurrencyForExpensifyCardTitle: 'Obter o Cartão Expensify', + euUkUpdateCurrencyForExpensifyCard: + 'O Cartão Expensify está disponível para emissão em USD, GBP e EUR. Atualize este espaço de trabalho para uma moeda compatível ou use um espaço de trabalho diferente.', }, changeOwner: { changeOwnerPageTitle: 'Transferir proprietário', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c3d39466111a..cb89e0234f2b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -942,6 +942,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, + addHomeAddress: {title: '添加您的家庭住址以进行距离跟踪', subtitle: '账户', cta: '添加地址'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', @@ -1236,6 +1237,14 @@ const translations: TranslationDeepObject = { createTimeExpense: '创建工时报销', }, iou: { + homeAddressRequired: { + title: '需要填写家庭地址', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `在开始记录出行距离之前,您需要在个人资料中添加家庭住址。${workspaceName} 会使用此地址用于通勤抵扣。` + : '在开始记录里程之前,您需要在个人资料中添加您的家庭住址。此工作区会使用该地址计算通勤扣除。', + cta: '添加家庭住址', + }, amount: '金额', percent: '百分比', date: '日期', @@ -3494,7 +3503,8 @@ ${amount},商户:${merchant} - 日期:${date}`, legalName: '法定姓名', legalFirstName: '法定名(名)', legalLastName: '法定姓氏', - address: '地址', + address: '家庭住址', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} 使用此地址进行通勤排除。`, error: { dateShouldBeBefore: (dateString: string) => `日期应早于 ${dateString}`, dateShouldBeAfter: (dateString: string) => `日期应晚于 ${dateString}`, @@ -3611,6 +3621,11 @@ ${amount},商户:${merchant} - 日期:${date}`, thisBankAccount: '此银行账户将用于您工作区的业务付款', accountNumber: '账号编号', routingNumber: '路由号码', + internationalBankAccountDetails: '国际银行账户信息', + internationalBankAccountDetailsTitle: '您的国际账户信息是什么?', + internationalBankAccountDetailsSubtitle: '您的其中一个工作区需要国际账户信息来处理报销', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BIC 代码', chooseAnAccountBelow: '在下方选择一个账户', addBankAccount: '添加银行账户', chooseAnAccount: '选择一个账户', @@ -3659,6 +3674,8 @@ ${amount},商户:${merchant} - 日期:${date}`, restrictedBusiness: '请确认该企业不在受限制业务列表中', routingNumber: '请输入有效的路由号码', accountNumber: '请输入有效的账户号码', + iban: '请输入有效的 IBAN', + swiftCode: '请输入有效的 SWIFT/BIC 代码', routingAndAccountNumberCannotBeSame: '路由号码和账号不能相同', companyType: '请选择一个有效的公司类型', tooManyAttempts: '由于登录尝试次数过多,此选项已被禁用 24 小时。请稍后重试,或改为手动输入详细信息。', @@ -5677,10 +5694,16 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM addNewCard: { other: '其他', fileImport: '从文件导入交易', - createFileFeedHelpText: `请按照此帮助指南操作,将您的公司卡费用导入!`, + createFileFeedHelpText: { + instructionStart: '在下一页,您将上传卡交易的 CSV 文件。上传前,请先', + templateLink: '下载我们的模板', + instructionMiddle: ',或查看我们的', + helpGuideLink: '帮助指南', + instructionEnd: '。', + }, companyCardLayoutName: '公司卡片布局名称', cardLayoutNameRequired: '公司卡片布局名称是必填的', - useAdvancedFields: '使用高级字段(不推荐)', + downloadTemplate: '下载我们的模板', cardProviders: { gl1025: 'American Express 公司卡', cdf: '万事达商业卡', @@ -5767,9 +5790,9 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM currency: '货币', ignore: '忽略', originalTransactionDate: '原始交易日期', - originalAmount: '原始金额', - originalCurrency: '原始货币', - comment: '评论', + originalAmount: '购买金额', + originalCurrency: '购买货币', + comment: '描述', category: '类别', tag: '标签', }, @@ -5856,6 +5879,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM currentBalanceDescription: '当前余额是上次结算日期以来已入账的所有 Expensify 卡交易的总和。', balanceWillBeSettledOn: (settlementDate: string) => `余额将于 ${settlementDate} 结清`, settleBalance: '结清余额', + settleBalanceConfirmationTitle: '结清余额?', + settleBalanceConfirmationPrompt: '这将在下一个工作日结清您的当前余额。成功后,该金额将重新计入您的剩余额度。', cardLimit: '卡片限额', remaining: '剩余', remainingLimit: '剩余额度', @@ -6957,11 +6982,21 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM summaryDisabled: '不排除通勤', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `每次报销排除 ${distance} ${unit}`, optionDisabledTitle: '不要排除通勤', - optionDisabledHelp: '未应用通勤排除规则。', + optionDisabledHelp: '未从报销中移除任何通勤费用。', optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', - errors: {distanceMustBePositive: '距离必须是一个正整数。', distanceTooLarge: '距离过大。'}, + summaryHomeAndOffice: '使用家庭和办公地点', + optionHomeAndOfficeTitle: '按家庭和办公室计算', + optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公室分配来计算通勤免税额。', + workspaceAddressRequired: { + title: '别急……', + promptStart: '在您先添加一个办公地点之前,无法启用按家庭和办公室计算的设置,添加位置于', + linkText: '概览', + promptEnd: '中添加办公地点,才能启用“按家庭和办公室计算”设置。', + cta: '知道了', + }, + errors: {distanceMustBePositive: '距离必须是一个正整数。', invalidAddress: '请输入有效地址', distanceTooLarge: '距离过大。'}, }, distance: '距离', centrallyManage: '集中管理费率,以英里或公里跟踪,并设置默认类别。', @@ -7054,6 +7089,9 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM yourWorkspace: `您的工作区当前使用不受支持的货币。请查看支持的货币列表。`, chooseAnExisting: '选择现有银行账户来支付报销,或添加新账户。', changeBankAccount: '更改银行账户', + updateCurrencyForExpensifyCard: 'Expensify 卡目前仅支持以美元(USD)发行。请将此工作区的货币更新为美元(USD),或使用其他工作区。', + updateCurrencyForExpensifyCardTitle: '获取 Expensify 卡', + euUkUpdateCurrencyForExpensifyCard: 'Expensify 卡目前可发行的货币包括 USD、GBP 和 EUR。请将此工作区更新为受支持的货币或使用其他工作区。', }, changeOwner: { changeOwnerPageTitle: '转移所有者', diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 27557d1056ba..14c224d8b036 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -19,12 +19,12 @@ import type Response from '@src/types/onyx/Response'; import type {OnyxKey} from 'react-native-onyx'; import type {ApiRequestCommandParameters, ApiRequestType, CommandOfType, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types'; -import type {WriteReadyBarrier} from './writeWhenReady'; +import type {WriteReadyBarrier, WriteWhenReadyOptions} from './writeWhenReady'; import {buildLogParams, prepareRequest, processRequest} from './makeRequest'; import {READ_COMMANDS, WRITE_COMMANDS} from './types'; import baseWrite from './write'; -import {createTransitionBarrier, writeWhenReady} from './writeWhenReady'; +import {armTransitionBarrier, createTransitionBarrier, writeWhenReady as baseWriteWhenReady} from './writeWhenReady'; /** * All calls to API.write() will be persisted to disk as JSON with the params, successData, and failureData (or finallyData, if included in place of the former two values). @@ -52,6 +52,27 @@ function write( return baseWrite(command, apiCommandParameters, onyxData, conflictResolver); } +/** `API.writeWhenReady()` - like `write()` but deferred until a readiness barrier settles; re-declared here for the same spyOn reason as `write` above. */ +function writeWhenReady(command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand]): Promise>; + +function writeWhenReady( + command: TCommand, + apiCommandParameters: ApiRequestCommandParameters[TCommand], + onyxData?: OnyxData, + barrier?: WriteReadyBarrier, + options?: number | WriteWhenReadyOptions, +): Promise>; + +function writeWhenReady( + command: TCommand, + apiCommandParameters: ApiRequestCommandParameters[TCommand], + onyxData?: OnyxData, + barrier?: WriteReadyBarrier, + options?: number | WriteWhenReadyOptions, +): Promise> { + return baseWriteWhenReady(command, apiCommandParameters, onyxData, barrier, options); +} + /** * This function is used to write data to the API while ensuring that there are no duplicate requests in the queue. * If a duplicate request is found, it resolves the conflict by replacing the duplicated request with the new one. @@ -79,7 +100,10 @@ function writeWithNoDuplicatesReconnectConflictAction = {}, ): Promise> { - const incomingRequest: AnyRequest = {command, data: {updateIDFrom: readUpdateIDFrom(apiCommandParameters)}}; + const incomingRequest: AnyRequest = { + command, + data: {updateIDFrom: readUpdateIDFrom(apiCommandParameters)}, + }; const conflictResolver = { checkAndFixConflictingRequest: (persistedRequests: AnyRequest[]) => resolveReconnectDuplicationConflictAction(persistedRequests, getOngoingRequest(), incomingRequest), }; @@ -227,6 +251,7 @@ export { write, writeWhenReady, createTransitionBarrier, + armTransitionBarrier, makeRequestWithSideEffects, read, paginate, @@ -236,4 +261,4 @@ export { writeWithNoDuplicatesEnableFeatureConflicts, waitForWrites, }; -export type {WriteReadyBarrier}; +export type {WriteReadyBarrier, WriteWhenReadyOptions}; diff --git a/src/libs/API/makeRequest.ts b/src/libs/API/makeRequest.ts index 563a3684f37d..05139c6066e5 100644 --- a/src/libs/API/makeRequest.ts +++ b/src/libs/API/makeRequest.ts @@ -109,7 +109,7 @@ function prepareRequest( const {optimisticData, successData, failureData, ...onyxDataWithoutOptimisticData} = onyxData; if (optimisticData && shouldApplyOptimisticData) { - Log.info('[API] Applying optimistic data', false, {command, type}); + Log.info('[API] Applying optimistic data', false, {command, type}, undefined, optimisticData); Onyx.update(optimisticData); } diff --git a/src/libs/API/parameters/AddPersonalBankAccountParams.ts b/src/libs/API/parameters/AddPersonalBankAccountParams.ts index 496c9a6b02c6..65c6847ffc76 100644 --- a/src/libs/API/parameters/AddPersonalBankAccountParams.ts +++ b/src/libs/API/parameters/AddPersonalBankAccountParams.ts @@ -18,6 +18,8 @@ type AddPersonalBankAccountParams = { addressZip?: string; addressCountry?: string; confirmedOwnershipDetails?: boolean; + iban?: string; + swiftCode?: string; }; export default AddPersonalBankAccountParams; diff --git a/src/libs/API/parameters/SetApprovalWorkflowParams.ts b/src/libs/API/parameters/SetApprovalWorkflowParams.ts new file mode 100644 index 000000000000..838dd39843c3 --- /dev/null +++ b/src/libs/API/parameters/SetApprovalWorkflowParams.ts @@ -0,0 +1,11 @@ +type SetApprovalWorkflowParams = { + /** The policy ID whose approval-workflow rules are being mutated. */ + policyID: string; + + /** + * JSON-stringified object keyed by ruleID + */ + rules: string; +}; + +export default SetApprovalWorkflowParams; diff --git a/src/libs/API/parameters/SetPolicyCommuterExclusionsParams.ts b/src/libs/API/parameters/SetPolicyCommuterExclusionsParams.ts index 4a61adb067b2..9c55f16f7a67 100644 --- a/src/libs/API/parameters/SetPolicyCommuterExclusionsParams.ts +++ b/src/libs/API/parameters/SetPolicyCommuterExclusionsParams.ts @@ -1,13 +1,13 @@ type SetPolicyCommuterExclusionsParams = { policyID: string; /** - * Exclusion method - currently only "fixedDistance" is supported. R2 will add "homeAndOffice". + * Exclusion method - either "fixedDistance" or "homeAndOffice". * Named `commuterExclusionMethod` (not `method`) because the Web-Expensify WAF restricts the * plain `method` parameter to payment values. */ commuterExclusionMethod: string; - /** Distance to exclude per claim. Must be > 0 when commuterExclusionMethod is "fixedDistance". */ - distance: number; + /** Distance to exclude per claim. Required and > 0 when commuterExclusionMethod is "fixedDistance"; omitted for "homeAndOffice". */ + distance?: number; }; export default SetPolicyCommuterExclusionsParams; diff --git a/src/libs/API/parameters/SetTravelBillingReconciliationBankAccountParams.ts b/src/libs/API/parameters/SetTravelBillingReconciliationBankAccountParams.ts index 84a464ae1fae..ff760f83ec76 100644 --- a/src/libs/API/parameters/SetTravelBillingReconciliationBankAccountParams.ts +++ b/src/libs/API/parameters/SetTravelBillingReconciliationBankAccountParams.ts @@ -1,7 +1,6 @@ type SetTravelBillingReconciliationBankAccountParams = { domainName: string; - // The backend reads this request key, so it keeps the legacy spelling. - travelInvoicingReconciliationBankAccountID: string; + travelBillingReconciliationBankAccountID: string; }; export default SetTravelBillingReconciliationBankAccountParams; diff --git a/src/libs/API/parameters/ToggleTravelBillingContinuousReconciliationParams.ts b/src/libs/API/parameters/ToggleTravelBillingContinuousReconciliationParams.ts index 6fb084c6388b..ce6413d9be83 100644 --- a/src/libs/API/parameters/ToggleTravelBillingContinuousReconciliationParams.ts +++ b/src/libs/API/parameters/ToggleTravelBillingContinuousReconciliationParams.ts @@ -1,8 +1,7 @@ type ToggleTravelBillingContinuousReconciliationParams = { policyAccountID: number; shouldUseContinuousReconciliation: boolean; - // The backend reads this request key, so it keeps the legacy spelling. - travelInvoicingContinuousReconciliationConnection?: string; + travelBillingContinuousReconciliationConnection?: string; }; export default ToggleTravelBillingContinuousReconciliationParams; diff --git a/src/libs/API/parameters/UpdateRilletTravelBillingSettlementsAccountParams.ts b/src/libs/API/parameters/UpdateRilletTravelBillingSettlementsAccountParams.ts index a85c80957844..3c71df97e79f 100644 --- a/src/libs/API/parameters/UpdateRilletTravelBillingSettlementsAccountParams.ts +++ b/src/libs/API/parameters/UpdateRilletTravelBillingSettlementsAccountParams.ts @@ -2,7 +2,7 @@ import type {RilletBankAccount} from '@src/types/onyx/Policy'; type UpdateRilletTravelBillingSettlementsAccountParams = { policyID: string; - travelInvoicingSettlementsBankAccountID: RilletBankAccount['id']; + travelBillingSettlementsBankAccountID: RilletBankAccount['id']; }; export default UpdateRilletTravelBillingSettlementsAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 24e6cfd7d0af..928f16e03d59 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -573,6 +573,7 @@ export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; export type {default as SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams} from './SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams'; export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; +export type {default as SetApprovalWorkflowParams} from './SetApprovalWorkflowParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as RevokeMultifactorAuthenticationCredentialsParams} from './RevokeMultifactorAuthenticationCredentialsParams'; export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 6b8fac5d492e..4c6dd487dc1d 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -281,6 +281,7 @@ const WRITE_COMMANDS = { SET_POLICY_TIME_TRACKING_DEFAULT_RATE: 'SetPolicyTimeTrackingDefaultRate', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', SET_POLICY_CODING_RULE: 'SetPolicyCodingRule', + SET_APPROVAL_WORKFLOW: 'SetApprovalWorkflow', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT: 'SetPolicyExpenseMaxAmountNoReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'SetPolicyExpenseMaxAmountNoItemizedReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT: 'SetPolicyExpenseMaxAmount', @@ -342,7 +343,7 @@ const WRITE_COMMANDS = { UPDATE_QUICKBOOKS_ONLINE_SYNC_ITEMS: 'UpdateQuickbooksOnlineSyncItems', UPDATE_QUICKBOOKS_ONLINE_REIMBURSEMENT_ACCOUNT_ID: 'UpdateQuickbooksOnlineReimbursementAccountID', UPDATE_QUICKBOOKS_ONLINE_EXPORT: 'UpdateQuickbooksOnlineExport', - UPDATE_QUICKBOOKS_ONLINE_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateQuickbooksOnlineTravelInvoicingPayableAccount', + UPDATE_QUICKBOOKS_ONLINE_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateQuickbooksOnlineTravelBillingPayableAccount', UPDATE_QUICKBOOKS_DESKTOP_EXPORT_DATE: 'UpdateQuickbooksDesktopExportDate', UPDATE_MANY_POLICY_CONNECTION_CONFIGS: 'UpdateManyPolicyConnectionConfigurations', UPDATE_QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'UpdateQuickbooksDesktopNonReimbursableExpensesExportDestination', @@ -359,7 +360,7 @@ const WRITE_COMMANDS = { UPDATE_QUICKBOOKS_DESKTOP_SYNC_CLASSES: 'UpdateQuickbooksDesktopSyncClasses', UPDATE_QUICKBOOKS_DESKTOP_SYNC_CUSTOMERS: 'UpdateQuickbooksDesktopSyncCustomers', UPDATE_QUICKBOOKS_DESKTOP_SYNC_ITEMS: 'UpdateQuickbooksDesktopSyncItems', - UPDATE_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateQuickbooksDesktopTravelInvoicingPayableAccount', + UPDATE_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateQuickbooksDesktopTravelBillingPayableAccount', REMOVE_POLICY_CONNECTION: 'RemovePolicyConnection', DISCONNECT_WORKSPACE_RECEIPT_PARTNER: 'DisconnectWorkspaceReceiptPartner', SET_POLICY_TAXES_ENABLED: 'SetPolicyTaxesEnabled', @@ -458,8 +459,8 @@ const WRITE_COMMANDS = { UPDATE_NETSUITE_ACCOUNTING_METHOD: 'UpdateNetSuiteAccountingMethod', UPDATE_NETSUITE_JOURNALS_TO: 'UpdateNetSuiteExportJournalsTo', UPDATE_NETSUITE_APPROVAL_ACCOUNT: 'UpdateNetSuiteApprovalAccount', - UPDATE_NETSUITE_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateNetSuiteTravelInvoicingPayableAccount', - UPDATE_NETSUITE_TRAVEL_BILLING_JOURNAL_POSTING_PREFERENCE: 'UpdateNetSuiteTravelInvoicingJournalPostingPreference', + UPDATE_NETSUITE_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateNetSuiteTravelBillingPayableAccount', + UPDATE_NETSUITE_TRAVEL_BILLING_JOURNAL_POSTING_PREFERENCE: 'UpdateNetSuiteTravelBillingJournalPostingPreference', UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_REIMBURSABLE: 'UpdateNetSuiteCustomFormIDOptionsReimbursable', UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_NON_REIMBURSABLE: 'UpdateNetSuiteCustomFormIDOptionsNonReimbursable', UPDATE_NETSUITE_TOKENS: 'UpdateNetSuiteTokens', @@ -510,7 +511,7 @@ const WRITE_COMMANDS = { UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_CREDIT_CARD_CHARGE_EXPORT_DEFAULT_VENDOR: 'UpdateSageIntacctNonreimbursableExpensesCreditCardChargeExportDefaultVendor', UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_ACCOUNT: 'UpdateSageIntacctNonreimbursableExpensesExportAccount', UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_VENDOR: 'UpdateSageIntacctNonreimbursableExpensesExportVendor', - UPDATE_SAGE_INTACCT_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateSageIntacctTravelInvoicingPayableAccount', + UPDATE_SAGE_INTACCT_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateSageIntacctTravelBillingPayableAccount', CONNECT_POLICY_TO_RILLET: 'ConnectPolicyToRillet', SELECT_INTUIT_ENTERPRISE_SUITE_ENTITY: 'SelectIntuitEnterpriseSuiteEntity', UPDATE_RILLET_SUBSIDIARY: 'UpdateRilletSubsidiary', @@ -529,8 +530,8 @@ const WRITE_COMMANDS = { UPDATE_RILLET_BILL_PAYMENT_ACCOUNT: 'UpdateRilletBillPaymentAccount', UPDATE_RILLET_SYNC_EXPENSIFY_CARD_SETTLEMENTS: 'UpdateRilletSyncExpensifyCardSettlements', UPDATE_RILLET_SETTLEMENTS_ACCOUNT: 'UpdateRilletSettlementsAccount', - UPDATE_RILLET_SYNC_TRAVEL_BILLING_SETTLEMENTS: 'UpdateRilletSyncTravelInvoicingSettlements', - UPDATE_RILLET_TRAVEL_BILLING_SETTLEMENTS_ACCOUNT: 'UpdateRilletTravelInvoicingSettlementsAccount', + UPDATE_RILLET_SYNC_TRAVEL_BILLING_SETTLEMENTS: 'UpdateRilletSyncTravelBillingSettlements', + UPDATE_RILLET_TRAVEL_BILLING_SETTLEMENTS_ACCOUNT: 'UpdateRilletTravelBillingSettlementsAccount', CONNECT_POLICY_TO_DUALENTRY: 'ConnectPolicyToDualEntry', UPDATE_DUALENTRY_SUBSIDIARY: 'UpdateDualEntrySubsidiary', UPDATE_DUALENTRY_ENABLE_NEW_CATEGORIES: 'UpdateDualEntryEnableNewCategories', @@ -567,14 +568,14 @@ const WRITE_COMMANDS = { DELETE_SAVED_SEARCH: 'DeleteSavedSearch', UPDATE_CARD_SETTLEMENT_FREQUENCY: 'UpdateCardSettlementFrequency', UPDATE_CARD_SETTLEMENT_ACCOUNT: 'UpdateCardSettlementAccount', - CONFIGURE_TRAVEL_BILLING_FOR_POLICY: 'ConfigureTravelInvoicingForPolicy', - DEACTIVATE_TRAVEL_BILLING: 'DeactivateTravelInvoicing', - SET_TRAVEL_BILLING_SETTLEMENT_ACCOUNT: 'SetTravelInvoicingSettlementAccount', - SET_TRAVEL_BILLING_RECONCILIATION_BANK_ACCOUNT: 'SetTravelInvoicingReconciliationBankAccount', - TOGGLE_TRAVEL_BILLING_CONTINUOUS_RECONCILIATION: 'ToggleTravelInvoicingContinuousReconciliation', - UPDATE_TRAVEL_BILLING_SETTLEMENT_FREQUENCY: 'UpdateTravelInvoiceSettlementFrequency', - UPDATE_TRAVEL_BILLING_MONTHLY_LIMIT: 'UpdateTravelInvoicingMonthlyLimit', - PAY_TRAVEL_BILLING_SPEND: 'PayTravelInvoicingSpend', + CONFIGURE_TRAVEL_BILLING_FOR_POLICY: 'ConfigureTravelBillingForPolicy', + DEACTIVATE_TRAVEL_BILLING: 'DeactivateTravelBilling', + SET_TRAVEL_BILLING_SETTLEMENT_ACCOUNT: 'SetTravelBillingSettlementAccount', + SET_TRAVEL_BILLING_RECONCILIATION_BANK_ACCOUNT: 'SetTravelBillingReconciliationBankAccount', + TOGGLE_TRAVEL_BILLING_CONTINUOUS_RECONCILIATION: 'ToggleTravelBillingContinuousReconciliation', + UPDATE_TRAVEL_BILLING_SETTLEMENT_FREQUENCY: 'UpdateTravelBillingSettlementFrequency', + UPDATE_TRAVEL_BILLING_MONTHLY_LIMIT: 'UpdateTravelBillingMonthlyLimit', + PAY_TRAVEL_BILLING_SPEND: 'PayTravelBillingSpend', RETRY_TRAVEL_CARDS_PROVISIONING: 'RetryTravelCardsProvisioning', UPDATE_XERO_IMPORT_TRACKING_CATEGORIES: 'UpdateXeroImportTrackingCategories', UPDATE_XERO_IMPORT_TAX_RATES: 'UpdateXeroImportTaxRates', @@ -588,7 +589,7 @@ const WRITE_COMMANDS = { UPDATE_XERO_EXPORT_BILL_DATE: 'UpdateXeroExportBillDate', UPDATE_XERO_EXPORT_EXPORTER: 'UpdateXeroExportExporter', UPDATE_XERO_EXPORT_NON_REIMBURSABLE_ACCOUNT: 'UpdateXeroExportNonReimbursableAccount', - UPDATE_XERO_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateXeroTravelInvoicingPayableAccount', + UPDATE_XERO_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'UpdateXeroTravelBillingPayableAccount', UPDATE_XERO_SYNC_INVOICE_COLLECTIONS_ACCOUNT_ID: 'UpdateXeroSyncInvoiceCollectionsAccountID', UPDATE_XERO_SYNC_SYNC_REIMBURSED_REPORTS: 'UpdateXeroSyncSyncReimbursedReports', UPDATE_XERO_SYNC_REIMBURSEMENT_ACCOUNT_ID: 'UpdateXeroSyncReimbursementAccountID', @@ -957,6 +958,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_TIME_TRACKING]: Parameters.EnablePolicyTimeTrackingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; + [WRITE_COMMANDS.SET_APPROVAL_WORKFLOW]: Parameters.SetApprovalWorkflowParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED]: Parameters.SetPolicyCategoryAttendeesRequiredParams; @@ -1398,6 +1400,7 @@ const READ_COMMANDS = { OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', OPEN_PAYMENTS_PAGE: 'OpenPaymentsPage', + OPEN_DEPOSIT_ACCOUNT_SETUP: 'OpenDepositAccountSetup', OPEN_BANK_ACCOUNT_SHARE_PAGE: 'OpenBankAccountSharePage', OPEN_PUBLIC_PROFILE_PAGE: 'OpenPublicProfilePage', OPEN_PLAID_BANK_LOGIN: 'OpenPlaidBankLogin', @@ -1440,8 +1443,8 @@ const READ_COMMANDS = { OPEN_POLICY_RULES_PAGE: 'OpenPolicyRulesPage', OPEN_POLICY_EXPENSIFY_CARDS_PAGE: 'OpenPolicyExpensifyCardsPage', OPEN_POLICY_TRAVEL_PAGE: 'OpenPolicyTravelPage', - GET_TRAVEL_BILLING_STATEMENT_PDF: 'GetTravelInvoiceStatementPDF', - EXPORT_TRAVEL_BILLING_STATEMENT_CSV: 'ExportTravelInvoiceStatementCSV', + GET_TRAVEL_BILLING_STATEMENT_PDF: 'GetTravelBillingStatementPDF', + EXPORT_TRAVEL_BILLING_STATEMENT_CSV: 'ExportTravelBillingStatementCSV', OPEN_POLICY_COMPANY_CARDS_FEED: 'OpenPolicyCompanyCardsFeed', OPEN_POLICY_COMPANY_CARDS_PAGE: 'OpenPolicyCompanyCardsPage', OPEN_POLICY_EDIT_CARD_LIMIT_TYPE_PAGE: 'OpenPolicyEditCardLimitTypePage', @@ -1508,6 +1511,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: Parameters.OpenWorkspaceViewParams; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: null; [READ_COMMANDS.OPEN_PAYMENTS_PAGE]: Parameters.OpenPaymentsPageParams | null; + [READ_COMMANDS.OPEN_DEPOSIT_ACCOUNT_SETUP]: null; [READ_COMMANDS.OPEN_BANK_ACCOUNT_SHARE_PAGE]: null; [READ_COMMANDS.OPEN_PUBLIC_PROFILE_PAGE]: Parameters.OpenPublicProfilePageParams; [READ_COMMANDS.OPEN_PLAID_BANK_LOGIN]: Parameters.OpenPlaidBankLoginParams; diff --git a/src/libs/API/writeWhenReady.ts b/src/libs/API/writeWhenReady.ts index 385cfdb95083..492667e7d5ba 100644 --- a/src/libs/API/writeWhenReady.ts +++ b/src/libs/API/writeWhenReady.ts @@ -14,47 +14,43 @@ import type {ApiRequestCommandParameters, WriteCommand} from './types'; import {buildLogParams} from './makeRequest'; import write from './write'; -/** - * A readiness check used by `writeWhenReady`. It is called when the write is queued, and the write happens - * once the returned promise settles (a rejection releases the write too). - * It also receives an `AbortSignal` that fires if the write is released early (safety timeout / app - * background), so a barrier waiting on something cancelable can drop its registration instead of leaving it dangling. - */ +/** Readiness check for `writeWhenReady`: the write happens once the returned promise settles - a rejection releases it too, since the barrier only answers "when" to write, never "whether". */ type WriteReadyBarrier = (signal: AbortSignal) => PromiseLike; type ReleaseReason = 'success' | 'rejected' | 'safetyTimeout' | 'appBackground'; -// How long writeWhenReady waits for the barrier before executing, so the write happens even if the promise never settles. -// Must stay longer than the default barrier's worst case (CONST.MAX_TRANSITION_START_WAIT_MS + CONST.MAX_TRANSITION_DURATION_MS); a unit test pins that against drift. -// Exported for that test so it asserts on the real value rather than a copy of the formula. +type WriteWhenReadyOptions = { + safetyTimeoutMs?: number; + + /** Fires exactly once, on every release path, before `write()` is invoked. A throwing handler is logged, not thrown. */ + onRelease?: (reason: ReleaseReason) => void; + + /** Fires only after `write()` has been called and returned without throwing. A throwing handler is logged, not thrown. */ + onWriteStarted?: () => void; +}; + +// Must stay longer than the default barrier's worst case (a unit test pins that); exported so that test asserts the real value. const SAFETY_TIMEOUT_MS = 5 * CONST.MAX_TRANSITION_DURATION_MS; -// Pending deferred writes. Tracked so they can be force-flushed when the app backgrounds. -// Because JS timers are suspended in the background, we flush before the OS suspends the process. -// That ensures the optimistic data + queued request are not lost. +// Tracked so pending deferred writes can be force-flushed before the OS suspends a backgrounded app. const pendingWrites = new Set<() => void>(); let hasRegisteredBackgroundFlushListener = false; -/** - * Subscribe to AppState lazily on the first deferred write, - * so importers that never call writeWhenReady don't pay for the subscription. - */ +/** Subscribes to AppState lazily on the first deferred write, so importers that never defer don't pay for it. */ function registerBackgroundFlushListener() { if (hasRegisteredBackgroundFlushListener) { return; } hasRegisteredBackgroundFlushListener = true; - // Flush only on a full `background` transition, not the transient `inactive` state (Control Center, the app switcher, permission/biometric prompts). - // `background` is the last event before the OS can suspend the process, so it's our best opportunity for a last-minute, best-effort flush. + // Only `background` (not the transient `inactive`) is the last event before the OS can suspend the process. AppState.addEventListener('change', (nextState) => { if (nextState !== CONST.APP_STATE.BACKGROUND || pendingWrites.size === 0) { return; } Log.info(`[API] App going to "${nextState}" - flushing ${pendingWrites.size} pending writeWhenReady write(s)`, false); - // Isolate each flush so one throwing write can't abort the loop and strand the remaining pending writes. - // The copy guards against a flush synchronously queueing another deferred write, which would otherwise be flushed in this same loop. + // Copy first: a flush can synchronously queue another deferred write, which must not join this same loop. for (const flush of [...pendingWrites]) { try { flush(); @@ -66,17 +62,10 @@ function registerBackgroundFlushListener() { } /** - * Builds a `writeWhenReady` barrier that resolves once the current or upcoming transition completes. - * - * `waitFor` only selects which transition *opens the gate*: `true` (the default) accepts any transition, - * `'navigation'` accepts only a screen transition. Once the gate is open, both wait for every active - * transition to finish, regardless of kind. - * - * The default is `true` because the point of deferring is to keep the optimistic re-render off the main - * thread while *something* is animating - a modal or the keyboard contends for it just like a screen push. - * Pass `'navigation'` when the caller knows it navigates away and a stray modal/keyboard blip must not - * release the write early; the trade-off is waiting out `CONST.MAX_TRANSITION_START_WAIT_MS` if no screen - * transition ever starts. + * Builds a barrier that resolves once the current or upcoming transition completes. + * `waitFor: 'navigation'` requires a screen transition specifically, so a stray modal/keyboard + * animation can't release the write early - at the cost of waiting out `MAX_TRANSITION_START_WAIT_MS` + * if no screen transition starts. */ function createTransitionBarrier(waitFor: true | 'navigation' = true): WriteReadyBarrier { return (signal) => @@ -85,36 +74,55 @@ function createTransitionBarrier(waitFor: true | 'navigation' = true): WriteRead callback: () => resolve(), waitForUpcomingTransition: waitFor, }); - // On abort, drop the TransitionTracker registration. - // The promise is intentionally left pending because resolving or rejecting would execute the write that was cancelled via the signal + // Left pending on abort - resolving/rejecting here would execute the write the signal cancelled. signal.addEventListener('abort', () => handle.cancel()); }); } const waitForTransition = createTransitionBarrier(); +/** A transition barrier that was attached to `TransitionTracker` before the write it gates existed; `cancel` drops the registration if the write never happens. */ +type ArmedTransitionBarrier = { + barrier: WriteReadyBarrier; + cancel: () => void; +}; + /** - * Like `write()`, but deferred until a readiness signal (barrier) is fired. - * - Optimistic updates are intentionally deferred, as well as the API request itself. - * - Once ready, it delegates to the normal `write()` pipeline. - * - The write is executed after `safetyTimeoutMs`, regardless of whether the barrier promise has settled. + * Use when a caller navigates first and only builds the write afterward, once the navigation has + * already finished: attaching the default barrier that late would miss the transition and fall + * through to the safety timeout. Arm this instead when navigation triggers, then pass the resulting + * barrier to `writeWhenReady` once the write is built; one armed barrier can gate several writes from + * the same interaction (e.g. one per receipt in a split), releasing them together. + */ +function armTransitionBarrier(waitFor: true | 'navigation' = true): ArmedTransitionBarrier { + const armController = new AbortController(); + const armed = createTransitionBarrier(waitFor)(armController.signal); + + return { + barrier: () => armed, + cancel: () => armController.abort(), + }; +} + +/** + * Like `write()`, but both the optimistic update and the request are deferred until `barrier` settles + * (or `safetyTimeoutMs` elapses, whichever comes first), then delegates to the normal `write()` pipeline. * * Caveats: - * - A barrier rejection is NOT a cancellation: the write still executes (the rejection is logged with its error). - * The barrier only answers "when" to write, never "whether", so a broken readiness check cannot silently drop a - * user action - there would be no optimistic data, no request, and no `failureData` to roll back or surface. - * A write that should be conditional has to be gated before the call, not by rejecting the barrier. - * - This currently does not support the `conflictResolver` param of `write()`. - * Any request queued via `writeWhenReady` will not enter the sequential queue or be considered in conflict resolution until the deferred execution happens - * - Call order is NOT preserved across multiple deferred writes. If two independent `writeWhenReady` calls are queued, the two barriers race each other. - * If you need to preserve call order, it must be managed by callers: the later barrier must await resolution of the former. + * - A barrier rejection is not a cancellation: the write still executes (the rejection is only + * logged). The barrier answers "when" to write, never "whether" - gate a conditional write before + * the call, not by rejecting the barrier. + * - Does not support `write()`'s `conflictResolver`: a deferred request isn't in the sequential + * queue or considered for conflict resolution until it actually executes. + * - Call order isn't preserved across independent `writeWhenReady` calls - their barriers race. * * Caution: - * - By default, it waits for any transition to complete, so re-renders from optimistic writes don't compete with the transition animation. - * If no transition happens, then the write is deferred by roughly `CONST.MAX_TRANSITION_START_WAIT_MS + CONST.MAX_TRANSITION_DURATION_MS` (~2s). - * Pass `createTransitionBarrier('navigation')` to gate on a screen transition only, or a custom `barrier` to wait on something else entirely. - * - In the edge case where a user backgrounds or hard-kills the app before the write finishes, we make a best-effort to flush pending writes. - * This is not guaranteed to succeed, so critical writes (such as ones that move money) should not be deferred. + * - The default barrier waits for any transition (~2s worst case if none starts). Pass + * `createTransitionBarrier('navigation')` to gate on a screen transition only, or a custom barrier. + * - We best-effort flush pending writes when the app backgrounds, but there's no flush on a hard + * kill or crash - a deferred write can simply be lost. Don't defer writes where losing one would + * leave something unrecoverable or hard to reconcile; losing one that's merely annoying to redo + * is an acceptable risk. * * @returns A promise that resolves with the underlying `write()` result once the write executes. */ @@ -124,17 +132,20 @@ function writeWhenReady( apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData?: OnyxData, barrier?: WriteReadyBarrier, - safetyTimeoutMs?: number, + options?: number | WriteWhenReadyOptions, ): Promise>; function writeWhenReady( command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}, barrier: WriteReadyBarrier = waitForTransition, - safetyTimeoutMs: number = SAFETY_TIMEOUT_MS, + options: number | WriteWhenReadyOptions = {}, ): Promise> { Log.info('[API] Called API writeWhenReady', false, buildLogParams(command, apiCommandParameters ?? {})); + // A bare number for `options` is treated as `safetyTimeoutMs`. + const {safetyTimeoutMs = SAFETY_TIMEOUT_MS, onRelease, onWriteStarted} = typeof options === 'number' ? {safetyTimeoutMs: options} : options; + return new Promise((resolve, reject) => { let hasExecuted = false; let flushOnBackground: () => void = () => {}; @@ -151,9 +162,7 @@ function writeWhenReady( try { clearTimeout(safetyTimeoutID); pendingWrites.delete(flushOnBackground); - // Abort on the early-release paths so that pending barriers can cleanup if they desire. - // On the 'success'/'rejected' paths, the barrier has already settled, so there's nothing to release. - // Barriers whose cleanup can throw should handle it in their own abort listener. + // Abort only on early-release paths - on 'success'/'rejected' the barrier already settled. if (reason === 'safetyTimeout' || reason === 'appBackground') { abortController.abort(); } @@ -165,7 +174,24 @@ function writeWhenReady( }); } + // Isolated so onRelease can never block or fail the write. + try { + onRelease?.(reason); + } catch (error) { + Log.warn('[API] writeWhenReady onRelease threw', {command, error}); + } + write(command, apiCommandParameters, onyxData).then(resolve, reject); + + // Isolated so a throwing side effect can't be mistaken for a failed write. + try { + onWriteStarted?.(); + } catch (error) { + Log.warn('[API] writeWhenReady onWriteStarted threw', { + command, + error, + }); + } } catch (error) { reject(error); } @@ -174,8 +200,7 @@ function writeWhenReady( registerBackgroundFlushListener(); flushOnBackground = () => execute('appBackground'); - // The AppState listener only catches new background transitions, so an app that is already backgrounded - // when the write is queued (e.g. from a push notification handler) would never be flushed by it. + // The AppState listener only catches new transitions, so an app already in the background when the write is queued must flush here directly. if (AppState.currentState === CONST.APP_STATE.BACKGROUND) { execute('appBackground'); return; @@ -185,8 +210,7 @@ function writeWhenReady( safetyTimeoutID = setTimeout(() => execute('safetyTimeout'), Math.max(0, safetyTimeoutMs)); - // Invoke the barrier inside a try/catch so a synchronously-thrown error is funneled into the - // rejection path instead of escaping. + // Caught so a synchronous throw is funneled into the rejection path instead of escaping. let barrierValue: PromiseLike; try { barrierValue = barrier(abortController.signal); @@ -203,5 +227,5 @@ function writeWhenReady( }); } -export {writeWhenReady, createTransitionBarrier, SAFETY_TIMEOUT_MS}; -export type {WriteReadyBarrier}; +export {writeWhenReady, createTransitionBarrier, armTransitionBarrier, SAFETY_TIMEOUT_MS}; +export type {WriteReadyBarrier, WriteWhenReadyOptions}; diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index 68b8799a73b5..2c496ee487f2 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -1,3 +1,5 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; @@ -284,7 +286,108 @@ function getRequiredKYBDocuments(externalApiResponses: KYBVerificationResponses) return requiredDocuments; } +function isValidIBAN(value?: string): boolean { + return CONST.BANK_ACCOUNT.REGEX.IBAN.test((value ?? '').trim()); +} + +function isValidSwiftBic(value?: string): boolean { + return CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test((value ?? '').trim()); +} + +/** + * IBAN/SWIFT can come from the dedicated `iban`/`swiftCode` fields or from `accountNumber`/`swiftBicCode` on the + * Corpay bank-details step. IBAN must match the IBAN format in either place. Dedicated `swiftCode` must be a BIC; + * first-page `swiftBicCode` is already Corpay-validated, so non-empty is enough (it may not be a BIC). + */ +function hasValidInternationalBankAccountDetails(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string) { + return (isValidIBAN(iban) || isValidIBAN(accountNumber)) && (isValidSwiftBic(swiftCode) || !!swiftBicCode); +} + +/** + * Whether the Corpay account details step already collected a real IBAN and a SWIFT value. That is the only case + * where the dedicated international details step can be omitted. `accountNumber` must be an IBAN (it is often a local + * number). `swiftBicCode` is always the SWIFT field and is validated on that step, so non-empty is enough. Filled + * `iban`/`swiftCode` must not omit the step, or it disappears from the wizard after the user completes it. + */ +function hasValidAccountDetailsInternationalFields(accountNumber?: string, swiftBicCode?: string): boolean { + return isValidIBAN(accountNumber) && !!swiftBicCode; +} + +/** + * Resolves the IBAN/SWIFT values to display or submit, falling back to `accountNumber`/`swiftBicCode` when the + * dedicated `iban`/`swiftCode` fields weren't collected (e.g. the international bank account details step was + * skipped because the Corpay bank-details step already gathered equivalent values). + */ +function getInternationalBankAccountDetailsValues(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string): {iban: string; swiftCode: string} { + let resolvedIBAN = iban ?? ''; + if (!resolvedIBAN && isValidIBAN(accountNumber)) { + resolvedIBAN = accountNumber ?? ''; + } + + let resolvedSwiftCode = swiftCode ?? ''; + if (!resolvedSwiftCode && swiftBicCode) { + resolvedSwiftCode = swiftBicCode; + } + + // `swiftBicCode` is always the SWIFT field and is already validated on the account details step, so a non-empty + // value can be copied as-is. `accountNumber` is not always an IBAN, so that fallback still uses isValidIBAN. + return { + iban: resolvedIBAN, + swiftCode: resolvedSwiftCode, + }; +} + +/** + * Locks the international-details IBAN or SWIFT/BIC input when the initial bank details page already collected + * that value, so the user cannot submit a second conflicting copy. + */ +function getDisabledInternationalBankAccountFields(accountNumber?: string, swiftBicCode?: string): {isIBANDisabled: boolean; isSwiftCodeDisabled: boolean} { + return { + isIBANDisabled: isValidIBAN(accountNumber), + isSwiftCodeDisabled: !!swiftBicCode, + }; +} + +/** + * Confirmation should list IBAN/SWIFT from the international details step only when the user entered them there. + * Prefill comes from `accountNumber` (IBAN countries) or `swiftBicCode`, which are already shown on the initial + * bank details fields, so a matching value is hidden. + */ +function shouldShowInternationalDetailOnConfirmation(value: string | undefined, sourceValue?: string): boolean { + const trimmed = value?.trim(); + if (!trimmed) { + return false; + } + return trimmed !== sourceValue?.trim(); +} + +/** + * Shared IBAN/SWIFT format validation for the international bank account details step, used by both the USD and + * international personal bank account flows. + */ +function getInternationalBankAccountDetailsErrors( + iban: string | undefined, + swiftCode: string | undefined, + translate: LocaleContextProps['translate'], + isSwiftCodeDisabled = false, +): Partial> { + const errors: Partial> = {}; + if (iban && !isValidIBAN(iban)) { + errors.iban = translate('bankAccount.error.iban'); + } + if (swiftCode && !isSwiftCodeDisabled && !isValidSwiftBic(swiftCode)) { + errors.swiftCode = translate('bankAccount.error.swiftCode'); + } + return errors; +} + export { + hasValidInternationalBankAccountDetails, + hasValidAccountDetailsInternationalFields, + getInternationalBankAccountDetailsValues, + getDisabledInternationalBankAccountFields, + shouldShowInternationalDetailOnConfirmation, + getInternationalBankAccountDetailsErrors, getBankAccountSearchLabel, isFilterableBankAccount, getDefaultCompanyWebsite, diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 0f523f1ce5ef..7f5dabb2a0d6 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1763,14 +1763,18 @@ function getFundIdFromSettingsKey(key: string) { return Number.isNaN(fundID) ? CONST.DEFAULT_NUMBER_ID : fundID; } +function getCardFeedWithoutDomainID(feedWithDomainID: string): string { + const [feed] = feedWithDomainID.split(CONST.COMPANY_CARD.FEED_KEY_SEPARATOR); + return feed; +} + /** Extract feed from feed with domainID */ function getCompanyCardFeed(feedWithDomainID: CardFeedWithNumber | CardFeedWithDomainID | undefined): CompanyCardFeedWithNumber { if (!feedWithDomainID) { return '' as CompanyCardFeedWithNumber; } - const [feed] = feedWithDomainID.split(CONST.COMPANY_CARD.FEED_KEY_SEPARATOR); - return feed as CompanyCardFeedWithNumber; + return getCardFeedWithoutDomainID(feedWithDomainID) as CompanyCardFeedWithNumber; } /** @@ -2248,6 +2252,7 @@ export { isDirectFeed, feedHasCards, getOriginalCompanyFeeds, + getCardFeedWithoutDomainID, getCompanyCardFeed, getCardFeedWithDomainID, getCompanyCardFeedWithDomainIDForCard, diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 7b5242db9833..fd48f679418b 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -185,6 +185,7 @@ function datetimeToCalendarTime(locale: Locale | undefined, datetime: string, cu let tomorrowAt = translateLocalize(locale, 'common.tomorrowAt'); let yesterdayAt = translateLocalize(locale, 'common.yesterdayAt'); const at = translateLocalize(locale, 'common.conjunctionAt'); + const translate: LocalizedTranslate = (path, ...params) => translateLocalize(locale, path, ...params); const weekStartsOn = getWeekStartsOn(); const startOfCurrentWeek = startOfWeek(new Date(), {weekStartsOn}); @@ -197,18 +198,18 @@ function datetimeToCalendarTime(locale: Locale | undefined, datetime: string, cu } if (isToday(date, currentSelectedTimezone)) { - return `${todayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; + return `${todayAt} ${formatTimeWithPeriod(translate, date)}${tz}`; } if (isTomorrow(date, currentSelectedTimezone)) { - return `${tomorrowAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; + return `${tomorrowAt} ${formatTimeWithPeriod(translate, date)}${tz}`; } if (isYesterday(date, currentSelectedTimezone)) { - return `${yesterdayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; + return `${yesterdayAt} ${formatTimeWithPeriod(translate, date)}${tz}`; } if (date >= startOfCurrentWeek && date <= endOfCurrentWeek) { - return `${format(date, CONST.DATE.MONTH_DAY_ABBR_FORMAT, {locale: dateFnsLocale})} ${at} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; + return `${format(date, CONST.DATE.MONTH_DAY_ABBR_FORMAT, {locale: dateFnsLocale})} ${at} ${formatTimeWithPeriod(translate, date)}${tz}`; } - return `${format(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, {locale: dateFnsLocale})} ${at} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; + return `${format(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, {locale: dateFnsLocale})} ${at} ${formatTimeWithPeriod(translate, date)}${tz}`; } /** @@ -298,8 +299,8 @@ function formatToDayOfWeek(datetime: Date, dateFnsLocale: DateFnsLocale | undefi * * @returns 2:30 PM */ -function formatToLocalTime(datetime: string | Date, dateFnsLocale: DateFnsLocale | undefined): string { - return format(new Date(datetime), CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale}); +function formatToLocalTime(translate: LocalizedTranslate, datetime: string | Date): string { + return formatTimeWithPeriod(translate, new Date(datetime)); } const THREE_HOURS = 1000 * 60 * 60 * 3; @@ -494,16 +495,43 @@ function extractTime12Hour(dateTimeString: string, isFullFormat = false): string return format(date, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a'); } +/** + * param {number} hours - 0-23, in the timezone the time is shown in + * returns {string} example: PM + */ +function getTimePeriodLabel(translate: LocalizedTranslate, hours: number): string { + return translate(hours >= 12 ? 'common.pm' : 'common.am'); +} + +/** + * param {string} timeFormat - a date-fns pattern with no meridiem token + * returns {string} example: 11:10 PM + */ +function formatTimeWithPeriod(translate: LocalizedTranslate, date: Date, timeFormat: string = CONST.DATE.LOCAL_TIME_FORMAT_WITHOUT_PERIOD): string { + return `${format(date, timeFormat)} ${getTimePeriodLabel(translate, date.getHours())}`; +} + +/** + * param {string} dateTimeString + * returns {string} example: 11:10 PM + */ +function getTime12HourWithTranslatedPeriod(translate: LocalizedTranslate, dateTimeString: string): string { + if (!dateTimeString || dateTimeString === 'never') { + return ''; + } + return formatTimeWithPeriod(translate, new Date(dateTimeString), CONST.DATE.TIME_FORMAT_WITHOUT_PERIOD); +} + /** * param {string} dateTimeString * returns {string} example: 2023-05-16 11:10 PM */ -function formatDateTimeTo12Hour(dateTimeString: string, dateFnsLocale: DateFnsLocale | undefined): string { +function formatDateTimeTo12Hour(translate: LocalizedTranslate, dateTimeString: string): string { if (!dateTimeString) { return ''; } const date = new Date(dateTimeString); - return format(date, 'yyyy-MM-dd hh:mm a', {locale: dateFnsLocale}); + return `${format(date, CONST.DATE.FNS_FORMAT_STRING)} ${formatTimeWithPeriod(translate, date, CONST.DATE.TIME_FORMAT_WITHOUT_PERIOD)}`; } /** @@ -539,7 +567,7 @@ function getLocalizedTimePeriodDescription(translate: LocalizedTranslate, dateFn case '': return translate('statusPage.timePeriods.never'); default: - return formatDateTimeTo12Hour(data, dateFnsLocale); + return formatDateTimeTo12Hour(translate, data); } } @@ -573,16 +601,16 @@ function getStatusUntilDate( // If it's a time on the same date if (isSameDay(input, now)) { - return translate('statusPage.untilTime', format(input, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})); + return translate('statusPage.untilTime', formatTimeWithPeriod(translate, input)); } // If it's further in the future than tomorrow but within the same year if (isAfter(input, now) && isSameYear(input, now)) { - return translate('statusPage.untilTime', format(input, `${CONST.DATE.SHORT_DATE_FORMAT} ${CONST.DATE.LOCAL_TIME_FORMAT}`, {locale: dateFnsLocale})); + return translate('statusPage.untilTime', `${format(input, CONST.DATE.SHORT_DATE_FORMAT)} ${formatTimeWithPeriod(translate, input)}`); } // If it's in another year - return translate('statusPage.untilTime', format(input, `${CONST.DATE.FNS_FORMAT_STRING} ${CONST.DATE.LOCAL_TIME_FORMAT}`, {locale: dateFnsLocale})); + return translate('statusPage.untilTime', `${format(input, CONST.DATE.FNS_FORMAT_STRING)} ${formatTimeWithPeriod(translate, input)}`); } /** @@ -867,10 +895,11 @@ function getFormattedReservationRangeDate(translate: LocalizedTranslate, dateFns * 2. When the date refers not to the current year: Departs on Wednesday, Mar 17, 2023 at 8:00. */ function getFormattedTransportDate(translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, date: Date): string { + const time = formatTimeWithPeriod(translate, date, CONST.DATE.TIME_FORMAT_WITHOUT_PERIOD); if (isThisYear(date)) { - return `${translate('travel.departs')} ${format(date, 'EEEE, MMM d', {locale: dateFnsLocale})} ${translate('common.conjunctionAt')} ${format(date, 'hh:mm a', {locale: dateFnsLocale})}`; + return `${translate('travel.departs')} ${format(date, 'EEEE, MMM d', {locale: dateFnsLocale})} ${translate('common.conjunctionAt')} ${time}`; } - return `${translate('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy', {locale: dateFnsLocale})} ${translate('common.conjunctionAt')} ${format(date, 'hh:mm a', {locale: dateFnsLocale})}`; + return `${translate('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy', {locale: dateFnsLocale})} ${translate('common.conjunctionAt')} ${time}`; } /** @@ -879,16 +908,17 @@ function getFormattedTransportDate(translate: LocalizedTranslate, dateFnsLocale: * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM */ -function getFormattedTransportDateAndHour(date: Date, dateFnsLocale: DateFnsLocale | undefined): {date: string; hour: string} { +function getFormattedTransportDateAndHour(translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, date: Date): {date: string; hour: string} { + const hour = formatTimeWithPeriod(translate, date); if (isThisYear(date)) { return { date: format(date, 'EEEE, MMM d', {locale: dateFnsLocale}), - hour: format(date, 'h:mm a', {locale: dateFnsLocale}), + hour, }; } return { date: format(date, 'EEEE, MMM d, yyyy', {locale: dateFnsLocale}), - hour: format(date, 'h:mm a', {locale: dateFnsLocale}), + hour, }; } @@ -915,17 +945,19 @@ function getCancellationDateTimezoneLabel(venueTimezone: string): string { * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM, GMT+7 * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM, GMT+7 */ -function getFormattedCancellationDate(isoDateString: string, dateFnsLocale: DateFnsLocale | undefined): string { +function getFormattedCancellationDate(translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, isoDateString: string): string { if (!isoDateString) { return ''; } const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/); const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC'; const date = new Date(isoDateString); - const pattern = isThisYear(date) ? 'EEEE, MMM d h:mm a' : 'EEEE, MMM d, yyyy h:mm a'; + const datePattern = isThisYear(date) ? 'EEEE, MMM d' : 'EEEE, MMM d, yyyy'; + // The marker follows the venue's own hour, not the reader's. + const time = `${formatInTimeZone(date, venueTimezone, CONST.DATE.LOCAL_TIME_FORMAT_WITHOUT_PERIOD)} ${getTimePeriodLabel(translate, Number(formatInTimeZone(date, venueTimezone, 'H')))}`; // `formatInTimeZone`'s `zzz` token relies on `Intl.DateTimeFormat`, which rejects raw offset strings like // `+07:00`, so the timezone label is derived from the offset and appended manually. - return `${formatInTimeZone(date, venueTimezone, pattern, {locale: dateFnsLocale})}, ${getCancellationDateTimezoneLabel(venueTimezone)}`; + return `${formatInTimeZone(date, venueTimezone, datePattern, {locale: dateFnsLocale})} ${time}, ${getCancellationDateTimezoneLabel(venueTimezone)}`; } /** @@ -1035,6 +1067,20 @@ const formatInTimeZoneWithFallback: typeof formatInTimeZone = (date, timeZone, f } }; +/** + * param {SelectedTimezone} timeZone - also decides the marker + * returns {string} example: 11:10 PM + */ +function formatTimeInTimeZoneWithPeriod( + translate: LocalizedTranslate, + date: string | Date, + timeZone: SelectedTimezone, + timeFormat: string = CONST.DATE.LOCAL_TIME_FORMAT_WITHOUT_PERIOD, +): string { + const hours = Number(formatInTimeZoneWithFallback(date, timeZone, 'H')); + return `${formatInTimeZoneWithFallback(date, timeZone, timeFormat)} ${getTimePeriodLabel(translate, hours)}`; +} + /** * Converts a UTC datetime string to a date string (yyyy-MM-dd) in the target timezone. * @param utcDateTime - Datetime string in UTC format (yyyy-MM-dd HH:mm:ss or yyyy-MM-dd HH:mm:ss.SSS) @@ -1142,6 +1188,16 @@ function isDateStringInMonth(dateString: string, year: number, month: number): b return datePart >= monthStart && datePart <= monthEnd; } +/** Returns a month label, e.g. "September 2025". */ +function getFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { + return format(new Date(year, month - 1, 1), 'LLLL yyyy', {locale: dateFnsLocale}); +} + +/** Returns a compact month label, e.g. "Sep ’25". */ +function getShortFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { + return format(new Date(year, month - 1, 1), 'LLL ’yy', {locale: dateFnsLocale}); +} + /** * Returns a formatted date range. */ @@ -1157,6 +1213,17 @@ function getFormattedDateRangeForSearch(startDate: string, endDate: string, date return `${format(start, 'MMM d', {locale: dateFnsLocale})} - ${format(end, 'MMM d, yyyy', {locale: dateFnsLocale})}`; } +/** Returns a compact date range, e.g. "Sep 1 - 7, ’25". */ +function getShortFormattedDateRangeForSearch(startDate: string, endDate: string, dateFnsLocale: DateFnsLocale | undefined): string { + const start = parse(startDate, 'yyyy-MM-dd', new Date()); + const end = parse(endDate, 'yyyy-MM-dd', new Date()); + if (!isSameYear(start, end)) { + return `${format(start, 'MMM d, ’yy', {locale: dateFnsLocale})} - ${format(end, 'MMM d, ’yy', {locale: dateFnsLocale})}`; + } + const formattedEnd = isSameMonth(start, end) ? format(end, 'd, ’yy', {locale: dateFnsLocale}) : format(end, 'MMM d, ’yy', {locale: dateFnsLocale}); + return `${format(start, 'MMM d', {locale: dateFnsLocale})} - ${formattedEnd}`; +} + function getYearDateRange(year: number): {start: string; end: string} { return { start: `${year}-01-01`, @@ -1187,6 +1254,13 @@ function getFormattedQuarterForSearch(year: number, quarter: number, dateFnsLoca return `Q${quarter} ${year} (${format(quarterStart, 'MMM d', {locale: dateFnsLocale})} - ${format(quarterEnd, 'MMM d', {locale: dateFnsLocale})})`; } +/** + * Returns a compact quarter label, e.g. "Q3 ’25". + */ +function getShortFormattedQuarterForSearch(year: number, quarter: number): string { + return `Q${quarter} ${format(new Date(year, 0, 1), '’yy')}`; +} + function getNextNthOfMonth(nth: number) { const now = new Date(); const year = now.getFullYear(); @@ -1230,6 +1304,10 @@ const DateUtils = { extractDate, getStatusUntilDate, extractTime12Hour, + getTime12HourWithTranslatedPeriod, + getTimePeriodLabel, + formatTimeWithPeriod, + formatTimeInTimeZoneWithPeriod, formatDateTimeTo12Hour, get12HourTimeObjectFromDate, getLocalizedTimePeriodDescription, @@ -1272,10 +1350,14 @@ const DateUtils = { getMonthDateRange, getWeekDateRange, isDateStringInMonth, + getFormattedMonthForSearch, + getShortFormattedMonthForSearch, getFormattedDateRangeForSearch, + getShortFormattedDateRangeForSearch, getYearDateRange, getQuarterDateRange, getFormattedQuarterForSearch, + getShortFormattedQuarterForSearch, getNextNthOfMonth, }; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index d591c69b57c5..182382b77192 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -2,6 +2,7 @@ import type {CurrencyListActionsContextType} from '@components/CurrencyListConte import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; +import type {IOURequestType} from '@src/CONST'; import type {LastSelectedDistanceRates, OnyxInputOrEntry, Transaction} from '@src/types/onyx'; import type DefaultP2PMileageRate from '@src/types/onyx/DefaultP2PMileageRate'; import type {Unit} from '@src/types/onyx/Policy'; @@ -336,6 +337,35 @@ function getCommuterExclusionDisplayData(customUnit: TransactionCustomUnit | und }; } +/** + * Whether a workspace's commuter exclusion applies to a distance expense of this request type. + * + * Only a distance the app itself measured describes a route the workspace can recognize a commute in, so a manually + * entered or odometer distance is reimbursed in full. + */ +function isCommuterExclusionApplicableToRequestType(iouRequestType: IOURequestType | undefined): boolean { + return iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL && iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER; +} + +/** + * Returns the distance a workspace excludes from a distance of `distance` units, expressed in that same unit. + * + * Returns 0 when the workspace excludes nothing, so callers can treat it as "no exclusion applies". The exclusion never + * exceeds the distance itself, which is what keeps a reimbursable distance from going negative. + */ +function getPolicyCommuterExclusionForDistance(policy: OnyxEntry, distance: number, distanceUnit: Unit): number { + const commuterExclusions = policy?.commuterExclusions; + if (commuterExclusions?.method !== CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE) { + return 0; + } + + const fixedDistanceUnit: Unit = + commuterExclusions.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const fixedDistanceInRequestUnit = convertDistanceUnit(convertToDistanceInMeters(commuterExclusions.fixedDistance ?? 0, fixedDistanceUnit), distanceUnit); + + return Math.max(0, Math.min(fixedDistanceInRequestUnit, distance)); +} + function getTransactionCommuterExclusionData({ transaction, policy, @@ -355,11 +385,10 @@ function getTransactionCommuterExclusionData({ getCurrencySymbol?: CurrencyListActionsContextType['getCurrencySymbol']; personalPolicyOutputCurrency?: string; }): (Pick & {modifiedAmount: number; customUnit: TransactionCustomUnit}) | undefined { - if (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { + if (!isCommuterExclusionApplicableToRequestType(transaction?.iouRequestType)) { return; } - const policyCommuterExclusions = policy?.commuterExclusions; const existingCustomUnit = customUnit ?? transaction?.comment?.customUnit; const selectedRate = existingCustomUnit?.customUnitRateID ? (getRateByCustomUnitRateID({customUnitRateID: existingCustomUnit.customUnitRateID, policy}) ?? getRate({transaction, policy, personalPolicyOutputCurrency})) @@ -385,23 +414,17 @@ function getTransactionCommuterExclusionData({ // Preserve the commuter exclusion stored on the expense at creation time; fall back to the current // policy setting only when there is no stored exclusion (i.e. a brand-new expense being created). const storedCommuterExclusion = storedCustomUnit?.commuterExclusion; - let fixedDistanceInRequestUnit: number; + let commuterExclusion: number; if (typeof storedCommuterExclusion === 'number' && storedCommuterExclusion > 0) { - fixedDistanceInRequestUnit = convertDistanceUnit(convertToDistanceInMeters(storedCommuterExclusion, storedCustomUnit?.distanceUnit ?? requestDistanceUnit), requestDistanceUnit); + const storedExclusionInRequestUnit = convertDistanceUnit( + convertToDistanceInMeters(storedCommuterExclusion, storedCustomUnit?.distanceUnit ?? requestDistanceUnit), + requestDistanceUnit, + ); + commuterExclusion = Math.max(0, Math.min(storedExclusionInRequestUnit, routeDistance)); } else { - if (policyCommuterExclusions?.method !== CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE) { - return; - } - const fixedDistanceUnit: Unit = - policyCommuterExclusions.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - fixedDistanceInRequestUnit = convertDistanceUnit(convertToDistanceInMeters(policyCommuterExclusions.fixedDistance ?? 0, fixedDistanceUnit), requestDistanceUnit); - } - - if (fixedDistanceInRequestUnit <= 0) { - return; + commuterExclusion = getPolicyCommuterExclusionForDistance(policy, routeDistance, requestDistanceUnit); } - const commuterExclusion = Math.min(fixedDistanceInRequestUnit, routeDistance); if (commuterExclusion <= 0) { return; } @@ -861,6 +884,8 @@ export default { getDistanceMerchant, getDistanceRequestAmount, getCommuterExclusionDisplayData, + getPolicyCommuterExclusionForDistance, + isCommuterExclusionApplicableToRequestType, getTransactionCommuterExclusionData, getDistanceDisplayDetailsWithCommuter, getFormattedRateValue, diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index cd7759e75d23..9722be1c9bf3 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -314,6 +314,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, + ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE, @@ -386,6 +387,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING, + ONYXKEYS.COLLECTION.RULE, ONYXKEYS.COLLECTION.SAML_METADATA, ONYXKEYS.COLLECTION.SECURITY_GROUP, ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT, @@ -401,6 +403,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT, ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT, ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT, + ONYXKEYS.CONCIERGE_PROMPT_DRAFT, ONYXKEYS.CORPAY_FIELDS, ONYXKEYS.CORPAY_ONBOARDING_FIELDS, ONYXKEYS.COUNTRY, diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 146408b3ec74..fbad088f740a 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -2,12 +2,14 @@ import type {CurrencyListActionsContextType} from '@components/CurrencyListConte import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; +import type {IOURequestType} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MergeTransaction, Policy, Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion} from 'type-fest'; import {SafeString} from 'expensify-common'; @@ -17,6 +19,7 @@ import type {TransactionDetails} from './ReportUtils'; import {getDecodedLeafCategoryName} from './CategoryUtils'; import {convertToBackendAmount} from './CurrencyUtils'; +import DistanceRequestUtils from './DistanceRequestUtils'; import {getAllNonDeletedTransactions} from './MoneyRequestReportUtils'; import Parser from './Parser'; import {getCommaSeparatedTagNameWithSanitizedColons} from './PolicyUtils'; @@ -48,6 +51,9 @@ import { // Define the specific merge fields we want to handle const MERGE_FIELDS = ['amount', 'merchant', 'created', 'category', 'tag', 'description', 'taxValue', 'reimbursable', 'billable', 'attendees', 'reportID'] as const; +const COMMUTER_EXCLUSION_CUSTOM_UNIT_KEYS = ['commuterExclusion', 'reimbursableDistance', 'commuterExclusionType', 'commuterExclusionMethod'] as const; +/** The custom unit's commuter exclusion fields, where null clears the stored value through Onyx.merge */ +type CommuterExclusionCustomUnitUpdate = {[Key in TupleToUnion]?: TransactionCustomUnit[Key] | null}; // Some fields are dependant on others. We need to automatically derive the correct field values depending on user selection. const DERIVED_MERGE_FIELDS = [...MERGE_FIELDS, 'taxCode', 'taxAmount'] as const; type MergeFieldKey = TupleToUnion; @@ -65,7 +71,7 @@ type MergeFieldData = { }; /** Type for merge transaction values that can be null to clear existing values in Onyx */ -type MergeTransactionUpdateValues = Partial>; +type MergeTransactionUpdateValues = Partial | null>>; const MERGE_FIELD_TRANSLATION_KEYS = { amount: 'iou.amount', @@ -231,6 +237,8 @@ function getMergeableDataAndConflictFields( ) { const conflictFields: string[] = []; const mergeableData: Record = {}; + // The same object, typed for the field builders that read the values merged so far + const mergedSoFar = mergeableData as MergeTransaction; // Resolve the report-owner fallback the same way the display path (buildMergeFieldsData) does, so an expense // with no stored attendee is compared as [owner] instead of [] and doesn't produce a false attendee conflict @@ -283,7 +291,16 @@ function getMergeableDataAndConflictFields( // We allow user to select unreported report if (field === 'reportID') { if (targetValue === sourceValue) { - const updatedValues = getMergeFieldUpdatedValues({transaction: targetTransaction, field, fieldValue: SafeString(targetValue), getCurrencyDecimals, searchReports}); + const updatedValues = getMergeFieldUpdatedValues({ + transaction: targetTransaction, + field, + fieldValue: SafeString(targetValue), + getCurrencyDecimals, + mergeTransaction: mergedSoFar, + searchReports, + // Both expenses share the report, so the merged expense stays on that report's workspace + destinationPolicy: targetTransactionPolicy, + }); Object.assign(mergeableData, updatedValues); } else { conflictFields.push(field); @@ -319,9 +336,11 @@ function getMergeableDataAndConflictFields( field, fieldValue: selectedFieldValue as MergeTransaction[typeof field], getCurrencyDecimals, - mergeTransaction: mergeableData as MergeTransaction, + mergeTransaction: mergedSoFar, searchReports, policy: selectedPolicy, + // Nothing conflicts, so the merged expense stays on the report it is already on + destinationPolicy: selectedPolicy, }); Object.assign(mergeableData, updatedValues); } else { @@ -683,8 +702,83 @@ type GetMergeFieldUpdatedValuesParams = { mergeTransaction?: OnyxEntry; searchReports?: Array>; policy?: OnyxEntry; + + /** + * Workspace of the report the merged expense will live on, which is the one whose rules apply to it. Required so + * that an expense with no workspace, which no rule applies to, has to be passed as undefined rather than omitted. + */ + destinationPolicy: OnyxEntry; }; +/** + * Build the custom unit's commuter exclusion for a merge selection. + * + * A commuter exclusion belongs to the workspace the surviving expense ends up on, and always describes the distance + * that is selected. Selections are stored with Onyx.merge, which deep merges the custom unit, so both the exclusion of + * a workspace that is no longer the destination and the reimbursable distance of a previously selected merchant would + * otherwise survive. The distance field then shows the reimbursable distance in place of the full distance. + * + * `iouRequestType` is the type the merged expense ends up with, so that merging reaches the same exclusion that + * creating the expense on the destination workspace would. + */ +function getCommuterExclusionCustomUnitUpdate( + selectedCustomUnit: TransactionCustomUnit | undefined, + previousCustomUnit: TransactionCustomUnit | undefined, + destinationPolicy: OnyxEntry, + iouRequestType: IOURequestType | undefined, +): CommuterExclusionCustomUnitUpdate { + const quantity = selectedCustomUnit?.quantity ?? previousCustomUnit?.quantity; + const distanceUnit = selectedCustomUnit?.distanceUnit ?? previousCustomUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + // The destination workspace's setting decides the exclusion, so it applies to an expense that arrives from a + // workspace excluding nothing, and stops applying to one that leaves a workspace that excludes a distance + const commuterExclusion = + typeof quantity === 'number' && DistanceRequestUtils.isCommuterExclusionApplicableToRequestType(iouRequestType) + ? DistanceRequestUtils.getPolicyCommuterExclusionForDistance(destinationPolicy, quantity, distanceUnit) + : 0; + + if (commuterExclusion > 0 && typeof quantity === 'number') { + return { + commuterExclusion, + reimbursableDistance: Math.max(0, quantity - commuterExclusion), + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }; + } + + // Null the exclusion keys that hold a value so Onyx removes them, leaving the ones that were never set out of the update + const clearedUpdate: CommuterExclusionCustomUnitUpdate = {}; + for (const key of COMMUTER_EXCLUSION_CUSTOM_UNIT_KEYS) { + if (selectedCustomUnit?.[key] === undefined && previousCustomUnit?.[key] === undefined) { + continue; + } + clearedUpdate[key] = null; + } + + return clearedUpdate; +} + +/** + * Scales an amount that pays for `billedDistance` so that it pays for `newBilledDistance` instead, keeping a merged + * distance expense's amount on the distance its workspace reimburses once a commuter exclusion is applied or dropped. + */ +function getAmountForBilledDistance(amount: number, billedDistance: number | undefined, newBilledDistance: number | undefined): number { + if (!amount || !billedDistance || newBilledDistance === undefined || billedDistance === newBilledDistance) { + return amount; + } + + return Math.round((amount / billedDistance) * newBilledDistance); +} + +/** + * The distance an amount pays for: the reimbursable distance when a commuter exclusion applies, otherwise the whole distance. + */ +function getBilledDistance(quantity: number | null | undefined, reimbursableDistance: number | null | undefined): number | undefined { + if (typeof reimbursableDistance === 'number') { + return reimbursableDistance; + } + + return typeof quantity === 'number' ? quantity : undefined; +} + /** * Build updated values for merge transaction field selection * Handles special cases like currency for amount field, report name, tax value and additional fields for distance requests @@ -697,6 +791,7 @@ function getMergeFieldUpdatedValues({ mergeTransaction, searchReports, policy, + destinationPolicy, }: GetMergeFieldUpdatedValuesParams): MergeTransactionUpdateValues { const updatedValues: MergeTransactionUpdateValues = { [field]: fieldValue, @@ -713,13 +808,48 @@ function getMergeFieldUpdatedValues({ if (field === 'reportID') { const reportName = transaction?.reportName?.length ? transaction?.reportName : getReportName(getReportOrDraftReport(getReportIDForExpense(transaction), searchReports)); updatedValues.reportName = reportName.length ? reportName : null; + + // Moving the expense to another workspace changes whether that workspace excludes commuter distance from it, + // and the amount has to follow the distance that is left to reimburse + if (isDistanceRequest(transaction)) { + const previousCustomUnit = mergeTransaction?.customUnit; + // The merchant selection is what sets the merged expense's type, so it is preferred over the type of the + // expense whose report was selected here + const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate( + undefined, + previousCustomUnit, + destinationPolicy, + mergeTransaction?.iouRequestType ?? transaction?.iouRequestType, + ); + if (Object.keys(commuterExclusionUpdate).length > 0) { + updatedValues.customUnit = {...previousCustomUnit, ...commuterExclusionUpdate}; + } + if (typeof mergeTransaction?.amount === 'number') { + updatedValues.amount = getAmountForBilledDistance( + mergeTransaction.amount, + getBilledDistance(previousCustomUnit?.quantity, previousCustomUnit?.reimbursableDistance), + getBilledDistance(previousCustomUnit?.quantity, commuterExclusionUpdate.reimbursableDistance), + ); + } + } } if (field === 'merchant' && isDistanceRequest(transaction)) { const transactionDetails = getTransactionDetails(transaction); - updatedValues.amount = getMergeFieldValue(transactionDetails, transaction, 'amount') as number; updatedValues.currency = getCurrency(transaction); - updatedValues.customUnit = transaction?.comment?.customUnit; + const selectedCustomUnit = transaction?.comment?.customUnit; + const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate(selectedCustomUnit, mergeTransaction?.customUnit, destinationPolicy, transaction?.iouRequestType); + updatedValues.customUnit = { + ...selectedCustomUnit, + ...commuterExclusionUpdate, + }; + // The selected expense's amount pays for the distance its own workspace reimbursed, so it is re-scaled to the + // distance the destination workspace reimburses + updatedValues.amount = getAmountForBilledDistance( + getMergeFieldValue(transactionDetails, transaction, 'amount') as number, + getBilledDistance(selectedCustomUnit?.quantity, selectedCustomUnit?.reimbursableDistance), + getBilledDistance(selectedCustomUnit?.quantity, commuterExclusionUpdate.reimbursableDistance), + ); updatedValues.iouRequestType = transaction?.iouRequestType; // For manual distance requests, set waypoints/routes and receipt to null to clear any existing values updatedValues.receipt = transaction?.receipt ?? null; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index efc70173d067..21717bdfdd51 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -95,7 +95,7 @@ const OPTIONS_PER_SCREEN: Partial [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: { animation: Animations.NONE, }, - [SCREENS.TRAVEL.VERIFY_ACCOUNT]: { + [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: { animationTypeForReplace: 'push', }, [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: { @@ -193,9 +193,9 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/WorkspaceCategoriesPage').default, [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT]: () => require('../../../../pages/workspace/tags/WorkspaceTagsPage').default, [SCREENS.MONEY_REQUEST.DYNAMIC_EDIT_REPORT]: () => require('../../../../pages/iou/request/step/DynamicIOURequestEditReport').default, - [SCREENS.MONEY_REQUEST.STEP_SCAN]: () => require('../../../../pages/iou/request/step/IOURequestStepScan').default, + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN]: () => require('../../../../pages/iou/request/step/IOURequestStepScan').default, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_TAG]: () => require('../../../../pages/iou/request/step/DynamicIOURequestStepTag').default, - [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: () => require('../../../../pages/iou/request/step/IOURequestStepWaypoint').default, + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_WAYPOINT]: () => require('../../../../pages/iou/request/step/DynamicIOURequestStepWaypoint').default, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SEND_FROM]: () => require('../../../../pages/iou/request/step/DynamicIOURequestStepSendFrom').default, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_REPORT]: () => require('../../../../pages/iou/request/step/DynamicIOURequestStepReport').default, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_COMPANY_INFO]: () => require('../../../../pages/iou/request/step/DynamicIOURequestStepCompanyInfo').default, @@ -217,7 +217,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/DynamicSplitExpensePage').default, [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_SEARCH]: () => require('../../../../pages/iou/DynamicSplitExpensePage').default, [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_CREATE_DATE_RANGE]: () => require('../../../../pages/iou/DynamicSplitExpenseCreateDateRangePage').default, - [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: () => require('../../../../pages/iou/SplitExpenseEditPage').default, + [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT]: () => require('../../../../pages/iou/DynamicSplitExpenseEditPage').default, [SCREENS.MONEY_REQUEST.DISTANCE_CREATE]: () => require('../../../../pages/iou/request/DistanceRequestStartPage').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MAP]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceMap').default, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DISTANCE_MANUAL]: () => require('../../../../pages/iou/request/step/DynamicIOURequestStepDistanceManual').default, @@ -235,12 +235,12 @@ const TravelModalStackNavigator = createModalStackNavigator require('../../../../pages/Travel/MyTripsPage').default, [SCREENS.TRAVEL.TRAVEL_DOT_LINK_WEB_VIEW]: () => require('../../../../pages/Travel/TravelDotLinkWebview').default, [SCREENS.TRAVEL.DYNAMIC_UPGRADE]: () => require('../../../../pages/Travel/DynamicTravelUpgrade').default, - [SCREENS.TRAVEL.TRIP_SUMMARY]: () => require('../../../../pages/Travel/TripSummaryPage').default, - [SCREENS.TRAVEL.TRIP_DETAILS]: () => require('../../../../pages/Travel/TripDetailsPage').default, + [SCREENS.TRAVEL.DYNAMIC_TRIP_SUMMARY]: () => require('../../../../pages/Travel/DynamicTripSummaryPage').default, + [SCREENS.TRAVEL.DYNAMIC_TRIP_DETAILS]: () => require('../../../../pages/Travel/DynamicTripDetailsPage').default, [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: () => require('../../../../pages/Travel/DynamicDomainPermissionInfoPage').default, [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: () => require('../../../../pages/Travel/DynamicPublicDomainErrorPage').default, - [SCREENS.TRAVEL.WORKSPACE_CONFIRMATION]: () => require('../../../../pages/Travel/WorkspaceConfirmationForTravelPage').default, - [SCREENS.TRAVEL.VERIFY_ACCOUNT]: () => require('../../../../pages/Travel/VerifyAccountPage').default, + [SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION]: () => require('../../../../pages/Travel/DynamicWorkspaceConfirmationForTravelPage').default, + [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: () => require('../../../../pages/Travel/DynamicVerifyAccountPage').default, [SCREENS.TRAVEL.ENABLE]: () => require('../../../../pages/Travel/EnableTravel').default, }); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts index a1e6e23c1410..12cbcc54ebe5 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts @@ -1,6 +1,7 @@ -import {useWideRHPState} from '@components/WideRHPContextProvider'; +import {animatedSuperWideRHPWidth, useWideRHPState} from '@components/WideRHPContextProvider'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSidePanelState from '@hooks/useSidePanelState'; import useThemeStyles from '@hooks/useThemeStyles'; import enhanceCardStyleInterpolator from '@libs/Navigation/AppNavigator/enhanceCardStyleInterpolator'; @@ -16,6 +17,9 @@ import type {StackCardStyleInterpolator} from '@react-navigation/stack'; import {CardStyleInterpolators} from '@react-navigation/stack'; import {useCallback} from 'react'; +// Import Animated directly from 'react-native' as animations are used with navigation. +// eslint-disable-next-line no-restricted-imports +import {Animated} from 'react-native'; function useWideModalStackScreenOptions() { const styles = useThemeStyles(); @@ -27,6 +31,7 @@ function useWideModalStackScreenOptions() { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const {wideRHPRouteKeys, superWideRHPRouteKeys} = useWideRHPState(); + const {sidePanelOffset} = useSidePanelState(); return useCallback<({route}: {route: PlatformStackRouteProp}) => PlatformStackNavigationOptions>( ({route}) => { @@ -39,7 +44,9 @@ function useWideModalStackScreenOptions() { if (!isSmallScreenWidth) { if (superWideRHPRouteKeys.includes(route.key)) { cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { - cardStyle: styles.superWideRHPExtendedCardInterpolatorStyles, + // Shrink the super wide sheet by the Side Panel width while it is open so the sheet's + // left edge stays put instead of being pushed off-screen. See https://github.com/Expensify/App/issues/99035 + cardStyle: styles.getSuperWideRHPExtendedCardInterpolatorStyles(Animated.subtract(animatedSuperWideRHPWidth, sidePanelOffset.current)), }); } else if (wideRHPRouteKeys.includes(route.key)) { cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { @@ -67,7 +74,7 @@ function useWideModalStackScreenOptions() { }, }; }, - [isSmallScreenWidth, modalCardStyleInterpolator, styles, superWideRHPRouteKeys, wideRHPRouteKeys], + [isSmallScreenWidth, modalCardStyleInterpolator, sidePanelOffset, styles, superWideRHPRouteKeys, wideRHPRouteKeys], ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index e44e3646c3fd..7bd9139e0ef7 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -195,10 +195,21 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { // When the wide rhp page is opened as first one, it will be animated with the entire RightModalNavigator. const animationEnabledOnSearchReport = superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0 || isSmallScreenWidth; - const animatedWidth = expandedRHPProgress.interpolate({ - inputRange: [0, 1, 2], - outputRange: [singleRHPWidth, getWideRHPWidth(windowWidth), calculateSuperWideRHPWidth(windowWidth)], - }); + // When the Concierge/Help Side Panel is open on a wide (extra large) layout, it shifts the whole RHP + // left by its width via paddingRight (see useModalCardStyleInterpolator + SidePanelContextProvider). + // The super wide RHP already spans almost the full window, so without shrinking it by the same amount + // its left edge would be pushed off-screen once the Side Panel opens. Subtract the Side Panel offset + // from the super wide width only (progress === 2) so the sheet's left edge stays put while the Side + // Panel animates open/closed. See https://github.com/Expensify/App/issues/99035 + const superWideRHPSidePanelOffset = Animated.multiply(expandedRHPProgress.interpolate({inputRange: [0, 1, 2], outputRange: [0, 0, 1], extrapolate: 'clamp'}), sidePanelOffset.current); + + const animatedWidth = Animated.subtract( + expandedRHPProgress.interpolate({ + inputRange: [0, 1, 2], + outputRange: [singleRHPWidth, getWideRHPWidth(windowWidth), calculateSuperWideRHPWidth(windowWidth)], + }), + superWideRHPSidePanelOffset, + ); const animatedWidthStyle = useMemo(() => { return { diff --git a/src/libs/Navigation/helpers/calculateSuperWideRHPWidth/index.ts b/src/libs/Navigation/helpers/calculateSuperWideRHPWidth/index.ts index 2d83cd2d3eb2..e772d0c70e80 100644 --- a/src/libs/Navigation/helpers/calculateSuperWideRHPWidth/index.ts +++ b/src/libs/Navigation/helpers/calculateSuperWideRHPWidth/index.ts @@ -10,7 +10,7 @@ import variables from '@styles/variables'; * @returns Calculated super wide RHP width with constraints applied */ function calculateSuperWideRHPWidth(windowWidth: number) { - const superWideRHPWidth = windowWidth - variables.sideBarWithLHBWidth - variables.navigationTabBarSize; + const superWideRHPWidth = windowWidth - variables.superWideRHPLeftMargin; const wideRHPWidth = calculateReceiptPaneRHPWidth(windowWidth) + variables.sideBarWidth; return Math.max(superWideRHPWidth, wideRHPWidth); diff --git a/src/libs/Navigation/helpers/markPendingWriteForSearchPage.ts b/src/libs/Navigation/helpers/markPendingWriteForSearchPage.ts new file mode 100644 index 000000000000..922b561ebee2 --- /dev/null +++ b/src/libs/Navigation/helpers/markPendingWriteForSearchPage.ts @@ -0,0 +1,17 @@ +import {markPendingSearchWrite} from '@libs/pendingSearchWrite'; + +import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; + +/** + * Marks Search's pending-write signal when this submit started from the FAB and isn't inside a + * report - meaning it's landing on Search - so the write waits until Search's skeleton is + * replaced by real content before applying its optimistic data. + */ +function markPendingWriteForSearchPage(isFromGlobalCreate: boolean) { + if (!isFromGlobalCreate || isReportTopmostSplitNavigator()) { + return; + } + markPendingSearchWrite(); +} + +export default markPendingWriteForSearchPage; diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index 374935a4aa48..cd736853f587 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -50,6 +50,8 @@ const oldRoutes: Record = { '/r/*/duplicates/review/billable': '/r/$1/billable/$1', '/r/*/duplicates/review': '/r/$1/duplicates/review/$1', '/r/*/duplicates/confirm': '/r/$1/confirm/$1', + '/r/*/trip/*/*/*': '/r/$1/trip-details/$1/$2/$3/$4', + '/r/*/trip/*': '/r/$1/trip-summary/$1/$2', '/workspaces/*/overview/address': '/workspaces/$1/overview/workspace-address', '/workspaces/*/categories/settings': '/workspaces/$1/categories/categories-settings', '/workspaces/*/categories/new': '/workspaces/$1/categories/category-new', @@ -76,6 +78,8 @@ const oldRoutes: Record = { '/workspaces/$1/accounting/quickbooks-online/advanced/quickbooks-online-autosync/quickbooks-online-accounting-method', '/workspaces/*/connections/quickbooks-online/advanced/autosync': '/workspaces/$1/accounting/quickbooks-online/advanced/quickbooks-online-autosync', '/travel/upgrade': '/travel/travel-upgrade', + '/travel/upgrade/workspace/confirmation': '/travel/travel-upgrade/workspace-confirmation', + '/travel/verify-account': '/travel/travel-verify-account', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account': '/workspaces/$1/accounting/quickbooks-desktop/export/qbd-company-card-expense-account', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account-select': '/workspaces/$1/accounting/quickbooks-desktop/export/qbd-company-card-expense-account-select', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account/account-select': @@ -164,6 +168,7 @@ const oldRoutes: Record = { '/*/*/taxRate/*/*': '/r/$4/taxRate?action=$1&iouType=$2&transactionID=$3&reportID=$4', '/*/*/taxAmount/*/*': '/r/$4/taxAmount?action=$1&iouType=$2&transactionID=$3&reportID=$4', '/*/*/attendees/*/*': '/r/$4/attendees?action=$1&iouType=$2&transactionID=$3&reportID=$4', + '/edit/split-expense/overview/*/*/*': '/create/split-expense/overview/$1/$2/0/split-expense/edit/$1/$3', '/*/*/accountant/*/*': '/r/$4/accountant?action=$1&iouType=$2&transactionID=$3&reportID=$4', '/*/*/upgrade/*/*/*': '/r/$4/money-request-upgrade?action=$1&iouType=$2&transactionID=$3&reportID=$4&upgradePath=$5', '/*/*/upgrade/*/*': '/r/$4/money-request-upgrade?action=$1&iouType=$2&transactionID=$3&reportID=$4', @@ -175,6 +180,8 @@ const oldRoutes: Record = { '/*/*/date/*/*': '/r/$4/expense-date?action=$1&iouType=$2&transactionID=$3&reportID=$4', '/*/*/participants/participants-details/*': '/$1/$2/participants/participants-details/$3', '/*/*/participants/*/*': '/r/$4/expense-participants?action=$1&iouType=$2&transactionID=$3&reportID=$4', + '/*/*/scan/*/*': '/r/$4/expense-scan?action=$1&iouType=$2&transactionID=$3&reportID=$4', + '/*/*/waypoint/*/*/*': '/r/$4/expense-distance/expense-waypoint?action=$1&iouType=$2&transactionID=$3&reportID=$4&pageIndex=$5', /* eslint-enable @typescript-eslint/naming-convention */ }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6b3b23c40fde..2286aecc990e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1807,10 +1807,10 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.REPORT_REJECT]: ROUTES.REJECT_EXPENSE_REPORT.route, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_MERCHANT]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.path, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_PARTICIPANTS]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.path, - [SCREENS.MONEY_REQUEST.STEP_SCAN]: ROUTES.MONEY_REQUEST_STEP_SCAN.route, + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SCAN.path, [SCREENS.MONEY_REQUEST.DYNAMIC_RECEIPT_VIEW]: DYNAMIC_ROUTES.MONEY_REQUEST_RECEIPT_VIEW.path, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_TAG]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAG.path, - [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: ROUTES.MONEY_REQUEST_STEP_WAYPOINT.route, + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_WAYPOINT]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_WAYPOINT.path, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_TAX_AMOUNT]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.path, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_TAX_RATE]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.path, [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_ATTENDEES]: DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.path, @@ -1856,10 +1856,7 @@ const config: LinkingOptions['config'] = { }, }, [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_CREATE_DATE_RANGE]: DYNAMIC_ROUTES.MONEY_REQUEST_SPLIT_EXPENSE_CREATE_DATE_RANGE.path, - [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: { - path: ROUTES.SPLIT_EXPENSE_EDIT.route, - exact: true, - }, + [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT]: DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.path, [SCREENS.SET_DEFAULT_WORKSPACE]: ROUTES.SET_DEFAULT_WORKSPACE.route, }, }, @@ -1935,17 +1932,12 @@ const config: LinkingOptions['config'] = { [SCREENS.TRAVEL.MY_TRIPS]: ROUTES.TRAVEL_MY_TRIPS.route, [SCREENS.TRAVEL.TRAVEL_DOT_LINK_WEB_VIEW]: ROUTES.TRAVEL_DOT_LINK_WEB_VIEW.route, [SCREENS.TRAVEL.DYNAMIC_UPGRADE]: DYNAMIC_ROUTES.TRAVEL_UPGRADE.path, - [SCREENS.TRAVEL.TRIP_SUMMARY]: ROUTES.TRAVEL_TRIP_SUMMARY.route, - [SCREENS.TRAVEL.TRIP_DETAILS]: { - path: ROUTES.TRAVEL_TRIP_DETAILS.route, - parse: { - reservationIndex: (reservationIndex: string) => parseInt(reservationIndex, 10), - }, - }, + [SCREENS.TRAVEL.DYNAMIC_TRIP_SUMMARY]: DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.path, + [SCREENS.TRAVEL.DYNAMIC_TRIP_DETAILS]: DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.path, [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: DYNAMIC_ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.path, [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: DYNAMIC_ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR.path, - [SCREENS.TRAVEL.WORKSPACE_CONFIRMATION]: ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.route, - [SCREENS.TRAVEL.VERIFY_ACCOUNT]: ROUTES.TRAVEL_VERIFY_ACCOUNT.route, + [SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION]: DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path, + [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT.path, [SCREENS.TRAVEL.ENABLE]: {path: ROUTES.TRAVEL_ENABLE.route, exact: true}, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 02da79612e4d..b4e28a3766e0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2142,12 +2142,9 @@ type MoneyRequestNavigatorParamList = { transactionID: string; reportID: string; }; - [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: { + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_WAYPOINT]: { iouType: IOUType; reportID: string; - - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo: Routes | undefined; action: IOUAction; pageIndex: string; transactionID: string; @@ -2268,14 +2265,11 @@ type MoneyRequestNavigatorParamList = { participantsAutoAssigned?: string; backToReport?: string; }; - [SCREENS.MONEY_REQUEST.STEP_SCAN]: { + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN]: { action: IOUAction; iouType: IOUType; transactionID: string; reportID: string; - pageIndex: number; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo: Routes; backToReport?: string; }; [SCREENS.MONEY_REQUEST.DYNAMIC_RECEIPT_VIEW]: { @@ -2292,17 +2286,18 @@ type MoneyRequestNavigatorParamList = { [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE]: { /** Deliberately not named `reportID` so it cannot inherit the base path's report. */ splitReportID: string; - transactionID: string; + /** Deliberately not named `transactionID` so it cannot collide with the `transactionID` query param used by the expense step screens nested under this dynamic route. */ + originalTransactionID: string; splitExpenseTransactionID?: string; }; [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_SEARCH]: { splitReportID: string; - transactionID: string; + originalTransactionID: string; splitExpenseTransactionID?: string; }; [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_CREATE_DATE_RANGE]: { splitReportID: string; - transactionID: string; + originalTransactionID: string; }; [SCREENS.MONEY_REQUEST.REJECT]: { /** ID of the transaction the page was opened for */ @@ -2745,7 +2740,12 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.MISSING_PERSONAL_DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.DEBUG]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.AVATAR_CROP]: NavigatorScreenParams; - [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: NavigatorScreenParams; + [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT]: { + reportID: string; + /** Inherited from the base path's `originalTransactionID` query param (see DYNAMIC_SPLIT_EXPENSE). */ + originalTransactionID: string; + editSplitExpenseTransactionID?: string; + }; [SCREENS.RIGHT_MODAL.ADD_EXISTING_EXPENSE]: NavigatorScreenParams<{reportId: string | undefined}>; [SCREENS.RIGHT_MODAL.SCHEDULE_CALL]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_CHANGE_APPROVER]: NavigatorScreenParams; @@ -2784,37 +2784,27 @@ type TravelNavigatorParamList = { isTestAccount?: string; redirectUrl?: string; }; - [SCREENS.TRAVEL.TRIP_SUMMARY]: { + [SCREENS.TRAVEL.DYNAMIC_TRIP_SUMMARY]: { reportID: string; transactionID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: string; }; - [SCREENS.TRAVEL.TRIP_DETAILS]: { + [SCREENS.TRAVEL.DYNAMIC_TRIP_DETAILS]: { reportID: string; transactionID: string; - sequenceIndex: number; + sequenceIndex: string; pnr: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: string; }; [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: { domain: string; policyID?: string; }; - [SCREENS.TRAVEL.WORKSPACE_CONFIRMATION]: { - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; + [SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION]: undefined; [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: { policyID?: string; }; [SCREENS.TRAVEL.DYNAMIC_UPGRADE]: undefined; - [SCREENS.TRAVEL.VERIFY_ACCOUNT]: { - domain?: string; + [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: { policyID?: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; }; [SCREENS.TRAVEL.ENABLE]: {policyID: string; subPage?: string; action?: 'edit'}; }; @@ -2919,6 +2909,7 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EDIT]: { policyID: string; firstApproverEmail: string; + memberEmail?: string; }; [SCREENS.WORKSPACE.DYNAMIC_WORKFLOWS_APPROVALS_EXPENSES_FROM]: { policyID: string; @@ -3356,16 +3347,6 @@ type MissingPersonalDetailsParamList = { }; }; -type SplitExpenseParamList = { - [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: { - reportID: string; - transactionID: string; - splitExpenseTransactionID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; -}; - type AddExistingExpensesParamList = { [SCREENS.ADD_EXISTING_EXPENSES_ROOT]: { reportID: string; @@ -3542,7 +3523,6 @@ export type { TwoFactorAuthNavigatorParamList, ScheduleCallParamList, TestDriveDemoNavigatorParamList, - SplitExpenseParamList, SetParamsAction, WorkspaceNavigatorRouteName, ReportChangeApproverParamList, diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 44ea8f651c1d..9dfee943026a 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -131,6 +131,7 @@ import { getIcons, getMovedActionMessage, getMovedTransactionMessage, + parseMovedTransactionReportIDs, getParticipantsAccountIDsForDisplay, getPolicyChangeLogCopyMessage, getPolicyChangeMessage, @@ -546,18 +547,15 @@ function getAlternateText( * Searches for a match when provided with a value */ function isSearchStringMatch(searchValue: string, searchText?: string | null, participantNames = new Set(), isReportChatRoom = false): boolean { - const searchWords = new Set(searchValue.replaceAll(',', ' ').split(/\s+/)); + const searchWords = Array.from(new Set(searchValue.replaceAll(',', ' ').split(/\s+/).filter(Boolean))); const valueToSearch = searchText?.replaceAll(new RegExp(/ /g), ''); - let matching = true; for (const word of searchWords) { - // if one of the word is not matching, we don't need to check further - if (!matching) { - continue; + const regex = new RegExp(Str.escapeForRegExp(word), 'i'); + if (!(regex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)))) { + return false; } - const matchRegex = new RegExp(Str.escapeForRegExp(word), 'i'); - matching = matchRegex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)); } - return matching; + return true; } function getLatestVisibleMoneyRequestAction( @@ -785,7 +783,15 @@ function getLastMessageTextForReport({ const properSchemaForModifiedExpenseMessage = Parser.htmlToText(properSchemaForModifiedExpenseMessageWithHTML); lastMessageTextFromReport = formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true); } else if (isMovedTransactionAction(lastReportAction)) { - lastMessageTextFromReport = Parser.htmlToText(getMovedTransactionMessage(translate, lastReportAction, reportAttributesDerived)); + const {fromReportID, toReportID, displayReportID} = parseMovedTransactionReportIDs(lastReportAction); + lastMessageTextFromReport = Parser.htmlToText( + getMovedTransactionMessage({ + translate, + fromReportID, + toReportID, + derivedReportName: displayReportID ? reportAttributesDerived?.[displayReportID]?.reportName : undefined, + }), + ); } else if (isTaskAction(lastReportAction)) { lastMessageTextFromReport = formatReportLastMessageText(getTaskReportActionMessage(translate, lastReportAction).text); } else if (isCreatedTaskReportAction(lastReportAction)) { @@ -898,7 +904,14 @@ function getLastMessageTextForReport({ } else if (isMovedAction(lastReportAction)) { lastMessageTextFromReport = Parser.htmlToText(getMovedActionMessage(translate, lastReportAction, report)); } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - lastMessageTextFromReport = Parser.htmlToText(getUnreportedTransactionMessage(translate, lastReportAction, reportAttributesDerived)); + const {fromReportID} = parseMovedTransactionReportIDs(lastReportAction); + lastMessageTextFromReport = Parser.htmlToText( + getUnreportedTransactionMessage({ + translate, + fromReportID, + derivedReportName: fromReportID ? reportAttributesDerived?.[fromReportID]?.reportName : undefined, + }), + ); } else if (isActionableMentionWhisper(lastReportAction)) { const targetAccountIDs = getOriginalMessage(lastReportAction)?.inviteeAccountIDs; lastMessageTextFromReport = Parser.htmlToText(getActionableMentionWhisperMessage(translate, lastReportAction, getPersonalDetailsListByIDs(targetAccountIDs, personalDetails))); diff --git a/src/libs/PerDiemRequestUtils.ts b/src/libs/PerDiemRequestUtils.ts index ace9ca5b642d..ee9e17ad18b2 100644 --- a/src/libs/PerDiemRequestUtils.ts +++ b/src/libs/PerDiemRequestUtils.ts @@ -5,7 +5,6 @@ import CONST from '@src/CONST'; import type {Policy, Report, Transaction} from '@src/types/onyx'; import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; -import type {Locale as DateFnsLocale} from 'date-fns'; import type {OnyxEntry} from 'react-native-onyx'; import {addDays, differenceInDays, differenceInMinutes, format, isSameDay, startOfDay} from 'date-fns'; @@ -13,6 +12,7 @@ import lodashSortBy from 'lodash/sortBy'; import type {OptionTree} from './OptionsListUtils'; +import DateUtils from './DateUtils'; import {isPolicyExpenseChat} from './ReportUtils'; import tokenizedSearch from './tokenizedSearch'; @@ -221,17 +221,17 @@ function getSubratesForDisplay(subrate: Subrate | undefined, qtyText: string) { * param {string} dateTimeString * returns {string} example: 2023-05-16 11:10 PM */ -function formatDateTimeTo12Hour(dateTimeString: string, dateFnsLocale: DateFnsLocale | undefined): string { +function formatDateTimeTo12Hour(translate: LocalizedTranslate, dateTimeString: string): string { if (!dateTimeString) { return ''; } const date = new Date(dateTimeString); - return format(date, 'hh:mm a, yyyy-MM-dd', {locale: dateFnsLocale}); + return `${DateUtils.formatTimeWithPeriod(translate, date, CONST.DATE.TIME_FORMAT_WITHOUT_PERIOD)}, ${format(date, CONST.DATE.FNS_FORMAT_STRING)}`; } -function getTimeForDisplay(transaction: OnyxEntry, dateFnsLocale: DateFnsLocale | undefined) { +function getTimeForDisplay(transaction: OnyxEntry, translate: LocalizedTranslate) { const customUnitRateDate = transaction?.comment?.customUnit?.attributes?.dates ?? {start: '', end: ''}; - return `${formatDateTimeTo12Hour(customUnitRateDate.start, dateFnsLocale)} - ${formatDateTimeTo12Hour(customUnitRateDate.end, dateFnsLocale)}`; + return `${formatDateTimeTo12Hour(translate, customUnitRateDate.start)} - ${formatDateTimeTo12Hour(translate, customUnitRateDate.end)}`; } function getTimeDifferenceIntervals(transaction: OnyxEntry) { diff --git a/src/libs/PlaidUtils.ts b/src/libs/PlaidUtils.ts new file mode 100644 index 000000000000..2e564c6620c7 --- /dev/null +++ b/src/libs/PlaidUtils.ts @@ -0,0 +1,16 @@ +import type {LinkSuccessMetadata} from 'react-native-plaid-link-sdk'; +import type {PlaidLinkOnSuccessMetadata} from 'react-plaid-link'; + +type PlaidInstitution = PlaidLinkOnSuccessMetadata['institution'] | LinkSuccessMetadata['institution']; + +function getPlaidInstitutionID(institution: PlaidInstitution): string | undefined { + if (!institution) { + return undefined; + } + if ('institution_id' in institution) { + return institution.institution_id; + } + return institution.id; +} + +export default getPlaidInstitutionID; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 3227a37e1084..c5699b833348 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -56,6 +56,7 @@ import {getApiRoot} from './ApiUtils'; import {getCategoryApproverRule, hasAnyCategoryRules} from './CategoryUtils'; import {convertToBackendAmount} from './CurrencyUtils'; import {getHRAdvancedModeFinalApprover, isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from './HRUtils'; +import isTeachersUnitePolicyID from './isTeachersUnitePolicyID'; import Navigation from './Navigation/Navigation'; import {getIsOffline} from './NetworkState'; import {formatMemberForList} from './OptionsListUtils'; @@ -702,6 +703,19 @@ function getReimburserEmail(policy: OnyxEntry): string | undefined { return policy.reimburser ?? policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); } +/** + * Whether the given role is allowed to pay (reimburse) on a workspace. + */ +function canRolePay(role: string | undefined): boolean { + return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; +} + +/** + * The roles that are allowed to pay (reimburse) on a workspace, derived from the WORKFLOWS_PAYMENTS permission. The + * Authorized Payer (reimburser) must always hold one of these, so any role change for a payer is restricted to this set. + */ +const PAYER_ROLES = Object.values(CONST.POLICY.ROLE).filter(canRolePay); + function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { if (!policy) { return false; @@ -2811,7 +2825,12 @@ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection !!policy && !policy.isJoinRequestPending && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && shouldShowPolicy(policy, false, currentUserLogin), + (policy): policy is Policy => + !!policy && + !policy.isJoinRequestPending && + (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && + shouldShowPolicy(policy, false, currentUserLogin) && + !isTeachersUnitePolicyID(policy.id), ); } @@ -3202,6 +3221,8 @@ export { isPolicyMember, isPolicyPayer, getReimburserEmail, + PAYER_ROLES, + canRolePay, arePaymentsEnabled, isSubmitterAndApprover, isSubmitAndClose, diff --git a/src/libs/ReportFieldOptionsListUtils.ts b/src/libs/ReportFieldOptionsListUtils.ts index 07e65fea9c76..610c1bdfe700 100644 --- a/src/libs/ReportFieldOptionsListUtils.ts +++ b/src/libs/ReportFieldOptionsListUtils.ts @@ -27,42 +27,54 @@ function getReportFieldOptionsSection({ options, recentlyUsedOptions, selectedOptions, + initiallySelectedValue, searchValue, translate, }: { options: string[]; recentlyUsedOptions: string[]; selectedOptions: Array>; + initiallySelectedValue: string; searchValue: string; translate: LocalizedTranslate; }) { const reportFieldOptionsSections = []; - const selectedOptionKeys = selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o); - const selectedKeySet = new Set(selectedOptionKeys); + // The live selection drives the checkmark (isSelected)... + const selectedKeySet = new Set(selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o)); + // ...while the frozen initial selection drives the pinned "Selected" section, so selecting a value marks it without reordering the list until reopen. + const pinnedOptionKeys = initiallySelectedValue ? [initiallySelectedValue] : []; + const pinnedKeySet = new Set(pinnedOptionKeys); if (searchValue) { const searchOptions = tokenizedSearch(options, searchValue, (option) => [option]); + // Keep the pinned option(s) at the top of the search results, matching the "Selected" section shown when not searching. + const pinnedSearchOptions: string[] = []; + const otherSearchOptions: string[] = []; + for (const option of searchOptions) { + (pinnedKeySet.has(option) ? pinnedSearchOptions : otherSearchOptions).push(option); + } + const orderedSearchOptions = [...pinnedSearchOptions, ...otherSearchOptions]; reportFieldOptionsSections.push({ // "Search" section title: '', sectionIndex: 0, - data: getReportFieldOptions(searchOptions, selectedKeySet), + data: getReportFieldOptions(orderedSearchOptions, selectedKeySet), }); return reportFieldOptionsSections; } const enabledOptionSet = new Set(options); - const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((o) => !selectedKeySet.has(o) && enabledOptionSet.has(o)); - const filteredOptions = options.filter((o) => !selectedKeySet.has(o)); + const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((o) => !pinnedKeySet.has(o) && enabledOptionSet.has(o)); + const filteredOptions = options.filter((o) => !pinnedKeySet.has(o)); - if (selectedOptionKeys.length) { + if (pinnedOptionKeys.length) { reportFieldOptionsSections.push({ - // "Selected" section + // "Selected" (pinned) section title: '', sectionIndex: 1, - data: getReportFieldOptions(selectedOptionKeys, selectedKeySet), + data: getReportFieldOptions(pinnedOptionKeys, selectedKeySet), }); } @@ -71,7 +83,7 @@ function getReportFieldOptionsSection({ // "Recent" section title: translate('common.recent'), sectionIndex: 2, - data: getReportFieldOptions(filteredRecentlyUsedOptions), + data: getReportFieldOptions(filteredRecentlyUsedOptions, selectedKeySet), }); } @@ -79,7 +91,7 @@ function getReportFieldOptionsSection({ // "All" section when items amount more than the threshold title: translate('common.all'), sectionIndex: 3, - data: getReportFieldOptions(filteredOptions), + data: getReportFieldOptions(filteredOptions, selectedKeySet), }); return reportFieldOptionsSections; diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index a9bd8efedd28..f60350769614 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -145,6 +145,7 @@ import { getMoneyRequestSpendBreakdown, getMovedActionMessage, getMovedTransactionMessage, + parseMovedTransactionReportIDs, getParentReport, getPolicyChangeLogCopyMessage, getPolicyChangeMessage, @@ -636,11 +637,16 @@ function computeReportNameBasedOnReportAction({ } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - return Parser.htmlToText(getUnreportedTransactionMessage(translate, parentReportAction, reportAttributes)); + const {fromReportID} = parseMovedTransactionReportIDs(parentReportAction); + const reportName = fromReportID ? reportAttributes?.[fromReportID]?.reportName : undefined; + return Parser.htmlToText(getUnreportedTransactionMessage({translate, fromReportID, derivedReportName: reportName})); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { - return Parser.htmlToText(getMovedTransactionMessage(translate, parentReportAction, reportAttributes)); + const {fromReportID, toReportID, displayReportID} = parseMovedTransactionReportIDs(parentReportAction); + return Parser.htmlToText( + getMovedTransactionMessage({translate, fromReportID, toReportID, derivedReportName: displayReportID ? reportAttributes?.[displayReportID]?.reportName : undefined}), + ); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4f2512d2e0aa..7077555d7f24 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -97,7 +97,6 @@ import Onyx from 'react-native-onyx'; import type {GuidedSetupData, TaskForParameters} from './actions/Report'; import type {OnboardingCompanySize, OnboardingMessage, OnboardingPurpose, OnboardingTaskLinks} from './actions/Welcome/OnboardingFlow'; import type {AddCommentOrAttachmentParams} from './API/parameters'; -import type EnvironmentType from './Environment/getEnvironment/types'; import type {FormulaContext, compute as computeFormula, computeWithMetadata as computeFormulaWithMetadata} from './Formula'; import type {MoneyRequestNavigatorParamList, ReportsSplitNavigatorParamList} from './Navigation/types'; import type {LastVisibleMessage} from './ReportActionsUtils'; @@ -126,9 +125,9 @@ import {getCategoryGLCode} from './CategoryUtils'; import {convertToDisplayStringEnLocale} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {getEnvironmentURL} from './Environment/Environment'; -import getEnvironment from './Environment/getEnvironment'; import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from './ErrorUtils'; import getAttachmentDetails from './fileDownload/getAttachmentDetails'; +import isTeachersUnitePolicyID from './isTeachersUnitePolicyID'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber'; import {translateLocal} from './Localize'; import Log from './Log'; @@ -238,7 +237,7 @@ import { // The functions imported here are pure utility functions that don't create initialization-time dependencies. // ReportNameUtils imports helper functions from ReportUtils, and ReportUtils imports name generation functions from ReportNameUtils. // eslint-disable-next-line import/no-cycle -import {deprecatedGetReportName, getGroupChatName, getInvoicePayerName, getInvoiceReportName} from './ReportNameUtils'; +import {deprecatedGetReportName, getGroupChatName, getInvoicePayerName, getInvoiceReportName, getReportName} from './ReportNameUtils'; import {getAllPersonalDetailLogins} from './ShortMentionLogins'; import {shouldRestrictUserBillableActions} from './SubscriptionUtils'; import {isTaskCompleted} from './TaskUtils'; @@ -951,6 +950,20 @@ type OnyxDataTaskAssigneeChat = { optimisticChatCreatedReportAction?: OptimisticCreatedReportAction; }; +type GetTaskAssigneeChatOnyxDataParams = { + accountID: number; + assigneeAccountID: number; + taskReportID: string; + assigneeChatReportID: string; + parentReportID: string | undefined; + title: string; + assigneeChatReport: OnyxEntry; + currentUserEmail: string; + currentUserAccountID: number; + delegateAccountID: number | undefined; + isOptimisticAssigneeChatReport?: boolean; +}; + type Ancestor = { report: Report; reportAction: ReportAction; @@ -1029,10 +1042,6 @@ let deprecatedIsAnonymousUser = false; let environmentURL: string; getEnvironmentURL().then((url: string) => (environmentURL = url)); -let environment: EnvironmentType; -getEnvironment().then((env) => { - environment = env; -}); /** * Fallback title field used when a policy has an empty fieldList (matches OldDot behavior). @@ -3227,6 +3236,7 @@ type GetAddExpenseDropdownOptionsParams = { unreportedExpenseBackToReport?: string; lastDistanceExpenseType?: IOURequestType; currentUserAccountID: number; + blockDistanceRequestIfNeeded?: () => boolean; }; function getAddExpenseDropdownOptions({ @@ -3242,44 +3252,55 @@ function getAddExpenseDropdownOptions({ unreportedExpenseBackToReport, lastDistanceExpenseType, currentUserAccountID, + blockDistanceRequestIfNeeded, }: GetAddExpenseDropdownOptionsParams): Array>> { + const isReportTeachersUnite = isTeachersUnitePolicyID(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); + return [ - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, - text: translate('iou.createExpense'), - icon: icons.Plus, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE, - onSelected: () => { - if (!iouReportID) { - return; - } - if ( - policy && - policy.type !== CONST.POLICY.TYPE.PERSONAL && - shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) - ) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); - }, - }, - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, - text: translate('iou.trackDistance'), - icon: icons.Location, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE, - onSelected: () => { - if (!iouReportID) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); - }, - }, + // Teachers Unite only supports expenses via split expense + ...(isReportTeachersUnite + ? [] + : [ + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, + text: translate('iou.createExpense'), + icon: icons.Plus, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE, + onSelected: () => { + if (!iouReportID) { + return; + } + if ( + policy && + policy.type !== CONST.POLICY.TYPE.PERSONAL && + shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) + ) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); + }, + }, + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, + text: translate('iou.trackDistance'), + icon: icons.Location, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE, + onSelected: () => { + if (!iouReportID) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + if (blockDistanceRequestIfNeeded?.()) { + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); + }, + }, + ]), { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, text: translate('iou.addExistingExpense'), @@ -7020,6 +7041,7 @@ function updateOptimisticParentReportAction( * @param taskAssigneeAccountID - AccountID of the person assigned to the task * @param text - Text of the comment * @param parentReportID - Report ID of the parent report + * @param delegateAccountID - AccountID of the delegate acting on behalf of the current user * @param createdOffset - The offset for task's created time that created via a loop */ function buildOptimisticTaskCommentReportAction( @@ -7028,13 +7050,20 @@ function buildOptimisticTaskCommentReportAction( taskAssigneeAccountID: number, text: string, parentReportID: string | undefined, + delegateAccountID: number | undefined, actorAccountID?: number, createdOffset = 0, currentUserEmail?: string, currentUserAccountID?: number, ): OptimisticReportAction { - // delegateAccountIDParam: will be threaded in PR 14; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) - const reportAction = buildOptimisticAddCommentReportAction({text, reportID: taskReportID, createdOffset, currentUserEmail, currentUserAccountID, delegateAccountIDParam: undefined}); + const reportAction = buildOptimisticAddCommentReportAction({ + text, + reportID: taskReportID, + createdOffset, + currentUserEmail, + currentUserAccountID, + delegateAccountIDParam: delegateAccountID, + }); if (Array.isArray(reportAction.reportAction.message)) { const message = reportAction.reportAction.message.at(0); if (message) { @@ -7447,16 +7476,35 @@ function getDeletedTransactionMessage(translate: LocalizedTranslate, action: Rep return message; } -function getMovedTransactionMessage(translate: LocalizedTranslate, action: ReportAction, reportAttributes?: ReportAttributesDerivedValue['reports']) { +function parseMovedTransactionReportIDs(action: ReportAction): {fromReportID: string | undefined; toReportID: string | undefined; displayReportID: string | undefined} { const movedTransactionOriginalMessage = getOriginalMessage(action) ?? {}; const {toReportID, fromReportID} = movedTransactionOriginalMessage as OriginalMessageMovedTransaction; + return {toReportID, fromReportID, displayReportID: fromReportID ?? toReportID}; +} - const toReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${toReportID}`]; - const fromReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; +function getMovedTransactionMessage({ + translate, + movedReport, + toReportID, + fromReportID, + derivedReportName, +}: { + translate: LocalizedTranslate; + movedReport?: Report; + toReportID?: string; + fromReportID?: string; + derivedReportName?: string; +}) { + let report = movedReport; + + if (!report) { + const toReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${toReportID}`]; + const fromReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; - const report = fromReport ?? toReport; + report = fromReport ?? toReport; + } - const reportName = Parser.htmlToText(deprecatedGetReportName(report, reportAttributes) ?? report?.reportName ?? ''); + const reportName = Parser.htmlToText(getReportName(report, derivedReportName)); const reportUrl = getReportURLForCurrentContext(report?.reportID); if (typeof fromReportID === 'undefined') { return reportName ? translate('iou.movedTransactionTo', reportUrl, reportName) : translate('iou.movedTransactionToAnotherReport'); @@ -7464,17 +7512,30 @@ function getMovedTransactionMessage(translate: LocalizedTranslate, action: Repor return reportName ? translate('iou.movedTransactionFrom', reportUrl, reportName) : translate('iou.movedTransactionFromAnotherReport'); } -function getUnreportedTransactionMessage(translate: LocalizedTranslate, action: ReportAction, reportAttributes?: ReportAttributesDerivedValue['reports']) { - const movedTransactionOriginalMessage = getOriginalMessage(action) ?? {}; - const {fromReportID} = movedTransactionOriginalMessage as OriginalMessageMovedTransaction; +function getUnreportedTransactionMessage({ + translate, + fromReport, + fromReportID, + derivedReportName, +}: { + translate: LocalizedTranslate; + fromReport?: Report; + fromReportID?: string; + derivedReportName?: string; +}) { + let report = fromReport; + let reportID = report?.reportID; - const fromReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; + if (!report) { + report = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; + reportID = fromReportID; + } - const reportName = Parser.htmlToText(deprecatedGetReportName(fromReport, reportAttributes) ?? fromReport?.reportName ?? ''); + const reportName = Parser.htmlToText(getReportName(report, derivedReportName)); - let reportUrl = getReportURLForCurrentContext(fromReportID); + let reportUrl = getReportURLForCurrentContext(reportID); - if (fromReportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + if (reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { reportUrl = `${environmentURL}/r/${findSelfDMReportID()}`; return translate('iou.unreportedTransaction', reportUrl); } @@ -10613,6 +10674,10 @@ function isGroupChatAdmin(report: OnyxEntry, accountID: number) { return participant?.role === CONST.REPORT.ROLE.ADMIN; } +function isTeachersUniteReport(report: OnyxEntry): boolean { + return isTeachersUnitePolicyID(report?.policyID); +} + /** * Helper method to define what expense options we want to show for particular method. * There are 4 expense options: Submit, Split, Pay and Track expense: @@ -10648,8 +10713,7 @@ function getMoneyRequestOptions( isRestrictedToPreferredPolicy = false, currentUserAccountID?: number, ): IOUType[] { - const teacherUnitePolicyID = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; - const isTeachersUniteReport = report?.policyID === teacherUnitePolicyID; + const isTeachersUniteReportValue = isTeachersUniteReport(report); // In any thread, task report or trip room, we do not allow any new expenses if (isChatThread(report) || isTaskReport(report) || isInvoiceReport(report) || isSystemChat(report) || isReportArchived || isTripRoom(report)) { @@ -10681,25 +10745,19 @@ function getMoneyRequestOptions( } if (canRequestMoney(report, policy, otherParticipants, currentUserAccountID)) { - // For Teachers Unite policy, don't show Create Expense option - if (!isTeachersUniteReport) { + if (!isTeachersUniteReportValue) { options = [...options, CONST.IOU.TYPE.SUBMIT]; if (!filterDeprecatedTypes) { options = [...options, CONST.IOU.TYPE.REQUEST]; } - } - // If the user can request money from the workspace report, they can also track expenses - if (isPolicyExpenseChat(report) || isExpenseReport(report)) { - options = [...options, CONST.IOU.TYPE.TRACK]; + // If the user can request money from the workspace report, they can also track expenses + if (isPolicyExpenseChat(report) || isExpenseReport(report)) { + options = [...options, CONST.IOU.TYPE.TRACK]; + } } } - // For expense reports on Teachers Unite workspace, disable "Create report" option - if (isExpenseReport(report) && report?.policyID === teacherUnitePolicyID) { - options = options.filter((option) => option !== CONST.IOU.TYPE.SUBMIT); - } - // User created policy rooms and default rooms like #admins or #announce will always have the Split Expense option // unless there are no other participants at all (e.g. #admins room for a policy with only 1 admin) // DM chats will have the Split Expense option. @@ -10709,7 +10767,7 @@ function getMoneyRequestOptions( (isChatRoom(report) && !isAnnounceRoom(report) && otherParticipants.length > 0) || (isDM(report) && otherParticipants.length > 0) || (isGroupChat(report) && otherParticipants.length > 0) || - (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && isTeachersUniteReport) + (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && isTeachersUniteReportValue) ) { options = [...options, CONST.IOU.TYPE.SPLIT]; } @@ -11082,18 +11140,19 @@ function canEditRoomVisibility(policy: OnyxEntry, isReportArchived: bool /** * Returns the onyx data needed for the task assignee chat */ -function getTaskAssigneeChatOnyxData( - accountID: number, - assigneeAccountID: number, - taskReportID: string, - assigneeChatReportID: string, - parentReportID: string | undefined, - title: string, - assigneeChatReport: OnyxEntry, - currentUserEmail: string, - currentUserAccountID: number, - isOptimisticAssigneeChatReport?: boolean, -): OnyxDataTaskAssigneeChat { +function getTaskAssigneeChatOnyxData({ + accountID, + assigneeAccountID, + taskReportID, + assigneeChatReportID, + parentReportID, + title, + assigneeChatReport, + currentUserEmail, + currentUserAccountID, + delegateAccountID, + isOptimisticAssigneeChatReport, +}: GetTaskAssigneeChatOnyxDataParams): OnyxDataTaskAssigneeChat { // Set if we need to add a comment to the assignee chat notifying them that they have been assigned a task let optimisticAssigneeAddComment: OptimisticReportAction | undefined; // Set if this is a new chat that needs to be created for the assignee @@ -11213,6 +11272,7 @@ function getTaskAssigneeChatOnyxData( assigneeAccountID, `assigned to ${displayname}`, parentReportID, + delegateAccountID, undefined, 0, currentUserEmail, @@ -12537,7 +12597,8 @@ function prepareOnboardingOnyxData({ // Text message const message = typeof onboardingMessage.message === 'function' ? onboardingMessage.message(onboardingTaskParams) : onboardingMessage.message; - // delegateAccountIDParam: will be threaded in PR 14; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) + // delegateAccountIDParam: will be threaded in PR 15b + // buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) const textComment = buildOptimisticAddCommentReportAction({text: message, actorAccountID, createdOffset: 1, delegateAccountIDParam: undefined}); const textCommentAction: OptimisticAddCommentReportAction = textComment.reportAction; const textMessage: AddCommentOrAttachmentParams = { @@ -12590,7 +12651,18 @@ function prepareOnboardingOnyxData({ ); const emailCreatingAction = CONST.EMAIL.CONCIERGE; const taskCreatedAction = buildOptimisticCreatedReportAction({emailCreatingAction}); - const taskReportAction = buildOptimisticTaskCommentReportAction(currentTask.reportID, taskTitle, 0, `task for ${taskTitle}`, targetChatReportID, actorAccountID, index + 3); + // delegateAccountID: will be threaded in PR 15b + // buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) + const taskReportAction = buildOptimisticTaskCommentReportAction( + currentTask.reportID, + taskTitle, + 0, + `task for ${taskTitle}`, + targetChatReportID, + undefined, + actorAccountID, + index + 3, + ); currentTask.parentReportActionID = taskReportAction.reportAction.reportActionID; let isTaskAutoCompleted: boolean = task.autoCompleted; @@ -12606,7 +12678,8 @@ function prepareOnboardingOnyxData({ } const completedTaskReportAction = isTaskAutoCompleted - ? // delegateEmailParam: will be threaded in PR 14; buildOptimisticTaskReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) + ? // delegateEmailParam: will be threaded in PR 17 + // buildOptimisticTaskReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) buildOptimisticTaskReportAction(currentTask.reportID, CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED, undefined, 'marked as complete', actorAccountID, 2) : null; if (task.type === CONST.ONBOARDING_TASK_TYPE.CREATE_WORKSPACE) { @@ -12635,7 +12708,8 @@ function prepareOnboardingOnyxData({ // Sign-off welcome message const welcomeSignOffText = engagementChoice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM ? translateLocal('onboarding.welcomeSignOffTitleManageTeam') : translateLocal('onboarding.welcomeSignOffTitle'); - // delegateAccountIDParam: will be threaded in PR 14; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) + // delegateAccountIDParam: will be threaded in PR 15b + // buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) const welcomeSignOffComment = buildOptimisticAddCommentReportAction({text: welcomeSignOffText, actorAccountID, createdOffset: tasksData.length + 3, delegateAccountIDParam: undefined}); const welcomeSignOffCommentAction: OptimisticAddCommentReportAction = welcomeSignOffComment.reportAction; const welcomeSignOffMessage = { @@ -13375,7 +13449,7 @@ function doesReportContainRequestsFromMultipleUsers(iouReport: OnyxEntry * Determines whether the report can be moved to the workspace. */ function isWorkspaceEligibleForReportChange(submitterEmail: string | undefined, newPolicy: OnyxEntry, report?: Report): boolean { - if (!submitterEmail || !newPolicy || !isGroupPolicyPolicyUtils(newPolicy)) { + if (!submitterEmail || !newPolicy || !isGroupPolicyPolicyUtils(newPolicy) || isTeachersUnitePolicyID(newPolicy.id)) { return false; } if (report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && !isPolicyAdminPolicyUtils(newPolicy)) { @@ -14398,6 +14472,7 @@ export { getRouteFromLink, canDeleteCardTransactionByLiabilityType, getAddExpenseDropdownOptions, + isTeachersUniteReport, getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, @@ -14643,6 +14718,7 @@ export { isUploadingAttachmentRemovedFromDraft, getUploadingAttachmentHtmlFromComment, buildEditedCommentWithAttachment, + parseMovedTransactionReportIDs, }; export type { diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index bd1976054406..1fb7a3714c07 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -599,7 +599,13 @@ function getQueryHashes(query: SearchQueryJSON) { // Certain filters shouldn't affect whether two searchers are similar or not, since they dont // actually filter out results - const similarSearchIgnoredFilters = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); + const similarSearchIgnoredFilters = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.BANK_ACCOUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_STATUS, + CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, + ]); // Certain filters' values are significant in deciding which search we are on, so we want to include // their value when computing the similarSearchHash diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d846183c0326..72bbf98004cd 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2105,7 +2105,7 @@ function hasVisibleViolations( } } - if (!hasUserVisible && shouldShowViolation(report, policy, violation.name, currentUserEmail, true, transaction)) { + if (!hasUserVisible && shouldShowViolation(report, policy, violation.name, currentUserEmail, currentUserAccountID, true, transaction)) { hasUserVisible = true; } @@ -3748,16 +3748,14 @@ function getMonthSections( queryJSON && monthGroup.year && monthGroup.month ? buildDateRangeGroupQuery(queryJSON, DateUtils.getMonthDateRange(monthGroup.year, monthGroup.month)) : undefined; const transactionsQueryJSON = dateResult?.transactionsQueryJSON; - const monthDate = new Date(monthGroup.year, monthGroup.month - 1, 1); - const formattedMonth = format(monthDate, 'MMMM yyyy', {locale: dateFnsLocale}); - monthSections[key] = { groupedBy: CONST.SEARCH.GROUP_BY.MONTH, transactions: [], transactionsQueryJSON, keyForList: key, ...monthGroup, - formattedMonth, + formattedMonth: DateUtils.getFormattedMonthForSearch(monthGroup.year, monthGroup.month, dateFnsLocale), + shortFormattedMonth: DateUtils.getShortFormattedMonthForSearch(monthGroup.year, monthGroup.month, dateFnsLocale), sortKey: monthGroup.year * 100 + monthGroup.month, }; } @@ -3786,7 +3784,10 @@ function getWeekSections( const rawRange = DateUtils.getWeekDateRange(weekGroup.week); const dateResult = queryJSON && weekGroup.week ? buildDateRangeGroupQuery(queryJSON, rawRange) : undefined; const transactionsQueryJSON = dateResult?.transactionsQueryJSON; - const formattedWeek = DateUtils.getFormattedDateRangeForSearch(dateResult?.start ?? rawRange.start, dateResult?.end ?? rawRange.end, dateFnsLocale); + const weekStart = dateResult?.start ?? rawRange.start; + const weekEnd = dateResult?.end ?? rawRange.end; + const formattedWeek = DateUtils.getFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale); + const shortFormattedWeek = DateUtils.getShortFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale); weekSections[key] = { groupedBy: CONST.SEARCH.GROUP_BY.WEEK, @@ -3794,6 +3795,7 @@ function getWeekSections( transactionsQueryJSON, ...weekGroup, formattedWeek, + shortFormattedWeek, keyForList: key, }; } @@ -3852,6 +3854,7 @@ function getQuarterSections( ? buildDateRangeGroupQuery(queryJSON, DateUtils.getQuarterDateRange(quarterGroup.year, quarterGroup.quarter))?.transactionsQueryJSON : undefined; const formattedQuarter = DateUtils.getFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter, dateFnsLocale); + const shortFormattedQuarter = DateUtils.getShortFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter); quarterSections[key] = { groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, @@ -3859,6 +3862,7 @@ function getQuarterSections( transactionsQueryJSON, ...quarterGroup, formattedQuarter, + shortFormattedQuarter, sortKey: quarterGroup.year * 10 + quarterGroup.quarter, // Sort by year*10 + quarter (e.g., 20241, 20242, etc.) keyForList: key, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 7ab4e8a78ec1..b95a1857fe10 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -165,6 +165,7 @@ import { getDisplayNamesWithTooltips, getIcons, getMovedTransactionMessage, + parseMovedTransactionReportIDs, getParticipantsAccountIDsForDisplay, getPendingDeleteMemberAccountIDs, getPolicyChangeLogCopyMessage, @@ -1248,7 +1249,14 @@ function getOptionData({ } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE) { result.alternateText = getPolicyChangeLogDeleteMemberMessage(translate, lastAction); } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - result.alternateText = Parser.htmlToText(getUnreportedTransactionMessage(translate, lastAction, reportAttributesDerived)); + const {fromReportID} = parseMovedTransactionReportIDs(lastAction); + result.alternateText = Parser.htmlToText( + getUnreportedTransactionMessage({ + translate, + fromReportID, + derivedReportName: fromReportID ? reportAttributesDerived?.[fromReportID]?.reportName : undefined, + }), + ); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_RATE) { result.alternateText = getReportActionMessageText(lastAction) ?? ''; } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION) { @@ -1332,7 +1340,15 @@ function getOptionData({ } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP) { result.alternateText = Parser.htmlToText(getUpdatedOwnershipMessage(translate, lastAction, policy)); } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { - result.alternateText = Parser.htmlToText(getMovedTransactionMessage(translate, lastAction, reportAttributesDerived)); + const {fromReportID, toReportID, displayReportID} = parseMovedTransactionReportIDs(lastAction); + result.alternateText = Parser.htmlToText( + getMovedTransactionMessage({ + translate, + fromReportID, + toReportID, + derivedReportName: displayReportID ? reportAttributesDerived?.[displayReportID]?.reportName : undefined, + }), + ); } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED)) { result.alternateText = Parser.htmlToText(getSettlementAccountLockedMessage(translate, lastAction)); } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 204babcaddbd..984891ffa3dd 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -19,9 +19,11 @@ import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import { + canSubmitPerDiemExpenseFromWorkspace, getCommaSeparatedTagNameWithSanitizedColons, getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, + getPerDiemCustomUnit, getTaxByID, isAttendeeTrackingEnabled as isAttendeeTrackingEnabledForPolicy, isInstantSubmitEnabled, @@ -34,11 +36,14 @@ import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/R import { getReportOrDraftReport, getReportTransactions, + getTransactionDetails, isCurrentUserSubmitter, isInvoiceReport, + isIOUReport, isOpenExpenseReport, isOpenReport, isProcessingReport, + isReportManager, isSettled, isThread, } from '@libs/ReportUtils'; @@ -54,6 +59,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type { Card, + CardList, OnyxInputOrEntry, PersonalDetails, Policy, @@ -2008,7 +2014,7 @@ function shouldShowBrokenConnectionViolationForMultipleTransactions( return false; } - return shouldShowViolation(report, policy, violation.name, currentUserEmail, true, transaction); + return shouldShowViolation(report, policy, violation.name, currentUserEmail, currentUserAccountID, true, transaction); }); }); @@ -2056,7 +2062,7 @@ function getVisibleTransactionViolations( transactionViolations.filter( (violation) => !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, iouReport, iouReportOwnerLogin, policy) && - shouldShowViolation(iouReport, policy, violation.name, currentUserEmail, shouldShowRterForSettledReport, transaction), + shouldShowViolation(iouReport, policy, violation.name, currentUserEmail, currentUserAccountID, shouldShowRterForSettledReport, transaction), ), ); } @@ -2069,10 +2075,11 @@ function shouldShowViolation( policy: OnyxEntry, violationName: ViolationName, currentUserEmail: string, + currentUserAccountID: number, shouldShowRterForSettledReport = true, transaction?: OnyxEntry, ): boolean { - const isSubmitter = isCurrentUserSubmitter(iouReport); + const isSubmitter = isCurrentUserSubmitter(iouReport, currentUserAccountID); const isPolicyMember = isPolicyMemberPolicyUtils(policy, currentUserEmail); const isReportOpen = isOpenExpenseReport(iouReport); if (violationName === CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE) { @@ -2080,7 +2087,9 @@ function shouldShowViolation( } if (violationName === CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT) { - return isPolicyAdmin(policy) && !isSubmitter && isProcessingReport(iouReport); + // Submitters are not shown this notice because they cannot act on it, but a submitter who is also the report's + // approver is the person who has to approve it manually, so they still need to know why it was not auto-approved. + return isPolicyAdmin(policy) && (!isSubmitter || isReportManager(iouReport, currentUserAccountID)) && isProcessingReport(iouReport); } if (violationName === CONST.VIOLATIONS.RTER) { @@ -2123,7 +2132,7 @@ function allHavePendingRTERViolation( const filteredTransactionViolations = getTransactionViolations(transaction, transactionViolations, currentUserEmail, currentUserAccountID, report, reportOwnerLogin, policy)?.filter( (violation) => // Further filter to only violations visible to the current user - shouldShowViolation(report, policy, violation.name, currentUserEmail, true, transaction), + shouldShowViolation(report, policy, violation.name, currentUserEmail, currentUserAccountID, true, transaction), ); // Check if there is pending rter violation in the filtered violations return hasPendingRTERViolation(filteredTransactionViolations); @@ -3252,6 +3261,91 @@ function createUnreportedExpenses(transactions: Array | u ); } +type GetEligibleTransactionsToAddParams = { + transactions: OnyxCollection; + report: OnyxEntry; + policy: OnyxEntry; + cardList: OnyxEntry; + currentUserAccountID: number | undefined; + reportID: string; + allOpenReports: Record | undefined; + openReportDrafts: Record | undefined; +}; + +/** + * Returns the transactions that can be added to the given expense or IOU report. + */ +function getEligibleTransactionsToAdd({ + transactions, + report, + policy, + cardList, + currentUserAccountID, + reportID, + allOpenReports, + openReportDrafts, +}: GetEligibleTransactionsToAddParams): Transaction[] { + if (!transactions) { + return []; + } + + const isIOU = isIOUReport(report); + const canSubmitPerDiemExpense = canSubmitPerDiemExpenseFromWorkspace(policy); + const workspacePerDiemUnitID = getPerDiemCustomUnit(policy)?.customUnitID; + + return Object.values(transactions).filter((transaction): transaction is Transaction => { + if (!transaction) { + return false; + } + + const isUnreported = isUnreportedTransaction(transaction); + if (isIOU && !isUnreported) { + return false; + } + + // Split expenses can't be moved to a 1:1 DM chat, so they must not be offered when adding to an IOU report + if (isIOU) { + const originalTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.comment?.originalTransactionID}`]; + const {isExpenseSplit: isExpenseSplitTransaction} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); + if (isExpenseSplitTransaction) { + return false; + } + } + + const isOnOpenExpenseReport = !!(transaction.reportID && (allOpenReports?.[transaction.reportID] ?? openReportDrafts?.[transaction.reportID])); + if (!isUnreported && !isOnOpenExpenseReport) { + return false; + } + + // Don't show expenses that are already on the current report + if (transaction.reportID === reportID) { + return false; + } + + // Check if the transaction belongs to the current user by verifying card ownership + if (transaction.cardID) { + const card = cardList?.[transaction.cardID]; + if (card?.accountID !== currentUserAccountID) { + return false; + } + } + + const transactionAmount = getTransactionDetails(transaction)?.amount ?? 0; + if (isIOU && transactionAmount <= 0) { + return false; + } + + if (isPerDiemRequest(transaction)) { + // Only show per diem expenses if the target workspace has per diem enabled and the per diem expense was created in the same workspace + const perDiemCustomUnitID = transaction.comment?.customUnit?.customUnitID; + + return canSubmitPerDiemExpense && (!perDiemCustomUnitID || perDiemCustomUnitID === workspacePerDiemUnitID); + } + + return true; + }); +} + function willFieldBeAutomaticallyFilled(transaction: OnyxEntry, fieldType: 'amount' | 'merchant' | 'date' | 'category'): boolean { if (!transaction?.receipt) { return false; @@ -3269,6 +3363,10 @@ function isExpenseUnreported(transaction?: Transaction): transaction is Unreport return transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; } +function isUnreportedTransaction(transaction: OnyxEntry): boolean { + return isExpenseUnreported(transaction ?? undefined) || transaction?.reportID === ''; +} + function isUnreportedManagedCardTransaction(transaction?: Transaction): boolean { return isExpenseUnreported(transaction) && isManagedCardTransaction(transaction); } @@ -3578,6 +3676,7 @@ export { isTransactionPendingDelete, getChildTransactions, createUnreportedExpenses, + getEligibleTransactionsToAdd, isDemoTransaction, shouldShowViolation, hasTransactionBeenRejected, @@ -3588,6 +3687,7 @@ export { getAttendeesListDisplayString, isCorporateCardTransaction, isExpenseUnreported, + isUnreportedTransaction, mergeProhibitedViolations, getVisibleTransactionViolations, getOriginalAttendees, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 63086368ff35..1c71a896636a 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -1203,7 +1203,7 @@ const ViolationsUtils = { return transactionViolations.some((violation: TransactionViolation) => { return ( !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, report, currentUserEmail, policy) && - shouldShowViolation(report, policy, violation.name, currentUserEmail, true, transaction) + shouldShowViolation(report, policy, violation.name, currentUserEmail, currentUserAccountID, true, transaction) ); }); }); diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 4a3905921de8..8b6747b2746a 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -3,22 +3,33 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {BankAccountList} from '@src/types/onyx'; import type {ApprovalWorkflowOnyx, Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type { + ApprovalWorkflowAction, + ApprovalWorkflowActions, + ApprovalWorkflowFilter, + ApprovalWorkflowFilterComparison, + ApprovalWorkflowRule, + ApprovalWorkflowTriggers, +} from '@src/types/onyx/ApprovalWorkflowRules'; import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; import type PersonalDetails from '@src/types/onyx/PersonalDetails'; import type Policy from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; +import type Rule from '@src/types/onyx/Rule'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; import {isBankAccountPartiallySetup} from './BankAccountUtils'; import {getHRAdvancedModeFinalApprover, getHRFinalApprover} from './HRUtils'; +import {rand64} from './NumberUtils'; import {getDefaultApprover, isExpensifyTeam, shouldFilterExpensifyTeam} from './PolicyUtils'; const INITIAL_APPROVAL_WORKFLOW: ApprovalWorkflowOnyx = { @@ -122,6 +133,9 @@ type PolicyConversionParams = { /** Current user's login email, used to determine if Expensify team members should be shown */ currentUserLogin?: string; + + /** The policy's approval-workflow rules keyed by ruleID */ + rules?: Record; }; type PolicyConversionResult = { @@ -727,15 +741,902 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all return [...workflowMembers, ...additionalMembers]; } +type ApprovalWorkflowRulesDiff = Record; + +function buildComparison( + left: ApprovalWorkflowFilterComparison['left'], + operator: ValueOf, + right: ApprovalWorkflowFilterComparison['right'], +): ApprovalWorkflowFilterComparison { + return {operator, left, right}; +} + +function buildAnd(left: ApprovalWorkflowFilter['left'], right: ApprovalWorkflowFilter['right']): ApprovalWorkflowFilter { + return {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left, right}; +} + +function buildSubmitterFilter(memberEmails: string[]): ApprovalWorkflowFilterComparison { + return buildComparison(CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, [...memberEmails]); +} + +function buildToComparison(email: string): ApprovalWorkflowFilterComparison { + return buildComparison(CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, email); +} + +/** The index-keyed object shape the rules API uses for lists (`['a', 'b'] -> {'0': 'a', '1': 'b'}`). */ +function toIndexMap(values: T[]): Record { + return Object.fromEntries(values.map((value, index) => [String(index), value])); +} + +function buildSubmitTriggers(): ApprovalWorkflowTriggers { + return toIndexMap([CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT]); +} + +function buildApproveTriggers(): ApprovalWorkflowTriggers { + return toIndexMap([CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE]); +} + +function buildForwardActions(approver: string): ApprovalWorkflowActions { + return toIndexMap([{name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO, approver}]); +} + +function buildApproveActions(): ApprovalWorkflowActions { + return toIndexMap([{name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT}]); +} + +/** + * Build the AST rule chain for a single `ApprovalWorkflow`: a `ReportSubmit` rule forwarding members' + * reports to the first approver, plus a `ReportApprove` rule per approver describing the next hop + * (forward to the next approver, approve if last, or split on the approver's over-limit target). + */ +function getWorkflowMemberEmails(members: Member[]): string[] { + return members.map((member) => member.email).filter((email): email is string => !!email); +} + +function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): ApprovalWorkflowRule[] { + const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); + const approvers = approvalWorkflow.approvers; + + if (memberEmails.length === 0 || approvers.length === 0) { + return []; + } + + const firstApproverEmail = approvers.at(0)?.email; + if (!firstApproverEmail) { + return []; + } + + const fromComparison = buildSubmitterFilter(memberEmails); + const rules: ApprovalWorkflowRule[] = []; + + /** + * An approver's approval limit decides who holds the report at that position, not where it goes afterwards: a + * report over the limit is received by `overLimitForwardsTo` *instead of* the approver, and then carries on to the + * next approver as normal. So each position has one or two people who can hold it, and the hop into the position + * is what branches on the amount. + */ + const holdersAtPosition = approvers.map((approver) => { + const hasLimitSplit = !!approver.approvalLimit && approver.approvalLimit > 0 && !!approver.overLimitForwardsTo; + return { + underLimitEmail: approver.email, + overLimitEmail: hasLimitSplit ? approver.overLimitForwardsTo : undefined, + limit: hasLimitSplit ? approver.approvalLimit : undefined, + }; + }); + + /** The rules routing a report into `position`, from either submission or the approvers at the position before. */ + const buildRulesIntoPosition = (position: number): ApprovalWorkflowRule[] => { + const holders = holdersAtPosition.at(position); + if (!holders?.underLimitEmail) { + return []; + } + + const isFirstPosition = position === 0; + const triggers = isFirstPosition ? buildSubmitTriggers() : buildApproveTriggers(); + + // Everyone who can hold the previous position routes onward the same way, so each of them needs a rule. + const previousHolders = isFirstPosition ? [undefined] : [holdersAtPosition.at(position - 1)?.underLimitEmail, holdersAtPosition.at(position - 1)?.overLimitEmail]; + + const rulesIntoPosition: ApprovalWorkflowRule[] = []; + for (const previousHolderEmail of previousHolders) { + if (!isFirstPosition && !previousHolderEmail) { + continue; + } + + const source = previousHolderEmail ? buildAnd(fromComparison, buildToComparison(previousHolderEmail)) : fromComparison; + + if (holders.overLimitEmail == null || holders.limit == null) { + rulesIntoPosition.push({triggers, filters: source, actions: buildForwardActions(holders.underLimitEmail)}); + continue; + } + + const underAmount = buildComparison(CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, holders.limit); + const overAmount = buildComparison(CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, holders.limit); + + rulesIntoPosition.push({triggers, filters: buildAnd(source, underAmount), actions: buildForwardActions(holders.underLimitEmail)}); + rulesIntoPosition.push({triggers, filters: buildAnd(source, overAmount), actions: buildForwardActions(holders.overLimitEmail)}); + } + + return rulesIntoPosition; + }; + + for (let position = 0; position < holdersAtPosition.length; position++) { + rules.push(...buildRulesIntoPosition(position)); + } + + // Whoever holds the last position finalizes the report. + const lastHolders = holdersAtPosition.at(-1); + for (const lastHolderEmail of [lastHolders?.underLimitEmail, lastHolders?.overLimitEmail]) { + if (!lastHolderEmail) { + continue; + } + rules.push({ + triggers: buildApproveTriggers(), + filters: buildAnd(fromComparison, buildToComparison(lastHolderEmail)), + actions: buildApproveActions(), + }); + } + + // Different approvers may share an `overLimitForwardsTo`, which would emit identical terminal rules. + const seen = new Set(); + return rules.filter((rule) => { + const fingerprint = JSON.stringify(sortObjectKeysDeep(rule)); + if (seen.has(fingerprint)) { + return false; + } + seen.add(fingerprint); + return true; + }); +} + +/** + * True when this node is a single comparison like `from = alice@expensify.com`, rather than an `AND` that + * joins two other nodes. + * + * Both look the same (`{operator, left, right}`), so the giveaway is `left`: a comparison points at a field + * name, an `AND` points at another node. + */ +function isComparisonLeaf(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): node is ApprovalWorkflowFilterComparison { + return !!node && typeof node.left === 'string'; +} + +/** True when a comparison node targets the `from` field with an equality operator. */ +function isSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): boolean { + return isComparisonLeaf(node) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && node.left === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM; +} + +/** Return the first comparison leaf in the filter tree whose `left` field matches. */ +function getFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, leftKey: string): ApprovalWorkflowFilterComparison | undefined { + if (!node) { + return undefined; + } + if (isComparisonLeaf(node)) { + return node.left === leftKey ? node : undefined; + } + return getFilter(node.left, leftKey) ?? getFilter(node.right, leftKey); +} + +/** Rebuild a filter tree, replacing every comparison leaf with the result of `mapLeaf`. */ +function mapFilters( + node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison, + mapLeaf: (leaf: ApprovalWorkflowFilterComparison) => ApprovalWorkflowFilterComparison, +): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison { + if (isComparisonLeaf(node)) { + return mapLeaf(node); + } + return {...node, left: mapFilters(node.left, mapLeaf), right: mapFilters(node.right, mapLeaf)}; +} + +/** The emails a rule's `from` filter lists. */ +function extractSubmitterEmails(rule: ApprovalWorkflowRule): string[] { + const submitterFilter = getFilter(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM); + if (!submitterFilter || !isSubmitterFilter(submitterFilter)) { + return []; + } + + const emails = Array.isArray(submitterFilter.right) ? submitterFilter.right : [submitterFilter.right]; + return Array.from(new Set(emails.filter((email): email is string => typeof email === 'string'))); +} + +/** + * Rewrite a value so that `JSON.stringify` gives the same string for two structures that mean the same thing. + * + * Only key order needs fixing: a rule that came back from the server can list the same keys in a different + * order than one we just built, and those two would otherwise produce different strings and never match. + */ +function sortObjectKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortObjectKeysDeep); + } + if (value !== null && typeof value === 'object') { + // Byte-order sort (not locale-aware): this is a structural fingerprint, not user-facing text. + const sortedEntries = Object.entries(value).sort(([a], [b]) => { + if (a === b) { + return 0; + } + return a < b ? -1 : 1; + }); + return Object.fromEntries(sortedEntries.map(([key, val]) => [key, sortObjectKeysDeep(val)])); + } + return value; +} + +/** + * Return a structural fingerprint of a rule with every `from` leaf's `right` (the submitter list) + * stripped and object keys sorted. Two rules with the same fingerprint differ only in their submitters, + * which is what we look for when deciding whether to merge two workflows into a shared rule. + */ +function structuralFingerprint(rule: ApprovalWorkflowRule): string { + const stripFromValues = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): unknown => { + if (!node) { + return node; + } + if (isComparisonLeaf(node)) { + if (isSubmitterFilter(node)) { + return {operator: node.operator, left: node.left}; + } + return {operator: node.operator, left: node.left, right: node.right}; + } + return {operator: node.operator, left: stripFromValues(node.left), right: stripFromValues(node.right)}; + }; + + return JSON.stringify( + sortObjectKeysDeep({ + triggers: rule.triggers, + filters: stripFromValues(rule.filters), + actions: rule.actions, + }), + ); +} + +/** Replace the `right` value on every `from` leaf with `newEmails`. */ +function replaceSubmitterEmails(rule: ApprovalWorkflowRule, newEmails: string[]): ApprovalWorkflowRule { + return {...rule, filters: mapFilters(rule.filters, (leaf) => (isSubmitterFilter(leaf) ? {...leaf, right: [...newEmails]} : leaf))}; +} + +/** Merge `emailsToAdd` into `existingEmails`, preserving the order of `existingEmails` and dropping duplicates. */ +function mergeEmails(existingEmails: string[], emailsToAdd: string[]): string[] { + return [...new Set([...existingEmails, ...emailsToAdd])]; +} + +/** Remove everything in `emailsToRemove` from `existingEmails`, preserving the order of `existingEmails`. */ +function removeEmails(existingEmails: string[], emailsToRemove: string[]): string[] { + const removalSet = new Set(emailsToRemove); + return existingEmails.filter((email) => !removalSet.has(email)); +} + +/** Fold `memberEmails` into a rule's `from` list (union), returning the updated rule. */ +function addMembersToRule(rule: ApprovalWorkflowRule, memberEmails: string[]): ApprovalWorkflowRule { + return replaceSubmitterEmails(rule, mergeEmails(extractSubmitterEmails(rule), memberEmails)); +} + +/** Rewrite a rule's `from` list, or drop the rule entirely (`null`) when no submitters remain. */ +function rewriteOrRemoveRule(rule: ApprovalWorkflowRule, remainingEmails: string[]): ApprovalWorkflowRule | null { + return remainingEmails.length === 0 ? null : replaceSubmitterEmails(rule, remainingEmails); +} + +type ReconcileContext = { + /** The policy's existing approval-workflow rules (`ruleID -> rule body`), defaulting to `{}`. */ + existingRules: Record; +}; + +/** + * Reconcile a freshly created workflow against the existing rules. New rules that match an + * existing rule's structure (ignoring `from`) are folded into that existing rule by appending + * the new workflow's members to its `from` list. Anything left over is saved under a fresh + * client-generated ruleID. + */ +function reconcileApprovalWorkflowRulesForCreate(newRules: ApprovalWorkflowRule[], memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { + const diff: ApprovalWorkflowRulesDiff = {}; + const existingEntries = Object.entries(context.existingRules); + + for (const newRule of newRules) { + const fingerprint = structuralFingerprint(newRule); + const match = existingEntries.find(([, existing]) => structuralFingerprint(existing) === fingerprint); + + if (match) { + const [existingID, existingRule] = match; + diff[existingID] = addMembersToRule(existingRule, memberEmails); + continue; + } + + diff[rand64()] = newRule; + } + + return diff; +} + +/** + * Reconcile an edit to an existing workflow into add/replace/remove instructions, preserving rules shared + * with other workflows. + */ +function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { + const diff: ApprovalWorkflowRulesDiff = {}; + const memberSet = new Set(memberEmails); + const newFingerprints = new Set(newRules.map(structuralFingerprint)); + + // Track which existing rules we've already turned into a no-op match so we don't drop them in pass 2. + const matchedExistingIDs = new Set(); + + // Pass 1: walk existing rules that belong (at least partially) to this workflow. + for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { + const ruleEmails = extractSubmitterEmails(existingRule); + if (!ruleEmails.some((email) => memberSet.has(email))) { + continue; + } + + const fingerprint = structuralFingerprint(existingRule); + if (newFingerprints.has(fingerprint)) { + // Structurally identical to a new rule: leave it alone but remember it as "covered". + matchedExistingIDs.add(ruleID); + continue; + } + + // Keep the rule (minus our members) for any other workflow that still needs it; drop it otherwise. + diff[ruleID] = rewriteOrRemoveRule(existingRule, removeEmails(ruleEmails, memberEmails)); + } + + // Pass 2: create or extend rules for any new rule that wasn't already covered. + const existingEntries = Object.entries(context.existingRules); + for (const newRule of newRules) { + const fingerprint = structuralFingerprint(newRule); + + // Already in place via a pass-1 match — nothing to do. + const alreadyCovered = existingEntries.some(([id, existing]) => matchedExistingIDs.has(id) && structuralFingerprint(existing) === fingerprint); + if (alreadyCovered) { + continue; + } + + // Look for a rule belonging to a different workflow that we can fold into. + const foreignMatch = existingEntries.find(([id, existing]) => { + if (matchedExistingIDs.has(id)) { + return false; + } + if (structuralFingerprint(existing) !== fingerprint) { + return false; + } + // "Different workflow" => no overlap with our members. + return !extractSubmitterEmails(existing).some((email) => memberSet.has(email)); + }); + + if (foreignMatch) { + const [existingID, existingRule] = foreignMatch; + diff[existingID] = addMembersToRule(existingRule, memberEmails); + continue; + } + + diff[rand64()] = newRule; + } + + return diff; +} + +/** + * Reconcile the deletion of a workflow. Rules that listed only this workflow's members in + * their `from` filter are removed outright; rules shared with other workflows have this workflow's members + * stripped from `from`. + */ +function reconcileApprovalWorkflowRulesForRemove(memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { + const diff: ApprovalWorkflowRulesDiff = {}; + const memberSet = new Set(memberEmails); + + for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { + const ruleEmails = extractSubmitterEmails(existingRule); + if (!ruleEmails.some((email) => memberSet.has(email))) { + continue; + } + + diff[ruleID] = rewriteOrRemoveRule(existingRule, removeEmails(ruleEmails, memberEmails)); + } + + return diff; +} + +/** + * Reconcile a member-only change (the approver chain is unchanged but the workflow's + * member list changed). For every existing rule that includes any of the previous members + * we add the new members and drop any previous members that aren't part of the new set. + */ +function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: string[], newMemberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { + const diff: ApprovalWorkflowRulesDiff = {}; + const previousSet = new Set(previousMemberEmails); + const newSet = new Set(newMemberEmails); + const removed = previousMemberEmails.filter((email) => !newSet.has(email)); + + for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { + const ruleEmails = extractSubmitterEmails(existingRule); + if (!ruleEmails.some((email) => previousSet.has(email))) { + continue; + } + + const updatedEmails = mergeEmails(removeEmails(ruleEmails, removed), newMemberEmails); + + // No effective change to this rule's `from` — skip the round-trip. + if (updatedEmails.length === ruleEmails.length && updatedEmails.every((email, idx) => email === ruleEmails.at(idx))) { + continue; + } + + diff[ruleID] = rewriteOrRemoveRule(existingRule, updatedEmails); + } + + return diff; +} + +/** Apply an `ApprovalWorkflowRulesDiff` to a rule map, returning a new map. A `null` value removes the rule. */ +function applyApprovalWorkflowRulesDiff(existingRules: Record, diff: ApprovalWorkflowRulesDiff): Record { + const result: Record = {...existingRules}; + for (const [ruleID, value] of Object.entries(diff)) { + if (value === null) { + delete result[ruleID]; + } else { + result[ruleID] = value; + } + } + return result; +} + +/** The triggers of a rule as a flat list. */ +function getRuleTriggers(rule: ApprovalWorkflowRule): Array> { + return Object.values(rule.triggers ?? {}); +} + +/** The actions of a rule as a flat list. */ +function getRuleActions(rule: ApprovalWorkflowRule): ApprovalWorkflowAction[] { + return Object.values(rule.actions ?? {}); +} + +/** True when the rule fires on report submission. */ +function isSubmitRule(rule: ApprovalWorkflowRule): boolean { + return getRuleTriggers(rule).includes(CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT); +} + +/** True when the rule approves (finalizes) the report. */ +function isApproveReportRule(rule: ApprovalWorkflowRule): boolean { + return getRuleActions(rule).some((action) => action.name === CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT); +} + +/** The approver a `ForwardTo` rule routes to, if any. */ +function getForwardApprover(rule: ApprovalWorkflowRule): string | undefined { + return getRuleActions(rule).find((action) => action.name === CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO)?.approver; +} + +/** + * Who approves this person's report first, right after they submit it? + * + * Finds the rule that fires on submit for this submitter and returns whoever it forwards the report to. + * A workspace set up before approval rules existed has no such rule until its workflow is saved again, so + * we fall back to the older `submitsTo` field on the employee. + */ +function resolveFirstApprover(submitter: string, rules: Record, employees: PolicyEmployeeList): string | undefined { + const firstPosition = resolvePositionHolders(submitter, rules, undefined); + if (firstPosition?.underLimitEmail) { + return firstPosition.underLimitEmail; + } + return employees[submitter]?.submitsTo; +} + +/** Who can hold one position in a submitter's approval chain. */ +type PositionHolders = { + /** Who holds the position for a report under the limit, or for any report when there is no limit. */ + underLimitEmail?: string; + + /** Who holds it instead once the report reaches the limit. */ + overLimitEmail?: string; + + /** The limit that decides between the two. */ + approvalLimit?: number; + + /** True when the routing rules approve the report at this point rather than passing it on. */ + isTerminal: boolean; +}; + +/** + * Read who holds the position a report reaches from `sourceApprover` — or from submission when `sourceApprover` is + * undefined. + * + * An approval limit belongs to the position it guards, not to the approver before it: the rules routing into a + * position carry the amount split, so a report at or over the limit is received by `overLimitEmail` *instead of* + * `underLimitEmail`. Returns undefined when no rule covers this step, so callers can fall back to `employeeList`. + */ +function resolvePositionHolders(submitter: string, rules: Record, sourceApprover: string | undefined): PositionHolders | undefined { + const holders: PositionHolders = {isTerminal: false}; + let didMatchRule = false; + + for (const rule of Object.values(rules)) { + if (!extractSubmitterEmails(rule).includes(submitter)) { + continue; + } + + const isFromSubmission = sourceApprover === undefined; + if (isFromSubmission !== isSubmitRule(rule)) { + continue; + } + + const toLeaf = getFilter(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO); + if (isFromSubmission ? !!toLeaf : toLeaf?.right !== sourceApprover) { + continue; + } + + didMatchRule = true; + + if (isApproveReportRule(rule)) { + holders.isTerminal = true; + continue; + } + + const amountLeaf = getFilter(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT); + const isOverLimitBranch = + !!amountLeaf && (amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO || amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN); + + if (amountLeaf && typeof amountLeaf.right === 'number') { + holders.approvalLimit = holders.approvalLimit ?? amountLeaf.right; + } + + if (isOverLimitBranch) { + holders.overLimitEmail = getForwardApprover(rule) ?? holders.overLimitEmail; + } else { + holders.underLimitEmail = getForwardApprover(rule) ?? holders.underLimitEmail; + } + } + + if (!didMatchRule) { + return undefined; + } + + // A limit only means anything when both halves of the split are known. + if (!holders.overLimitEmail) { + holders.approvalLimit = undefined; + } + + return holders; +} + +type BuildApproverChainFromRulesParams = { + submitter: string; + rules: Record; + employees: PolicyEmployeeList; + personalDetailsByEmail: PersonalDetailsList; +}; + +/** + * Walk a submitter's approval chain forward. The first approver comes from the `ReportSubmit` rule; + * for each approver we look at the `to == approver` rules to learn that approver's own + * `approvalLimit` / `overLimitForwardsTo` and where the report goes next (or whether it is approved). + * Any step the rules don't cover falls back to the legacy `employeeList` fields. + */ +function buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail}: BuildApproverChainFromRulesParams): Approver[] { + // Walk the positions forward first: an approver's own row needs to name the next position's holder, which is only + // known once that position has been read. + const positions: PositionHolders[] = []; + const seenEmails = new Set(); + let sourceApprover: string | undefined; + let isFirstPosition = true; + + while (true) { + const holders = resolvePositionHolders(submitter, rules, sourceApprover); + + // Nothing covers this step, so fall back to the legacy employeeList fields for it. + const fallbackEmail = isFirstPosition ? employees[submitter]?.submitsTo : employees[sourceApprover ?? '']?.forwardsTo; + const nextEmail = holders?.isTerminal ? undefined : (holders?.underLimitEmail ?? (holders ? undefined : fallbackEmail)); + if (!nextEmail) { + break; + } + + const fallbackEmployee: PolicyEmployee | undefined = employees[nextEmail]; + positions.push({ + isTerminal: false, + underLimitEmail: nextEmail, + overLimitEmail: holders?.overLimitEmail ?? (holders ? undefined : fallbackEmployee?.overLimitForwardsTo), + approvalLimit: holders?.approvalLimit ?? (holders ? undefined : (fallbackEmployee?.approvalLimit ?? undefined)), + }); + + if (seenEmails.has(nextEmail)) { + break; + } + seenEmails.add(nextEmail); + sourceApprover = nextEmail; + isFirstPosition = false; + } + + return positions.map((position, index) => { + const email = position.underLimitEmail ?? ''; + const employee: PolicyEmployee | undefined = employees[email]; + const isCircularReference = positions.findIndex((other) => other.underLimitEmail === email) !== index; + + return { + email, + forwardsTo: isCircularReference ? undefined : positions.at(index + 1)?.underLimitEmail, + avatar: personalDetailsByEmail[email]?.avatar, + displayName: personalDetailsByEmail[email]?.displayName ?? email, + isCircularReference, + approvalLimit: position.approvalLimit ?? null, + overLimitForwardsTo: position.overLimitEmail, + overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(position.overLimitEmail, personalDetailsByEmail), + pendingAction: employee?.pendingAction, + errors: employee?.errors, + }; + }); +} + +/** Structural identity of a chain — used to fold submitters with identical chains into one workflow. */ +function approverChainFingerprint(chain: Approver[]): string { + return JSON.stringify( + chain.map((approver) => ({ + email: approver.email, + approvalLimit: approver.approvalLimit ?? null, + overLimitForwardsTo: approver.overLimitForwardsTo ?? null, + isCircularReference: !!approver.isCircularReference, + })), + ); +} + +/** The sorted set of ruleIDs whose `from` filter includes this submitter — their exact rule membership. */ +function getSubmitterRuleIDs(submitter: string, rules: Record): string[] { + return Object.entries(rules) + .filter(([, rule]) => extractSubmitterEmails(rule).includes(submitter)) + .map(([ruleID]) => ruleID) + .sort(); +} + +/** + * Keep only the rules belonging to `policyID`, preserving the collection shape so the result can still be + * handed to anything that takes the whole rules collection. + */ +function filterRulesForPolicy(rulesCollection: OnyxCollection, policyID: string | undefined): NonNullable> { + const result: NonNullable> = {}; + if (!rulesCollection || !policyID) { + return result; + } + + for (const [onyxKey, rule] of Object.entries(rulesCollection)) { + if (rule?.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { + continue; + } + result[onyxKey] = rule; + } + + return result; +} + +/** + * Convert the `ONYXKEYS.COLLECTION.RULE` collection into the `ruleID -> rule body` map used by the + * builder, reconcilers and converters, keeping only non-deleted rules scoped to `policyID`. + */ +function getApprovalWorkflowRulesForPolicy(rulesCollection: OnyxCollection | undefined, policyID: string | undefined): Record { + const result: Record = {}; + + for (const [onyxKey, rule] of Object.entries(filterRulesForPolicy(rulesCollection, policyID))) { + if (!rule || rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + const ruleID = onyxKey.slice(ONYXKEYS.COLLECTION.RULE.length); + result[ruleID] = { + triggers: toIndexMap(Object.values(rule.triggers ?? {})), + filters: rule.filters, + actions: toIndexMap(Object.values(rule.actions ?? {})), + }; + } + + return result; +} + +/** Map every submitter found in the rules to their workflow's first approver. */ +function getRulesSubmitterToFirstApprover(rules: Record, employees: PolicyEmployeeList = {}): Record { + const submitters = new Set(); + for (const rule of Object.values(rules)) { + for (const email of extractSubmitterEmails(rule)) { + submitters.add(email); + } + } + + const result: Record = {}; + for (const submitter of submitters) { + const firstApprover = resolveFirstApprover(submitter, rules, employees); + if (firstApprover) { + result[submitter] = firstApprover; + } + } + return result; +} + +/** + * Map every submitter found in the rules to a stable identity of the workflow they belong to (a fingerprint + * of their full approver chain). Unlike `getRulesSubmitterToFirstApprover`, this distinguishes workflows that + * share a first approver but diverge later, so callers can detect a genuine cross-workflow move rather than + * treating "same first approver" as "same workflow". + */ +function getRulesSubmitterToWorkflowKey(rules: Record, employees: PolicyEmployeeList = {}): Record { + const submitters = new Set(); + for (const rule of Object.values(rules)) { + for (const email of extractSubmitterEmails(rule)) { + submitters.add(email); + } + } + + const result: Record = {}; + for (const submitter of submitters) { + // The fingerprint ignores display/avatar fields, so personal details aren't needed here. + const chain = buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail: {}}); + if (chain.length > 0) { + result[submitter] = approverChainFingerprint(chain); + } + } + return result; +} + +/** The submitters and approver chain of one workflow, accumulated while grouping employees by fingerprint. */ +type WorkflowGroup = { + chain: Approver[]; + members: Member[]; + isDefault: boolean; + pendingAction: ApprovalWorkflow['pendingAction']; +}; + +/** + * Beta-enabled counterpart to `convertPolicyEmployeesToApprovalWorkflows`: rebuild the same + * `PolicyConversionResult` from `params.rules`, falling back to `employeeList` for any chain step the + * rules don't cover. + */ +function convertApprovalWorkflowRulesToWorkflows({ + policy, + personalDetails, + firstApprover, + localeCompare, + currentUserLogin, + rules: rulesParam, +}: PolicyConversionParams): PolicyConversionResult { + const employees = policy?.employeeList ?? {}; + const rules = rulesParam ?? {}; + const defaultApprover = getHRFinalApprover(policy) ?? getDefaultApprover(policy); + const shouldFilterOutExpensifyTeam = shouldFilterExpensifyTeam(policy?.owner, currentUserLogin); + const hrAdvancedModeFinalApproverEmail = getHRAdvancedModeFinalApprover(policy); + + const personalDetailsByEmail: PersonalDetailsList = {}; + for (const [key, value] of Object.entries(personalDetails)) { + personalDetailsByEmail[value?.login ?? key] = value; + } + + // Keyed by a source-tagged fingerprint so a legacy chain and a rule-based chain with the same shape stay + // in separate workflows. Tag values: 'r' (any rule mentions the submitter) or 'l'. + const groupedByFingerprint = new Map(); + const usedApproverEmails = new Set(); + const availableMembers: Member[] = []; + + for (const employee of Object.values(employees)) { + const {email, submitsTo, pendingAction} = employee; + if (!email) { + continue; + } + if (shouldFilterOutExpensifyTeam && isExpensifyTeam(email)) { + continue; + } + + const member = buildMemberFromEmployee(employee, personalDetailsByEmail, email); + + if (pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + availableMembers.push(member); + } + + const hasInitialRule = !!resolveFirstApprover(email, rules, {}); + if (!hasInitialRule && (!submitsTo || (!employees[submitsTo] && !hrAdvancedModeFinalApproverEmail))) { + // Mirrors the existing legacy filter: skip submitters whose first approver isn't reachable. + continue; + } + + let chain = buildApproverChainFromRules({submitter: email, rules, employees, personalDetailsByEmail}); + if (chain.length === 0) { + continue; + } + + if (shouldFilterOutExpensifyTeam) { + chain = chain.filter((approver) => !isExpensifyTeam(approver.email)); + } + if (hrAdvancedModeFinalApproverEmail) { + const last = chain.at(-1); + if (last && last.email !== hrAdvancedModeFinalApproverEmail) { + chain.push({ + email: hrAdvancedModeFinalApproverEmail, + forwardsTo: undefined, + avatar: personalDetailsByEmail[hrAdvancedModeFinalApproverEmail]?.avatar, + displayName: personalDetailsByEmail[hrAdvancedModeFinalApproverEmail]?.displayName ?? hrAdvancedModeFinalApproverEmail, + isCircularReference: false, + }); + usedApproverEmails.add(hrAdvancedModeFinalApproverEmail); + } + } + if (chain.length === 0) { + continue; + } + + const firstApproverEmail = chain.at(0)?.email; + if (firstApproverEmail !== firstApprover) { + for (const approver of chain) { + usedApproverEmails.add(approver.email); + } + } + + // Group by resolved approver chain (not by ruleID set) so submitters routing through the same chain + // render as one card even when their rules are stored as separate pairs. The `r`/`l` tag keeps + // rule-based chains separate from legacy employeeList chains. + const hasRuleBasedChain = getSubmitterRuleIDs(email, rules).length > 0; + const chainKey = approverChainFingerprint(chain); + const fingerprint = hasRuleBasedChain ? `r|${chainKey}` : `l|${chainKey}`; + const existingGroup = groupedByFingerprint.get(fingerprint); + + if (existingGroup) { + if (pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + existingGroup.members.push(member); + } + if (pendingAction && pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + existingGroup.pendingAction = pendingAction; + } + continue; + } + + const workflowPendingAction = pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? pendingAction : undefined; + groupedByFingerprint.set(fingerprint, { + chain, + members: pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? [member] : [], + isDefault: firstApproverEmail === defaultApprover, + pendingAction: workflowPendingAction, + }); + } + + const sortedApprovalWorkflows: ApprovalWorkflow[] = Array.from(groupedByFingerprint.values()) + .map(({chain, members, isDefault, pendingAction}) => ({ + members, + approvers: chain, + isDefault, + pendingAction, + })) + .sort((a, b) => { + if (a.isDefault) { + return -1; + } + if (b.isDefault) { + return 1; + } + return localeCompare(a.approvers.at(0)?.displayName ?? '', b.approvers.at(0)?.displayName ?? ''); + }); + + const firstWorkflow = sortedApprovalWorkflows.at(0); + if (firstWorkflow && !firstWorkflow.isDefault) { + sortedApprovalWorkflows.unshift({ + members: [], + approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail}), + isDefault: true, + }); + } + + availableMembers.sort((a, b) => localeCompare(a.displayName ?? a.email, b.displayName ?? b.email)); + + return {approvalWorkflows: sortedApprovalWorkflows, usedApproverEmails: [...usedApproverEmails], availableMembers}; +} + export { + applyApprovalWorkflowRulesDiff, + approverChainFingerprint, + buildApprovalWorkflowRules, calculateApprovers, + convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, + extractSubmitterEmails, getApprovalLimitDescription, + getApprovalWorkflowRulesForPolicy, + filterRulesForPolicy, + getRulesSubmitterToFirstApprover, + getRulesSubmitterToWorkflowKey, + getWorkflowMemberEmails, getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, getOverLimitForwardsToDisplayName, INITIAL_APPROVAL_WORKFLOW, mergeWorkflowMembersWithAvailableMembers, + reconcileApprovalWorkflowRulesForCreate, + reconcileApprovalWorkflowRulesForEdit, + reconcileApprovalWorkflowRulesForMembersChange, + reconcileApprovalWorkflowRulesForRemove, updateWorkflowDataOnApproverRemoval, }; +export type {ApprovalWorkflowRulesDiff}; diff --git a/src/libs/actions/Attachment/index.native.ts b/src/libs/actions/Attachment/index.native.ts index e3fd4c0ac055..624bec0ef605 100644 --- a/src/libs/actions/Attachment/index.native.ts +++ b/src/libs/actions/Attachment/index.native.ts @@ -88,7 +88,11 @@ async function getCachedAttachment({attachmentID, attachment, currentSource}: Ge // exists. If it was purged, fall back to the current source and re-cache it. const localFileExists = await RNFS.exists(localSource); if (localFileExists) { - return localSource; + // The path is stored without a scheme so RNFS file operations (exists/unlink) accept it, but + // React Native's on Android only loads a local file when it carries a `file://` + // scheme (a bare path renders as a broken thumbnail), so add the scheme before the path + // reaches the image renderer. + return localSource.startsWith('file://') ? localSource : `file://${localSource}`; } cacheAttachment({attachmentID, uri: currentSource}); } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index b62b6e779fc3..2aabddb4b404 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -25,6 +25,7 @@ import type AskForCorpaySignerInformationParams from '@libs/API/parameters/AskFo import type {SaveCorpayOnboardingCompanyDetails} from '@libs/API/parameters/SaveCorpayOnboardingCompanyDetailsParams'; import type SaveCorpayOnboardingDirectorInformationParams from '@libs/API/parameters/SaveCorpayOnboardingDirectorInformationParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {getInternationalBankAccountDetailsValues} from '@libs/BankAccountUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -169,6 +170,32 @@ function openPersonalBankAccountSetupView({ }); } +/** + * Fetches the reimbursement countries for each of the user's policies and merges them into the policy_ Onyx + * collection. The personal bank account setup flow uses policy.reimbursement.enabled/countries to decide whether to + * collect international deposit details (IBAN/SWIFT), and this data is intentionally not part of the policy summary. + */ +function openDepositAccountSetup() { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP, + value: true, + }, + ], + finallyData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP, + value: false, + }, + ], + }; + + API.read(READ_COMMANDS.OPEN_DEPOSIT_ACCOUNT_SETUP, null, onyxData); +} + /** * Open the personal bank account setup flow using Plaid, with an optional exitReportID to redirect to once the flow is finished. */ @@ -510,6 +537,8 @@ function addPersonalBankAccount( addressZip: account?.addressZipCode, addressCountry: account?.country, confirmedOwnershipDetails: account?.confirmedOwnershipDetails, + iban: account?.iban, + swiftCode: account?.swiftCode, }; if (policyID) { parameters.policyID = policyID; @@ -1563,8 +1592,13 @@ function unshareBankAccount(bankAccountID: number, ownerEmail: string) { } function createCorpayBankAccountForWalletFlow(data: InternationalBankAccountForm, classification: string, destinationCountry: string, preferredMethod: string) { + // The international details step is skipped when the Corpay bank-details step already collected equivalent values, + // so resolve them here to guarantee the IBAN/SWIFT are always sent when we have them, whichever step captured them. + const {iban, swiftCode} = getInternationalBankAccountDetailsValues(data.iban, data.swiftCode, data.accountNumber, data.swiftBicCode); const inputData = { ...data, + iban, + swiftCode, classification, destinationCountry, preferredMethod, @@ -1885,6 +1919,7 @@ export { deletePaymentBankAccount, handlePlaidError, openPersonalBankAccountSetupView, + openDepositAccountSetup, openReimbursementAccountPage, updateBeneficialOwnersForBankAccount, updateCompanyInformationForBankAccount, diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 8c70922c76e9..dec9ea4b7cde 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -1127,6 +1127,9 @@ function updateExpensifyCardLimitType( validThru?: string, shouldClearValidityDates?: boolean, ) { + const normalizedValidFrom = validFrom ? DateUtils.normalizeDateToStartOfDay(validFrom, timeZone) : undefined; + const normalizedValidThru = validThru ? DateUtils.normalizeDateToEndOfDay(validThru, timeZone) : undefined; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1140,8 +1143,8 @@ function updateExpensifyCardLimitType( validFrom: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, validThru: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - validFrom: shouldClearValidityDates ? null : validFrom, - validThru: shouldClearValidityDates ? null : validThru, + validFrom: shouldClearValidityDates ? null : normalizedValidFrom, + validThru: shouldClearValidityDates ? null : normalizedValidThru, }, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: {availableSpend: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, @@ -1193,8 +1196,8 @@ function updateExpensifyCardLimitType( const parameters: UpdateExpensifyCardLimitTypeParams = { cardID, limitType: newLimitType, - validFrom: validFrom ? DateUtils.normalizeDateToStartOfDay(validFrom, timeZone) : undefined, - validThru: validThru ? DateUtils.normalizeDateToEndOfDay(validThru, timeZone) : undefined, + validFrom: normalizedValidFrom, + validThru: normalizedValidThru, clearValidityDates: shouldClearValidityDates, }; diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index b62ceac75be5..2c8926bb4bf5 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -53,6 +53,11 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ONYXKEYS.IS_DEBUG_MODE_ENABLED, ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS, ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS, + + // Keep personal details through the switch so the nav avatar and user names render + // instantly instead of showing a skeleton while OpenApp responds. OpenApp overwrites + // this key with fresh data when its response lands, so stale entries are short lived. + ONYXKEYS.PERSONAL_DETAILS_LIST, ]; /** diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index ffa30e883b72..597d3cdf890e 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -4,6 +4,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {readFileAsync} from '@libs/fileDownload/FileUtils'; import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {hasDependentTags, isGroupPolicy} from '@libs/PolicyUtils'; import ReceiptStorage from '@libs/ReceiptStorage'; @@ -17,7 +18,7 @@ import {resolveDetachReceiptConflicts} from '@userActions/RequestConflictUtils'; import type {IOURequestType, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type {ReceiptSource} from '@src/types/onyx/Transaction'; @@ -358,7 +359,14 @@ function navigateToStartStepIfScanFileCannotBeRead( onFailureCallback(); return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + // The base is explicit because the unreadable receipt is discarded from screens that are themselves dynamic + // routes (the participant picker), and their query params would clash with the ones this suffix adds. + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), + ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), + ), + ); return; } navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID); diff --git a/src/libs/actions/IOU/SearchUpdate.ts b/src/libs/actions/IOU/SearchUpdate.ts index 614d642e595a..1768cdf53126 100644 --- a/src/libs/actions/IOU/SearchUpdate.ts +++ b/src/libs/actions/IOU/SearchUpdate.ts @@ -3,6 +3,7 @@ import type {SearchQueryJSON} from '@components/Search/types'; import {isExpenseReport, isOptimisticPersonalDetail} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON, getFilterFromQuery} from '@libs/SearchQueryUtils'; import {getSuggestedSearches, isEligibleForStatus} from '@libs/SearchUIUtils'; +import {isInvalidMerchantValue} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -11,7 +12,7 @@ import type {Participant} from '@src/types/onyx/IOU'; import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -156,7 +157,7 @@ function getSearchOnyxUpdate({ } // Common transaction payload merged into every matching snapshot. - const baseSnapshotData: SearchResultDataType = {}; + const baseSnapshotData: NullishDeep = {}; baseSnapshotData[ONYXKEYS.PERSONAL_DETAILS_LIST] = { [toAccountID]: { accountID: toAccountID, @@ -170,10 +171,15 @@ function getSearchOnyxUpdate({ login: deprecatedCurrentUserPersonalDetails?.login, }, }; + const hasGenuineModifiedMerchant = !!transaction.modifiedMerchant && !isInvalidMerchantValue(transaction.modifiedMerchant); baseSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { ...(transactionThreadReportID && {transactionThreadReportID}), ...(isFromOneTransactionReport && {isFromOneTransactionReport}), ...transaction, + // Onyx.merge can't clear a key by spreading `undefined`, so a stale snapshot `modifiedMerchant` (e.g. the + // `(none)`/`Expense` placeholder a self-DM split inherits) would win over `merchant` in `isMerchantMissing` + // and show a false "Missing Merchant". Clear it with `null` unless it's a genuine user edit (#99500). + modifiedMerchant: hasGenuineModifiedMerchant ? transaction.modifiedMerchant : null, }; if (policy) { baseSnapshotData[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; @@ -199,7 +205,7 @@ function getSearchOnyxUpdate({ return; } - const snapshotData: SearchResultDataType = {...baseSnapshotData}; + const snapshotData: NullishDeep = {...baseSnapshotData}; if (queryJSON.groupBy === CONST.SEARCH.GROUP_BY.FROM) { const groupKey = `${CONST.SEARCH.GROUP_PREFIX}${fromAccountID}` as const; diff --git a/src/libs/actions/IOU/resolveWriteBarrier.ts b/src/libs/actions/IOU/resolveWriteBarrier.ts new file mode 100644 index 000000000000..de88edf86307 --- /dev/null +++ b/src/libs/actions/IOU/resolveWriteBarrier.ts @@ -0,0 +1,104 @@ +import type {WriteReadyBarrier} from '@libs/API'; +import { + acquireSearchWriteBarrier, + consumePendingSearchWrite, + consumePendingSearchWriteForGeneration, + getPendingSearchWriteGeneration, + hasPendingSearchWrite, + restartPendingSearchWriteSafetyTimeoutForGeneration, +} from '@libs/pendingSearchWrite'; +import {addOptimization} from '@libs/telemetry/submitFollowUpAction'; + +import CONST from '@src/CONST'; + +import type {OnyxKey} from 'react-native-onyx'; + +import {AppState} from 'react-native'; + +/** `writeWhenReady` defaults to waiting on any transition (~2s) when no barrier is given, + * so this lets no-barrier callers get immediate `write()` behavior without branching to `API.write`. */ +const IMMEDIATE: WriteReadyBarrier = () => Promise.resolve(); + +type ResolveWriteBarrierParams = { + /** Barrier the caller wants this write to wait on before applying optimistic data (see `API.armTransitionBarrier`). */ + writeBarrier?: WriteReadyBarrier; + + /** + * Onyx key of this write's optimistic data. Only used when this write also waits on Search's barrier: Search + * reads it back to tell when the optimistic item has landed in real search results. + */ + optimisticWatchKey?: OnyxKey; + + /** Retries must not wait for Search - the layout that would have released them already happened. */ + isRetry?: boolean; +}; + +/** + * Picks what a submit write waits on before applying its optimistic data: an explicit barrier from the + * caller, Search's pending-write signal, or neither. Callers pass their own barrier or nothing; whether + * the write also has to wait on Search's signal is decided here. Records the deferral for the + * submit-expense telemetry span. + */ +function resolveWriteBarrier({writeBarrier, optimisticWatchKey, isRetry = false}: ResolveWriteBarrierParams = {}): WriteReadyBarrier { + if (writeBarrier) { + addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE); + + const searchGeneration = getPendingSearchWriteGeneration(); + if (searchGeneration === undefined) { + return writeBarrier; + } + + // If the app is already in background, writeWhenReady executes immediately without invoking this + // barrier, so consume Search's pending-write signal now instead of relying on the abort listener below. + if (AppState.currentState === CONST.APP_STATE.BACKGROUND) { + consumePendingSearchWriteForGeneration(searchGeneration); + return writeBarrier; + } + + // Explicit barrier wins, but this write must still count as Search's consumer or its skeleton + // row stays up forever (`flushPendingSearchWrite` never gets a consumer to release). + return async (abortSignal) => { + // This write waits on `writeBarrier`, not `pending.barrier`, so restart Search's safety + // timeout from attach time - otherwise it can expire before this write's own timeout does. + restartPendingSearchWriteSafetyTimeoutForGeneration(searchGeneration); + + let hasConsumed = false; + const consumeOnce = () => { + if (hasConsumed) { + return; + } + hasConsumed = true; + // Consume by generation, not a live `hasPendingSearchWrite()` check, so a later + // submission's pending-write signal isn't cleared by mistake. + consumePendingSearchWriteForGeneration(searchGeneration); + }; + + // writeWhenReady's early-release paths abort `abortSignal` without settling `writeBarrier`, + // so the abort listener is the only thing that consumes in those cases. + abortSignal.addEventListener('abort', consumeOnce); + try { + return await writeBarrier(abortSignal); + } finally { + consumeOnce(); + } + }; + } + + // Search marks its pending-write signal earlier, before this write is even built (and possibly + // before a navigation away), so check for it even when no writeBarrier is passed. + if (hasPendingSearchWrite()) { + if (isRetry) { + // Consume anyway - a retry bypassing the signal must not leave flushPendingSearchWrite waiting for it. + consumePendingSearchWrite(); + return IMMEDIATE; + } + + addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE); + return acquireSearchWriteBarrier(optimisticWatchKey); + } + + return IMMEDIATE; +} + +export default resolveWriteBarrier; +export {IMMEDIATE}; diff --git a/src/libs/actions/Plaid.ts b/src/libs/actions/Plaid.ts index 71a8cc08623d..45e42e95a9cf 100644 --- a/src/libs/actions/Plaid.ts +++ b/src/libs/actions/Plaid.ts @@ -2,12 +2,12 @@ import * as API from '@libs/API'; import type {AddPersonalPlaidCardParams, ImportPlaidAccountsParams, OpenPlaidBankAccountSelectorParams, OpenPlaidBankLoginParams} from '@libs/API/parameters'; import type OpenPlaidCompanyCardLoginParams from '@libs/API/parameters/OpenPlaidCompanyCardLoginParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {getCompanyCardFeed} from '@libs/CardUtils'; +import {getCardFeedWithoutDomainID} from '@libs/CardUtils'; import getPlaidLinkTokenParameters from '@libs/getPlaidLinkTokenParameters'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeedWithDomainID, CardFeedWithNumber, CompanyCardFeedWithDomainID} from '@src/types/onyx/CardFeeds'; +import type {CardFeedWithNumber, CompanyCardFeedWithDomainID} from '@src/types/onyx/CardFeeds'; import Onyx from 'react-native-onyx'; @@ -70,7 +70,7 @@ function openPlaidCompanyCardLogin(country: string, domain?: string, feed?: Card country, domain, isPersonal, - feed: feed ? getCompanyCardFeed(feed) : undefined, + feed: feed ? getCardFeedWithoutDomainID(feed) : undefined, cardID, }; @@ -144,7 +144,7 @@ function openPlaidBankAccountSelector(publicToken: string, bankName: string, all function importPlaidAccounts( publicToken: string, - feed: CardFeedWithNumber | CardFeedWithDomainID, + feed: string, feedName: string, country: string, domainName: string, @@ -154,7 +154,7 @@ function importPlaidAccounts( ) { const parameters: ImportPlaidAccountsParams = { publicToken, - feed: getCompanyCardFeed(feed), + feed: getCardFeedWithoutDomainID(feed), feedName, country, domainName, diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index d10e3ab16230..b908d9d81fe0 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -27,6 +27,7 @@ import type {CommuterExclusions, CustomUnit, Rate} from '@src/types/onyx/Policy' import type {OnyxData} from '@src/types/onyx/Request'; import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -529,27 +530,25 @@ function updateDistanceTaxRate(policyID: string, customUnit: CustomUnit, customU } /** - * Set the commuter exclusion for a workspace. Currently only `fixedDistance` is supported; the - * distance unit is owned by the workspace's distance custom unit (Auth resolves it server-side and - * snapshots it into the change log), so we only echo it into Onyx optimistically. + * Set the commuter exclusion for a policy. Two methods are supported: + * - "fixedDistance" - subtracts a fixed distance per claim. `fixedDistance` (> 0) and `fixedDistanceUnit` + * (mirrors the policy's distance custom unit) are required. + * - "homeAndOffice" - subtracts each member's home-to-office distance, computed per-claim from the + * member's saved addresses. No client-side distance/unit needed. * - * Callers should pass the workspace's current `commuterExclusions` so the failure path can restore + * Callers should pass the policy's current `commuterExclusions` so the failure path can restore * the prior state. */ function setPolicyCommuterExclusions( policyID: string, - method: 'fixedDistance', - fixedDistance: number, - fixedDistanceUnit: string, + method: ValueOf, + fixedDistance: number | undefined, + fixedDistanceUnit: string | undefined, previousCommuterExclusions: CommuterExclusions | undefined, ) { const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - const optimisticCommuterExclusions: CommuterExclusions = { - method, - fixedDistance, - fixedDistanceUnit, - }; + const optimisticCommuterExclusions: CommuterExclusions = method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE ? {method, fixedDistance, fixedDistanceUnit} : {method}; const onyxData: OnyxData = { optimisticData: [ @@ -585,7 +584,9 @@ function setPolicyCommuterExclusions( ], }; - const parameters: SetPolicyCommuterExclusionsParams = {policyID, commuterExclusionMethod: method, distance: fixedDistance}; + // Only send distance when the server actually needs it. HomeAndOffice ignores the field. + const parameters: SetPolicyCommuterExclusionsParams = + method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE ? {policyID, commuterExclusionMethod: method, distance: fixedDistance} : {policyID, commuterExclusionMethod: method}; API.write(WRITE_COMMANDS.SET_POLICY_COMMUTER_EXCLUSIONS, parameters, onyxData); } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 702564ae68e1..d0f7d5322eaf 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -127,6 +127,7 @@ import type { Report, ReportAction, ReportActions, + Rule, TaxRatesWithDefault, Transaction, TransactionViolations, @@ -929,6 +930,7 @@ function setWorkspaceApprovalMode( currentUserEmail: string, isTrackIntentUser: boolean | undefined, additionalData?: SetWorkspaceApprovalModeAdditionalData, + rules?: OnyxCollection, ) { if (!policy) { return; @@ -1044,7 +1046,7 @@ function setWorkspaceApprovalMode( } } - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -1063,7 +1065,7 @@ function setWorkspaceApprovalMode( optimisticData.push(...reportsOptimisticData); } - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -1081,6 +1083,16 @@ function setWorkspaceApprovalMode( failureData.push(...reportsFailureData); } + if (approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL && rules) { + for (const [ruleKey, rule] of Object.entries(rules)) { + if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { + continue; + } + const ruleID = ruleKey.slice(ONYXKEYS.COLLECTION.RULE.length); + optimisticData.push({onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}`, value: null}); + failureData.push({onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}`, value: rule}); + } + } const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 999b1dd3d6b1..3977d45532f6 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2601,7 +2601,20 @@ function navigateToAndOpenReport({ return; } + // The create path above (createAndOpenNewOptimisticChat) enqueues an onboarding OpenReport carrying guidedSetupData + // whenever guided setup is still pending. When the DM already exists we take an existing-chat path instead, which would + // otherwise skip that OpenReport — breaking callers that rely on it being enqueued (e.g. the Concierge limit-increase + // reply must be stamped after the onboarding welcome message + tasks). So when onboarding is pending, enqueue the same + // onboarding OpenReport here too, so the create-path branch's assumption holds regardless of whether the DM already + // exists. getGuidedSetupDataForOpenReport de-dupes, so this won't duplicate onboarding. See https://github.com/Expensify/App/issues/99396. + const isOnboardingCompleted = hasCompletedGuidedSetupFlow ?? false; + const isRegularOnboardingPending = !!introSelected && !introSelected.inviteType && isSupportedInviteOnboardingChoice(introSelected.choice) && !isOnboardingCompleted; + const isOnboardingPending = isRegularOnboardingPending || isSupportedPendingInviteOnboarding(introSelected); + if (!shouldRevalidateExistingChat) { + if (isOnboardingPending) { + openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID}); + } navigateToReport(chat.reportID, {shouldDismissModal, ...linkToOptions}); return; } @@ -2626,8 +2639,9 @@ function navigateToAndOpenReport({ }, }); - // Re-open existing chats to re-validate server-side access and refresh stale local state. - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, betas, hasReportActions, currentUserAccountID}); + // Re-open existing chats to re-validate server-side access and refresh stale local state. Pass hasCompletedGuidedSetupFlow + // so a pending onboarding OpenReport is enqueued here too (see the create-path assumption note above). + openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID}); navigateToReport(chat.reportID, {shouldDismissModal, ...linkToOptions}); } @@ -4254,13 +4268,15 @@ function navigateToConciergeChat( reportActionID?: string, // TODO: personalDetails should be a required field in follow-up PRs https://github.com/Expensify/App/issues/73656 personalDetails?: OnyxEntry, -) { +): Promise { // If conciergeReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID. // Otherwise, we would find the concierge chat and navigate to it. + // A resolved promise is returned on every branch so callers can sequence work (e.g. another API.write) to run only + // after the concierge chat has been created/opened and its onboarding OpenReport enqueued. See https://github.com/Expensify/App/issues/99396. if (!conciergeReportID) { // In order to avoid creating concierge repeatedly, // we need to ensure that the server data has been successfully pulled - onServerDataReady().then(() => { + return onServerDataReady().then(() => { // If we don't have a chat with Concierge then create it if (!checkIfCurrentPageActive()) { return; @@ -4278,16 +4294,18 @@ function navigateToConciergeChat( linkToOptions, }); }); - } else if (shouldDismissModal) { + } + if (shouldDismissModal) { const reportParams = {reportID: conciergeReportID, reportActionID}; if (linkToOptions?.afterTransition) { Navigation.dismissModalWithReport(reportParams, undefined, {afterTransition: linkToOptions.afterTransition}); } else { Navigation.dismissModalWithReport(reportParams); } - } else { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID), linkToOptions); + return Promise.resolve(); } + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID), linkToOptions); + return Promise.resolve(); } type BuildNewReportOptimisticDataParams = { @@ -8633,6 +8651,11 @@ function mergeReports({ }); } +/** Saves the message the user is typing into the Concierge prompt box on the home page. */ +function saveConciergePromptDraft(draft: string | null) { + Onyx.set(ONYXKEYS.CONCIERGE_PROMPT_DRAFT, draft); +} + export type {Video, GuidedSetupData, GuidedSetupTask, TaskForParameters, IntroSelected, OpenReportActionParams}; export { @@ -8759,4 +8782,5 @@ export { getGuidedSetupDataForOpenReport, getReportChannelName, setViewingPublicRoomReportID, + saveConciergePromptDraft, }; diff --git a/src/libs/actions/SplitExpenses.ts b/src/libs/actions/SplitExpenses.ts index aadc3c69eccf..b3feec702439 100644 --- a/src/libs/actions/SplitExpenses.ts +++ b/src/libs/actions/SplitExpenses.ts @@ -127,7 +127,8 @@ function initSplitExpense( ? createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_SPLIT_EXPENSE_SEARCH.getRoute(reportID, originalTransactionID)) : createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_SPLIT_EXPENSE.getRoute(reportID, originalTransactionID)); initDraftSplitExpenseDataForEdit(draftTransaction, transaction.transactionID, reportID); - Navigation.navigate(ROUTES.SPLIT_EXPENSE_EDIT.getRoute(reportID, originalTransactionID, transaction.transactionID, splitExpenseOverviewRoute)); + Navigation.navigate(splitExpenseOverviewRoute); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.getRoute(reportID, transaction.transactionID), splitExpenseOverviewRoute)); return; } if (isSearchTopmostFullScreenRoute()) { diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 4e420b45ad3c..806d7acc5065 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -7,18 +7,22 @@ import type {CancelTaskParams, CompleteTaskParams, CreateTaskParams, EditTaskAss import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {isEmailPublicDomain} from '@libs/LoginUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getDBTimeWithSkew} from '@libs/NetworkState'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {addDomainToShortMention} from '@libs/ParsingUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {deprecatedGetReportName} from '@libs/ReportNameUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {buildOptimisticSnapshotData} from '@libs/SearchQueryUtils'; +import {getAllPersonalDetailLogins} from '@libs/ShortMentionLogins'; import playSound, {SOUNDS} from '@libs/Sound'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {generateAccountID} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -27,6 +31,7 @@ import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type PersonalDetails from '@src/types/onyx/PersonalDetails'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -35,6 +40,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import {getMostRecentReportID, navigateToConciergeChatAndDeleteReport, notifyNewAction, optimisticReportLastData} from './Report'; @@ -49,6 +55,7 @@ type EditTaskAssigneeOptions = { currentUserAccountID: number; hasOutstandingChildTask: boolean; delegateEmail: string | undefined; + delegateAccountID: number | undefined; assigneeAccountID?: number | null; assigneeChatReport?: OnyxEntry; isOptimisticReport?: boolean; @@ -76,6 +83,7 @@ type CreateTaskAndNavigateParams = { assigneeEmail: string; currentUserAccountID: number; currentUserEmail: string; + delegateAccountID: number | undefined; assigneeAccountID?: number; assigneeChatReport?: OnyxEntry; policyID?: string; @@ -87,6 +95,21 @@ type CreateTaskAndNavigateParams = { taskCreatorAndAssigneeDetails: OnyxEntry; }; +type CreateTaskFromMarkdownParams = { + /** The already trimmed text the user is sending */ + text: string; + /** The parent report to which the task belongs */ + parentReport: OnyxEntry; + /** The current user's personal details */ + currentUserPersonalDetails: CurrentUserPersonalDetails; + /** The quick action associated with the task */ + quickAction: OnyxEntry; + /** AccountID of the delegate acting on behalf of the current user */ + delegateAccountID: number | undefined; + /** The ancestors of the task */ + ancestors?: ReportUtils.Ancestor[]; +}; + type DeleteTaskOptions = { ancestors?: ReportUtils.Ancestor[]; shouldNavigateBack?: boolean; @@ -128,6 +151,7 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { currentUserEmail, currentUserDisplayName, currentUserAvatar, + delegateAccountID, assigneeAccountID = 0, assigneeChatReport, policyID = CONST.POLICY.OWNER_EMAIL_FAKE, @@ -164,7 +188,7 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { currentUserEmail, currentUserAvatar, }); - const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID); + const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID, delegateAccountID); optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID; const currentTime = getDBTimeWithSkew(); @@ -258,8 +282,8 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { > = []; if (assigneeChatReport && assigneeChatReportID) { - assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( - currentUserAccountID, + assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData({ + accountID: currentUserAccountID, assigneeAccountID, taskReportID, assigneeChatReportID, @@ -268,7 +292,8 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { assigneeChatReport, currentUserEmail, currentUserAccountID, - ); + delegateAccountID, + }); optimisticData.push(...assigneeChatReportOnyxData.optimisticData); successData.push(...assigneeChatReportOnyxData.successData); @@ -410,6 +435,78 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { notifyNewAction(parentReportID, optimisticAddCommentReport.reportAction, true); } +/** + * Creates a task from the `[] title` markdown shorthand (with an optional `@mention` assignee). + * + * @returns true when a task was created, so the caller can skip sending the text as a plain comment. + */ +function createTaskFromMarkdown({text, parentReport, currentUserPersonalDetails, quickAction, delegateAccountID, ancestors = []}: CreateTaskFromMarkdownParams): boolean { + // A task cannot be created without a parent report, so let the caller fall back to sending the text as a comment. + if (!parentReport?.reportID) { + return false; + } + + const taskMatch = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (!taskMatch) { + return false; + } + + let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; + if (!taskTitle || taskTitle.length > CONST.TITLE_CHARACTER_LIMIT) { + return false; + } + + const currentUserEmail = currentUserPersonalDetails.email ?? ''; + const mention = taskMatch[1] ? taskMatch[1].trim() : ''; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); + const mentionWithDomain = addDomainToShortMention(mention, getAllPersonalDetailLogins(), currentUserPrivateDomain) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); + + let assignee: OnyxEntry; + let assigneeChatReport; + if (mentionWithDomain) { + if (isValidMention) { + assignee = PersonalDetailsUtils.getPersonalDetailByEmail(mentionWithDomain); + if (!assignee) { + const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { + accountID: generateAccountID(mentionWithDomain), + login: mentionWithDomain, + }); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + } + } else { + taskTitle = `@${mentionWithDomain} ${taskTitle}`; + } + } + + const taskCreatorAndAssigneeDetails = {[currentUserPersonalDetails.accountID]: currentUserPersonalDetails}; + if (assignee) { + taskCreatorAndAssigneeDetails[assignee.accountID] = assignee; + } + + createTaskAndNavigate({ + parentReport, + title: taskTitle, + description: '', + assigneeEmail: assignee?.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail, + currentUserDisplayName: currentUserPersonalDetails.displayName, + currentUserAvatar: currentUserPersonalDetails.avatar, + delegateAccountID, + assigneeAccountID: assignee?.accountID, + assigneeChatReport, + policyID: parentReport?.policyID, + isCreatedUsingMarkdown: true, + quickAction, + ancestors, + taskCreatorAndAssigneeDetails, + }); + + return true; +} + function buildTaskData( taskReport: OnyxEntry, taskReportID: string, @@ -791,6 +888,7 @@ function editTaskAssignee({ currentUserAccountID, hasOutstandingChildTask, delegateEmail, + delegateAccountID, assigneeAccountID = 0, assigneeChatReport, isOptimisticReport, @@ -907,18 +1005,19 @@ function editTaskAssignee({ }, }; - assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( - currentUserAccountID, + assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData({ + accountID: currentUserAccountID, assigneeAccountID, - report.reportID, + taskReportID: report.reportID, assigneeChatReportID, - report.parentReportID, - reportName ?? '', + parentReportID: report.parentReportID, + title: reportName ?? '', assigneeChatReport, currentUserEmail, currentUserAccountID, - isOptimisticReport, - ); + delegateAccountID, + isOptimisticAssigneeChatReport: isOptimisticReport, + }); if (assigneeChatReportMetadata?.isOptimisticReport && assigneeChatReport.pendingFields?.createChat !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { // BE will send a different participant. We clear the optimistic one to avoid duplicated entries @@ -1554,6 +1653,7 @@ function completeTestDriveTask( export { createTaskAndNavigate, + createTaskFromMarkdown, editTask, editTaskAssignee, setTitleValue, @@ -1574,7 +1674,6 @@ export { getTaskAssigneeAccountID, clearTaskErrors, canModifyTask, - setNewOptimisticAssignee, getNavigationUrlOnTaskDelete, canActionTask, getFinishOnboardingTaskOnyxData, diff --git a/src/libs/actions/TravelBilling.ts b/src/libs/actions/TravelBilling.ts index b9686f00181a..b246eb4ca5d0 100644 --- a/src/libs/actions/TravelBilling.ts +++ b/src/libs/actions/TravelBilling.ts @@ -183,7 +183,7 @@ function toggleTravelBillingContinuousReconciliation( ? { policyAccountID: workspaceAccountID, shouldUseContinuousReconciliation, - travelInvoicingContinuousReconciliationConnection: connectionName, + travelBillingContinuousReconciliationConnection: connectionName, } : { policyAccountID: workspaceAccountID, @@ -259,7 +259,7 @@ function setTravelBillingReconciliationBankAccount( ) { const parameters: SetTravelBillingReconciliationBankAccountParams = { domainName, - travelInvoicingReconciliationBankAccountID: travelBillingReconciliationBankAccountID, + travelBillingReconciliationBankAccountID, }; const optimisticData: Array> = [ diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 22cbb365cba9..91125b9267e8 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -1,8 +1,23 @@ -import * as API from '@libs/API'; -import type {CreateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams, UpdateWorkspaceApprovalParams} from '@libs/API/parameters'; +import {write} from '@libs/API'; +import type {CreateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams, SetApprovalWorkflowParams, UpdateWorkspaceApprovalParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getDefaultApprover} from '@libs/PolicyUtils'; -import {calculateApprovers, convertApprovalWorkflowToPolicyEmployees, getOverLimitForwardsToDisplayName, mergeWorkflowMembersWithAvailableMembers} from '@libs/WorkflowUtils'; +import type {ApprovalWorkflowRulesDiff} from '@libs/WorkflowUtils'; +import { + applyApprovalWorkflowRulesDiff, + buildApprovalWorkflowRules, + calculateApprovers, + convertApprovalWorkflowToPolicyEmployees, + getApprovalWorkflowRulesForPolicy, + getOverLimitForwardsToDisplayName, + getWorkflowMemberEmails, + mergeWorkflowMembersWithAvailableMembers, + reconcileApprovalWorkflowRulesForCreate, + reconcileApprovalWorkflowRulesForEdit, + reconcileApprovalWorkflowRulesForMembersChange, + reconcileApprovalWorkflowRulesForRemove, +} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -10,9 +25,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type Rule from '@src/types/onyx/Rule'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import lodashDropRightWhile from 'lodash/dropRightWhile'; import Onyx from 'react-native-onyx'; @@ -90,7 +106,7 @@ function createApprovalWorkflow({approvalWorkflow, policy, addExpenseApprovalsTa ]; const parameters: CreateWorkspaceApprovalParams = {policyID: policy.id, employees: JSON.stringify(Object.values(updatedEmployees))}; - API.write(WRITE_COMMANDS.CREATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.CREATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); if ( addExpenseApprovalsTaskReport && @@ -204,7 +220,7 @@ function updateApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, membersToRem employees: JSON.stringify(Object.values(updatedEmployees)), defaultApprover: newDefaultApprover, }; - API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } function removeApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry) { @@ -263,7 +279,163 @@ function removeApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, policy: Onyx ]; const parameters: RemoveWorkspaceApprovalParams = {policyID: policy.id, employees: JSON.stringify(Object.values(updatedEmployees))}; - API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); +} + +type SetApprovalWorkflowRulesParams = { + policyID: string; + + /** + * Diff of rules keyed by ruleID. A value of `ApprovalWorkflowRule` sets/replaces the rule + * under its `ruleID` in the `ONYXKEYS.COLLECTION.RULE` collection; a value of `null` removes it. + */ + rulesDiff: ApprovalWorkflowRulesDiff; + + /** The rules as currently stored in Onyx, which `rulesDiff` is applied on top of. Used to roll back on failure. */ + previousRules: OnyxCollection; +}; + +/** + * Apply a set of approval-workflow rule changes to a policy via the SetApprovalWorkflow Auth command. + */ +function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetApprovalWorkflowRulesParams) { + if (!policyID || isEmptyObject(rulesDiff)) { + return; + } + + const genericError = getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'); + + const optimisticData: Array> = []; + const successData: Array> = []; + const failureData: Array> = []; + + for (const [ruleID, rule] of Object.entries(rulesDiff)) { + const ruleKey = `${ONYXKEYS.COLLECTION.RULE}${ruleID}` as const; + const previousRule = previousRules?.[ruleKey]; + const restore: OnyxUpdate = previousRule + ? {onyxMethod: Onyx.METHOD.SET, key: ruleKey, value: {...previousRule, pendingAction: null, errors: genericError}} + : {onyxMethod: Onyx.METHOD.SET, key: ruleKey, value: null}; + + if (rule === null) { + optimisticData.push({onyxMethod: Onyx.METHOD.MERGE, key: ruleKey, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null}}); + successData.push({onyxMethod: Onyx.METHOD.SET, key: ruleKey, value: null}); + failureData.push(restore); + continue; + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ruleKey, + value: { + ...rule, + scope: CONST.RULES.SCOPE.POLICY, + scopeID: policyID, + pendingAction: previousRule ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: null, + }, + }); + successData.push({onyxMethod: Onyx.METHOD.MERGE, key: ruleKey, value: {pendingAction: null}}); + failureData.push(restore); + } + + const parameters: SetApprovalWorkflowParams = { + policyID, + rules: JSON.stringify(rulesDiff), + }; + + write(WRITE_COMMANDS.SET_APPROVAL_WORKFLOW, parameters, {optimisticData, successData, failureData}); +} + +type CreateApprovalWorkflowRulesParams = CreateApprovalWorkflowParams & { + rules: OnyxCollection; +}; + +/** Create an approval workflow using the rules-based backend structure. */ +function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport, rules}: CreateApprovalWorkflowRulesParams) { + if (!policy) { + return; + } + + const existingRules = getApprovalWorkflowRulesForPolicy(rules, policy.id); + const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); + + // A submitter can only belong to one workflow, so first drop these members from any OTHER + // workflow's rules, then add them to the new workflow. + const removeDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); + const rulesAfterRemoval = applyApprovalWorkflowRulesDiff(existingRules, removeDiff); + + const newRules = buildApprovalWorkflowRules(approvalWorkflow); + const createDiff = reconcileApprovalWorkflowRulesForCreate(newRules, memberEmails, {existingRules: rulesAfterRemoval}); + + const rulesDiff = {...removeDiff, ...createDiff}; + + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: rules}); + + if ( + addExpenseApprovalsTaskReport && + (addExpenseApprovalsTaskReport.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || addExpenseApprovalsTaskReport.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED) + ) { + completeTask(addExpenseApprovalsTaskReport, false, false, undefined, undefined, undefined, false); + } +} + +type UpdateApprovalWorkflowRulesParams = { + approvalWorkflow: ApprovalWorkflow; + initialApprovalWorkflow: ApprovalWorkflow; + policy: OnyxEntry; + rules: OnyxCollection; +}; + +/** Update an existing workflow using the rules-based backend structure. */ +function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy, rules}: UpdateApprovalWorkflowRulesParams) { + if (!policy) { + return; + } + + const existingRules = getApprovalWorkflowRulesForPolicy(rules, policy.id); + const previousMemberEmails = getWorkflowMemberEmails(initialApprovalWorkflow.members); + const newMemberEmails = getWorkflowMemberEmails(approvalWorkflow.members); + + // 1. A submitter can only belong to one workflow, so drop joining members (those not previously in + // this workflow) from any OTHER workflow's rules first. + const joiningMemberEmails = newMemberEmails.filter((email) => !previousMemberEmails.includes(email)); + const removeFromOthersDiff = reconcileApprovalWorkflowRulesForRemove(joiningMemberEmails, {existingRules}); + const rulesAfterRemoval = applyApprovalWorkflowRulesDiff(existingRules, removeFromOthersDiff); + + // 2. Sync membership across this workflow's rules (add joiners, drop leavers). + const memberDiff = reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails, newMemberEmails, {existingRules: rulesAfterRemoval}); + const rulesAfterMembers = applyApprovalWorkflowRulesDiff(rulesAfterRemoval, memberDiff); + + // 3. Reconcile the approver chain with the membership-updated rules. + const newRules = buildApprovalWorkflowRules(approvalWorkflow); + const chainDiff = reconcileApprovalWorkflowRulesForEdit(newRules, newMemberEmails, {existingRules: rulesAfterMembers}); + + const rulesDiff = {...removeFromOthersDiff, ...memberDiff, ...chainDiff}; + + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: rules}); +} + +/** + * Delete an approval workflow using the rules-based backend structure. + * + * Returns false without calling the API when this policy has no rules covering the workflow's members, so the + * caller can fall back to the `employeeList` path. + */ +function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry, rules: OnyxCollection): boolean { + if (!policy) { + return false; + } + + const existingRules = getApprovalWorkflowRulesForPolicy(rules, policy.id); + const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); + const rulesDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); + + if (isEmptyObject(rulesDiff)) { + return false; + } + + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: rules}); + return true; } /** Set the members of the approval workflow that is currently edited */ @@ -364,10 +536,12 @@ type SelectApprovalWorkflowForEditParams = { usedApproverEmails: string[]; /** Override for the approvers list (the Edit page uses this to seed an optimistic agent). */ approvers?: Approver[]; + /** Identity anchor of the member whose workflow is being edited, preserved across sub-page back routes. */ + memberEmail?: string; }; /** Commits a workflow to onyx in EDIT mode so any sub-page can be entered directly, skipping the Edit RHP. */ -function selectApprovalWorkflowForEdit({workflow, defaultWorkflowMembers, usedApproverEmails, approvers}: SelectApprovalWorkflowForEditParams) { +function selectApprovalWorkflowForEdit({workflow, defaultWorkflowMembers, usedApproverEmails, approvers, memberEmail}: SelectApprovalWorkflowForEditParams) { setApprovalWorkflow({ ...workflow, approvers: approvers ?? workflow.approvers, @@ -376,6 +550,7 @@ function selectApprovalWorkflowForEdit({workflow, defaultWorkflowMembers, usedAp action: CONST.APPROVAL_WORKFLOW.ACTION.EDIT, errors: null, originalApprovers: workflow.approvers, + memberEmail, }); } @@ -427,7 +602,10 @@ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): appro export { createApprovalWorkflow, + createApprovalWorkflowRules, + removeApprovalWorkflowRules, updateApprovalWorkflow, + updateApprovalWorkflowRules, removeApprovalWorkflow, setApprovalWorkflowMembers, setApprovalWorkflowApprover, diff --git a/src/libs/actions/connections/Rillet.ts b/src/libs/actions/connections/Rillet.ts index 833629c8db6c..7d738fc6e3d4 100644 --- a/src/libs/actions/connections/Rillet.ts +++ b/src/libs/actions/connections/Rillet.ts @@ -739,18 +739,18 @@ function updateRilletSyncTravelBillingSettlements(policyID: string, enabled: Ril function updateRilletTravelBillingSettlementsAccount( policyID: string, - travelInvoicingSettlementsBankAccountID: RilletSync['travelInvoicingSettlementsBankAccountID'], + travelBillingSettlementsBankAccountID: RilletSync['travelInvoicingSettlementsBankAccountID'], oldTravelBillingSettlementsBankAccountID?: RilletSync['travelInvoicingSettlementsBankAccountID'], ) { const onyxData = prepareRilletSyncOnyxData( policyID, CONST.RILLET_CONFIG.TRAVEL_BILLING_SETTLEMENTS_BANK_ACCOUNT_ID, - travelInvoicingSettlementsBankAccountID, + travelBillingSettlementsBankAccountID, oldTravelBillingSettlementsBankAccountID ?? null, ); const parameters: UpdateRilletTravelBillingSettlementsAccountParams = { policyID, - travelInvoicingSettlementsBankAccountID, + travelBillingSettlementsBankAccountID, }; write(WRITE_COMMANDS.UPDATE_RILLET_TRAVEL_BILLING_SETTLEMENTS_ACCOUNT, parameters, onyxData); } diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index 3d3aba5b3b63..4f94629c2236 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -73,6 +73,11 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm return; } + // API sometimes returns the parent as the preexisting report, which would parent it to itself + if (preexistingReportID === parentReportID) { + return; + } + // Handle cleanup of stale optimistic IOU report and its report preview separately if (isMoneyRequestReport(report) && parentReportID && parentReportActionID) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { diff --git a/src/libs/isTeachersUnitePolicyID.ts b/src/libs/isTeachersUnitePolicyID.ts new file mode 100644 index 000000000000..56857fdcf994 --- /dev/null +++ b/src/libs/isTeachersUnitePolicyID.ts @@ -0,0 +1,15 @@ +import CONST from '@src/CONST'; + +import type EnvironmentType from './Environment/getEnvironment/types'; + +import getEnvironment from './Environment/getEnvironment'; + +let environment: EnvironmentType; +getEnvironment().then((env) => { + environment = env; +}); + +export default function isTeachersUnitePolicyID(policyID: string | undefined): boolean { + const teacherUnitePolicyID = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; + return policyID === teacherUnitePolicyID; +} diff --git a/src/libs/pendingSearchWrite.ts b/src/libs/pendingSearchWrite.ts new file mode 100644 index 000000000000..b3ab8f1bf03f --- /dev/null +++ b/src/libs/pendingSearchWrite.ts @@ -0,0 +1,202 @@ +import type {OnyxKey} from 'react-native-onyx'; + +import type {WriteReadyBarrier} from './API'; + +import {SAFETY_TIMEOUT_MS} from './API/writeWhenReady'; + +/** + * Search's own write-readiness signal: releases deferred writes on real-content layout, or a + * focus/unmount fallback. Call sites read `barrier` and pass it down themselves; nothing here calls + * `write()`/`writeWhenReady()`. State is module-level because mark (`markPendingWriteForSearchPage`) and consume (Search's + * mount effect) run independently, not one calling the other, and only one pending write exists at a time. + * + * `SAFETY_TIMEOUT_MS` here guarantees the API write itself executes; Search's separate 10s + * `clearOptimisticTracking` timeout only clears the skeleton/ghost-row UI. + */ + +type PendingSearchWrite = { + /** Resolves when `release` runs. Every write acquired before that shares this one instance. */ + barrier: WriteReadyBarrier; + + /** Resolves `barrier`, unblocking every write waiting on it. */ + release: () => void; + + /** + * `generationCounter`'s value at the time this object was created. + * Timeouts/callbacks compare it against the current `pending.generation` before acting, to detect a newer object having replaced this one. + */ + generation: number; + + /** How many writes have received this object's `barrier`. */ + consumerCount: number; + + /** Set when a flush arrives before any write has received the barrier. */ + isFlushRequested: boolean; + + /** Clears this object automatically if nothing releases it first. */ + safetyTimeoutID: ReturnType; +}; + +let pending: PendingSearchWrite | undefined; +let generationCounter = 0; + +/** Onyx key the pending write creates optimistically. Persists across reads, only cleared when the next pending write starts. */ +let watchKey: OnyxKey | undefined; + +function clearPending(generation: number) { + if (pending?.generation !== generation) { + return; + } + clearTimeout(pending.safetyTimeoutID); + // Resolve now so a write already waiting on `pending.barrier` doesn't have to wait for its own separate timeout to fire. + pending.release(); + pending = undefined; +} + +/** + * Sets `pending` to a new object, unless one already exists. Called at submission start, not when the + * write is built, since `hasPendingSearchWrite` has consumers (the skeleton, `skipWaitForWrites`) that + * read it before any write exists. Calling it again while one is already pending does nothing, so + * multiple writes from the same submission end up sharing that one object. + */ +function markPendingSearchWrite() { + if (pending) { + return; + } + + watchKey = undefined; + generationCounter += 1; + const generation = generationCounter; + + let release: () => void = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + + // Self-clearing so a submission that never reaches a Search release point doesn't leave the skeleton up forever. + const safetyTimeoutID = setTimeout(() => clearPending(generation), SAFETY_TIMEOUT_MS); + + pending = { + barrier: () => released, + release, + generation, + consumerCount: 0, + isFlushRequested: false, + safetyTimeoutID, + }; +} + +function hasPendingSearchWrite(): boolean { + return !!pending; +} + +function getPendingSearchWriteGeneration(): number | undefined { + return pending?.generation; +} + +/** The barrier function a write should wait on before applying its optimistic data; an already-resolved one when nothing is pending. */ +function acquireSearchWriteBarrier(optimisticWatchKey?: OnyxKey): WriteReadyBarrier { + if (!pending) { + return () => Promise.resolve(); + } + + setSearchWriteWatchKey(optimisticWatchKey); + return consumePendingSearchWrite(); +} + +/** Same as `acquireSearchWriteBarrier`, without recording a watch key, for writes that bypass Search's barrier. */ +function consumePendingSearchWrite(): WriteReadyBarrier { + if (!pending) { + return () => Promise.resolve(); + } + + const {barrier, generation, isFlushRequested} = pending; + pending.consumerCount += 1; + + // Search already flushed before this write existed; hand the held-up release down now. + if (isFlushRequested) { + clearPending(generation); + } + + return barrier; +} + +/** Same as `consumePendingSearchWrite`, but a no-op if the pending signal is no longer `generation`. */ +function consumePendingSearchWriteForGeneration(generation: number) { + if (pending?.generation !== generation) { + return; + } + consumePendingSearchWrite(); +} + +/** + * Restarts the safety timeout for the current pending write, counting `SAFETY_TIMEOUT_MS` from now + * instead of from when `markPendingSearchWrite` first started it. + * + * A write that waits on its own `writeBarrier` (see `resolveWriteBarrier`) can attach well after that + * point, so without this the timeout could fire, and clear the pending write, before that write is + * even done waiting. + */ +function restartPendingSearchWriteSafetyTimeoutForGeneration(generation: number) { + if (pending?.generation !== generation) { + return; + } + clearTimeout(pending.safetyTimeoutID); + pending.safetyTimeoutID = setTimeout(() => clearPending(generation), SAFETY_TIMEOUT_MS); +} + +/** Resolves `pending.barrier`. Clears `pending` right away if a write already consumed it, otherwise flags `isFlushRequested` so the next one to consume it clears it instead. */ +function flushPendingSearchWrite() { + if (!pending) { + return; + } + + const {release, generation, consumerCount} = pending; + release(); + + // No write has called consumePendingSearchWrite yet, so keep the signal up until one arrives to hand the release to. + if (consumerCount === 0) { + pending.isFlushRequested = true; + return; + } + + clearPending(generation); +} + +/** Record the Onyx key the pending write will create optimistically. */ +function setSearchWriteWatchKey(key: OnyxKey | undefined) { + if (!key) { + return; + } + watchKey = key; +} + +function getSearchWriteWatchKey(): OnyxKey | undefined { + return watchKey; +} + +/** Test-only reset, gated on `__DEV__` so the bundler dead-code eliminates it in production. */ +function resetForTesting() { + if (!__DEV__) { + return; + } + if (pending) { + clearTimeout(pending.safetyTimeoutID); + } + pending = undefined; + watchKey = undefined; +} + +export { + markPendingSearchWrite, + hasPendingSearchWrite, + getPendingSearchWriteGeneration, + acquireSearchWriteBarrier, + consumePendingSearchWrite, + consumePendingSearchWriteForGeneration, + restartPendingSearchWriteSafetyTimeoutForGeneration, + flushPendingSearchWrite, + setSearchWriteWatchKey, + getSearchWriteWatchKey, + resetForTesting, +}; diff --git a/src/libs/pendingSubmitWrite.ts b/src/libs/pendingSubmitWrite.ts new file mode 100644 index 000000000000..4b722b42bb4f --- /dev/null +++ b/src/libs/pendingSubmitWrite.ts @@ -0,0 +1,80 @@ +import {SAFETY_TIMEOUT_MS} from './API/writeWhenReady'; +import Log from './Log'; + +/** + * Tracks "a submit-expense write is on its way to report X", for callers to check via + * `hasPendingSubmitWriteForReport`. Meant for a one-off read; there's no onChange/listener API. + */ + +/** The report a submit write is currently pending for, if any. Only one submission is ever in flight. */ +let pendingReportID: string | undefined; + +/** ID of the current pending submission, bumped each time `markPendingSubmitWriteForReport` runs. */ +let generation = 0; + +let safetyTimeoutID: ReturnType | undefined; + +function clearPending(forGeneration: number) { + if (generation !== forGeneration || pendingReportID === undefined) { + return; + } + pendingReportID = undefined; + clearTimeout(safetyTimeoutID); + safetyTimeoutID = undefined; +} + +function startSafetyTimeout(reportID: string, forGeneration: number) { + clearTimeout(safetyTimeoutID); + safetyTimeoutID = setTimeout(() => { + Log.warn('[pendingSubmitWrite] Pending write signal cleared by its safety timeout - the submission never released it', {reportID}); + clearPending(forGeneration); + }, SAFETY_TIMEOUT_MS); +} + +/** + * Marks `reportID` pending, returns a function to call once the write settles. Calling that returned + * function twice, or after a later submission replaced this one, is a no-op. Also self-clears after + * `SAFETY_TIMEOUT_MS` in case the caller never calls it. + */ +function markPendingSubmitWriteForReport(reportID: string | undefined): () => void { + if (!reportID) { + return () => {}; + } + + generation++; + const forGeneration = generation; + pendingReportID = reportID; + + startSafetyTimeout(reportID, forGeneration); + + return () => clearPending(forGeneration); +} + +/** Call once the write attaches, restarting the safety timeout from there so it can't clear `pendingReportID` while that write is still in flight. */ +function restartPendingSubmitWriteSafetyTimeout(reportID: string | undefined) { + if (!reportID || pendingReportID !== reportID) { + return; + } + startSafetyTimeout(reportID, generation); +} + +/** Whether a submit write is pending for this specific report, scoped so an unrelated submission can't affect it. */ +function hasPendingSubmitWriteForReport(reportID: string | undefined): boolean { + if (!reportID) { + return false; + } + return pendingReportID === reportID; +} + +/** Only for use in tests. Gated behind __DEV__ so it is a no-op in production. */ +function resetForTesting() { + if (!__DEV__) { + return; + } + clearTimeout(safetyTimeoutID); + safetyTimeoutID = undefined; + pendingReportID = undefined; + generation = 0; +} + +export {markPendingSubmitWriteForReport, restartPendingSubmitWriteSafetyTimeout, hasPendingSubmitWriteForReport, resetForTesting}; diff --git a/src/libs/telemetry/getSendMessageListWeight.ts b/src/libs/telemetry/getSendMessageListWeight.ts index 5b212f71a286..825ceb0ea19e 100644 --- a/src/libs/telemetry/getSendMessageListWeight.ts +++ b/src/libs/telemetry/getSendMessageListWeight.ts @@ -11,7 +11,7 @@ type SendMessageListWeight = { }; /** - * Counts the report's actions and preview cards for the `ManualSendMessage` span so we can group send latency + * Counts the report's actions and preview cards for the `ManualSendMessageVisible` span so we can group send latency * in Sentry by how "heavy" the chat is. We filter through `getSortedReportActionsForDisplay` to skip hidden/deleted * actions, and pass `reportID` so actions missing their own `reportID` aren't dropped (same as the report view does). * diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index 3cbe6e266b41..dd811d341198 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -27,11 +27,9 @@ import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import type {AddExistingExpensesParamList} from '@libs/Navigation/types'; -import {canSubmitPerDiemExpenseFromWorkspace, getPerDiemCustomUnit} from '@libs/PolicyUtils'; -import {getTransactionDetails, isIOUReport} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; -import {createUnreportedExpenses, getAmount, getCurrency, getDescription, getMerchant, getOriginalTransactionWithSplitInfo, isPerDiemRequest} from '@libs/TransactionUtils'; +import {createUnreportedExpenses, getAmount, getCurrency, getDescription, getEligibleTransactionsToAdd, getMerchant, isUnreportedTransaction} from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -47,7 +45,7 @@ import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft' import type Transaction from '@src/types/onyx/Transaction'; import getEmptyArray from '@src/types/utils/getEmptyArray'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -57,10 +55,6 @@ import UnreportedExpenseListItem from './UnreportedExpenseListItem'; type AddExistingExpensePageType = PlatformStackScreenProps; type ExpenseStatus = typeof CONST.SEARCH.STATUS.EXPENSE.UNREPORTED | typeof CONST.SEARCH.STATUS.EXPENSE.DRAFTS; -function isUnreportedTransaction(transaction: OnyxEntry): boolean { - return transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID || transaction?.reportID === ''; -} - function AddExistingExpense({route}: AddExistingExpensePageType) { const {convertToDisplayString} = useCurrencyListActions(); const {translate} = useLocalize(); @@ -89,74 +83,12 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { const [openReportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT, {selector: openExpenseReportIDsSelector}); const isInLandscapeMode = useIsInLandscapeMode(); - const getEligibleTransactions = useCallback( - (transactions: OnyxCollection) => { - if (!transactions) { - return []; - } - const isIOU = isIOUReport(report); - return Object.values(transactions || {}).filter((item) => { - const isUnreported = isUnreportedTransaction(item); - if (isIOU && !isUnreported) { - return false; - } - - // Split expenses can't be moved to a 1:1 DM chat, so they must not be offered when adding to an IOU report - if (isIOU) { - const originalTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${item?.comment?.originalTransactionID}`]; - const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(item, originalTransaction); - if (isExpenseSplit) { - return false; - } - } - - const isOnOpenExpenseReport = !!(item?.reportID && (allOpenReports?.[item.reportID] ?? openReportDrafts?.[item.reportID])); - if (!isUnreported && !isOnOpenExpenseReport) { - return false; - } - - // Don't show expenses that are already on the current report - if (item?.reportID === reportID) { - return false; - } - - // Check if the transaction belongs to the current user by verifying card ownership - if (item?.cardID) { - const card = cardList?.[item.cardID]; - if (card?.accountID !== currentUserAccountID) { - return false; - } - } - - const transactionAmount = getTransactionDetails(item)?.amount ?? 0; - - // Only block negative amounts for unreported expenses. - if (transactionAmount < 0 && isUnreported) { - return false; - } - - // Zero amount expenses are not allowed in IOU reports - if (isIOU && transactionAmount === 0) { - return false; - } - - if (isPerDiemRequest(item)) { - // Only show per diem expenses if the target workspace has per diem enabled and the per diem expense was created in the same workspace - const workspacePerDiemUnit = getPerDiemCustomUnit(policy); - const perDiemCustomUnitID = item?.comment?.customUnit?.customUnitID; - - return canSubmitPerDiemExpenseFromWorkspace(policy) && (!perDiemCustomUnitID || perDiemCustomUnitID === workspacePerDiemUnit?.customUnitID); - } - - return true; - }); - }, - [policy, report, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts], + const transactionsSelector = useCallback( + (transactions: OnyxCollection) => + getEligibleTransactionsToAdd({transactions, report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts}), + [report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts], ); - - const [transactions = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { - selector: getEligibleTransactions, - }); + const [transactions = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: transactionsSelector}); const fetchMoreUnreportedTransactions = () => { if (!hasMoreUnreportedTransactionsResults || isLoadingUnreportedTransactions) { diff --git a/src/pages/AddPersonalBankAccountPage/index.tsx b/src/pages/AddPersonalBankAccountPage/index.tsx index b4713ab25200..a326c40bc4c7 100644 --- a/src/pages/AddPersonalBankAccountPage/index.tsx +++ b/src/pages/AddPersonalBankAccountPage/index.tsx @@ -4,6 +4,7 @@ import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useShouldCollectInternationalDepositDetails from '@hooks/useShouldCollectInternationalDepositDetails'; import useSubPage from '@hooks/useSubPage'; import type {SubPageProps} from '@hooks/useSubPage/types'; @@ -29,6 +30,7 @@ import React, {useContext, useEffect, useRef} from 'react'; import Address from './substeps/AddressStep'; import Confirmation from './substeps/ConfirmationStep'; +import InternationalBankAccountDetails from './substeps/InternationalBankAccountDetailsStep'; import LegalName from './substeps/LegalNameStep'; import ManualBankAccountDetails from './substeps/ManualBankAccountDetailsStep'; import PhoneNumber from './substeps/PhoneNumberStep'; @@ -38,7 +40,10 @@ import getSkippedStepsPersonalInfo from './utils/getSkippedStepsPersonalInfo'; const SUB_PAGE_NAMES = CONST.ADD_PERSONAL_BANK_ACCOUNT.SUB_PAGE_NAMES; +// The international bank account details page sits right after the bank account details page, so the info pages that +// follow it start at index 2 (see getSkippedStepsPersonalInfo). const infoPages = [ + {pageName: SUB_PAGE_NAMES.INTERNATIONAL_BANK_ACCOUNT_DETAILS, component: InternationalBankAccountDetails}, {pageName: SUB_PAGE_NAMES.LEGAL_NAME, component: LegalName}, {pageName: SUB_PAGE_NAMES.ADDRESS, component: Address}, {pageName: SUB_PAGE_NAMES.PHONE_NUMBER, component: PhoneNumber}, @@ -66,6 +71,7 @@ function AddPersonalBankAccountPage() { const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA); + const shouldCollectInternationalDepositDetails = useShouldCollectInternationalDepositDetails(CONST.COUNTRY.US); const kycWallRef = useContext(KYCWallContext); const shouldShowSuccess = fullPersonalBankAccount?.shouldShowSuccess ?? false; @@ -142,7 +148,7 @@ function AddPersonalBankAccountPage() { }; const pages = isManual ? pagesWithManualSetup : pagesWithPlaid; - const skipPages = getSkippedStepsPersonalInfo(privatePersonalDetails) + const skipPages = getSkippedStepsPersonalInfo(privatePersonalDetails, shouldCollectInternationalDepositDetails) .map((index) => pages.at(index)?.pageName) .filter((pageName): pageName is NonNullable => !!pageName); diff --git a/src/pages/AddPersonalBankAccountPage/substeps/ConfirmationStep.tsx b/src/pages/AddPersonalBankAccountPage/substeps/ConfirmationStep.tsx index 5e24ca932117..31f4e70cb6f8 100644 --- a/src/pages/AddPersonalBankAccountPage/substeps/ConfirmationStep.tsx +++ b/src/pages/AddPersonalBankAccountPage/substeps/ConfirmationStep.tsx @@ -4,6 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import type {SubPageProps} from '@hooks/useSubPage/types'; +import {shouldShowInternationalDetailOnConfirmation} from '@libs/BankAccountUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import {formatE164PhoneNumber} from '@libs/LoginUtils'; import {getCurrentAddress} from '@libs/PersonalDetailsUtils'; @@ -91,15 +92,45 @@ function ConfirmationStep({onNext, onMove, isEditing}: SubPageProps) { }, ]; + const internationalBankAccountDetails = [ + ...(shouldShowInternationalDetailOnConfirmation(bankAccountPersonalDetails?.iban, bankAccountPersonalDetails?.accountNumber) + ? [ + { + id: 'iban', + description: translate('bankAccount.iban'), + title: bankAccountPersonalDetails?.iban ?? '', + shouldShowRightIcon: true, + onPress: () => { + moveToEditStep(1); + }, + }, + ] + : []), + ...(shouldShowInternationalDetailOnConfirmation(bankAccountPersonalDetails?.swiftCode) + ? [ + { + id: 'swift-code', + description: translate('bankAccount.swiftBicCode'), + title: bankAccountPersonalDetails?.swiftCode ?? '', + shouldShowRightIcon: true, + onPress: () => { + moveToEditStep(1); + }, + }, + ] + : []), + ]; + return [ ...bankConnection, + ...internationalBankAccountDetails, { id: 'legal-name', description: translate('personalInfoStep.legalName'), title: `${personalDetails[PERSONAL_INFO_STEP_KEYS.FIRST_NAME]} ${personalDetails[PERSONAL_INFO_STEP_KEYS.LAST_NAME]}`, shouldShowRightIcon: true, onPress: () => { - moveToEditStep(1); + moveToEditStep(2); }, }, { @@ -108,7 +139,7 @@ function ConfirmationStep({onNext, onMove, isEditing}: SubPageProps) { title: `${personalDetails?.addressStreet}, ${personalDetails?.addressCity}, ${personalDetails?.addressState} ${personalDetails?.addressZip}`, shouldShowRightIcon: true, onPress: () => { - moveToEditStep(2); + moveToEditStep(3); }, }, { @@ -117,7 +148,7 @@ function ConfirmationStep({onNext, onMove, isEditing}: SubPageProps) { title: personalDetails[PERSONAL_INFO_STEP_KEYS.PHONE_NUMBER], shouldShowRightIcon: true, onPress: () => { - moveToEditStep(3); + moveToEditStep(4); }, }, ]; diff --git a/src/pages/AddPersonalBankAccountPage/substeps/InternationalBankAccountDetailsStep.tsx b/src/pages/AddPersonalBankAccountPage/substeps/InternationalBankAccountDetailsStep.tsx new file mode 100644 index 000000000000..54c3cbf42694 --- /dev/null +++ b/src/pages/AddPersonalBankAccountPage/substeps/InternationalBankAccountDetailsStep.tsx @@ -0,0 +1,55 @@ +/** + * Personal-info substep for collecting international bank account details (IBAN + SWIFT/BIC) when adding a personal bank account. + */ +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import InternationalBankAccountDetailsForm from '@components/SubStepForms/InternationalBankAccountDetailsStep'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalBankAccountDetailsFormSubmit from '@hooks/usePersonalBankAccountDetailsFormSubmit'; +import type {SubPageProps} from '@hooks/useSubPage/types'; + +import {getInternationalBankAccountDetailsErrors} from '@libs/BankAccountUtils'; +import {getFieldRequiredErrors} from '@libs/ValidationUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/PersonalBankAccountForm'; + +import React from 'react'; + +type InternationalBankAccountDetailsProps = SubPageProps; + +const BANK_INFO_STEP_KEYS = INPUT_IDS.BANK_INFO_STEP; +const STEP_FIELDS = [BANK_INFO_STEP_KEYS.IBAN, BANK_INFO_STEP_KEYS.SWIFT_CODE]; + +function InternationalBankAccountDetailsStep({onNext, isEditing}: InternationalBankAccountDetailsProps) { + const [bankAccountPersonalDetails] = useOnyx(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT); + + const {translate} = useLocalize(); + + const validate = (values: FormOnyxValues): FormInputErrors => ({ + ...getFieldRequiredErrors(values, STEP_FIELDS, translate), + ...getInternationalBankAccountDetailsErrors(values.iban, values.swiftCode, translate), + }); + + const handleSubmit = usePersonalBankAccountDetailsFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: true, + }); + + return ( + + ); +} + +InternationalBankAccountDetailsStep.displayName = 'InternationalBankAccountDetailsStep'; + +export default InternationalBankAccountDetailsStep; diff --git a/src/pages/AddPersonalBankAccountPage/utils/getSkippedStepsPersonalInfo.ts b/src/pages/AddPersonalBankAccountPage/utils/getSkippedStepsPersonalInfo.ts index 2b56438f356d..88e731d4437c 100644 --- a/src/pages/AddPersonalBankAccountPage/utils/getSkippedStepsPersonalInfo.ts +++ b/src/pages/AddPersonalBankAccountPage/utils/getSkippedStepsPersonalInfo.ts @@ -4,24 +4,33 @@ import CONST from '@src/CONST'; import type {PrivatePersonalDetails} from '@src/types/onyx'; /** - * Returns the initial substep for the Personal Info step based on already existing data + * Returns the steps to skip in the Personal Info flow based on already existing data. + * + * The international bank account details step sits at index 1 (right after the bank account details step), + * so the legal name / address / phone number steps that follow it are at indexes 2, 3 and 4. */ -function getSkippedStepsPersonalInfo(data?: Partial): number[] { +function getSkippedStepsPersonalInfo(data: Partial | undefined, shouldCollectInternationalDepositDetails: boolean): number[] { const currentAddress = getCurrentAddress(data); const skippedSteps = []; - if (!!data?.legalFirstName && !!data?.legalLastName) { + + // US page 1 never has an IBAN or SWIFT/BIC, so this step is omitted only when collection is not needed. + if (!shouldCollectInternationalDepositDetails) { skippedSteps.push(1); } + if (!!data?.legalFirstName && !!data?.legalLastName) { + skippedSteps.push(2); + } + const isUsOrCanada = currentAddress?.country === CONST.COUNTRY.US || currentAddress?.country === CONST.COUNTRY.CA; const hasValidState = !isUsOrCanada || !!currentAddress?.state; if (!!currentAddress?.street && !!currentAddress?.city && hasValidState && !!currentAddress?.zip) { - skippedSteps.push(2); + skippedSteps.push(3); } if (data?.phoneNumber) { - skippedSteps.push(3); + skippedSteps.push(4); } return skippedSteps; diff --git a/src/pages/AddressPage.tsx b/src/pages/AddressPage.tsx index d67fc91c1866..22d38687e563 100644 --- a/src/pages/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -32,10 +32,16 @@ type AddressPageProps = { /** Title of address page */ title: string; + /** + * When true, the underlying AddressForm also requires the zip/postal code. Used by callers + * that need a complete address (e.g. workspace addresses backing homeAndOffice exclusions). + */ + shouldRequireZip?: boolean; + defaultCountry?: Country; } & BackToParams; -function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo, defaultCountry}: AddressPageProps) { +function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo, defaultCountry, shouldRequireZip}: AddressPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -118,6 +124,7 @@ function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo street1={street1} street2={street2} zip={zipcode} + shouldRequireZip={shouldRequireZip} /> )} diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx index 58b8ab9f7b63..745ba67d0752 100644 --- a/src/pages/ChangePolicyEducationalModal.tsx +++ b/src/pages/ChangePolicyEducationalModal.tsx @@ -1,6 +1,6 @@ import CenteredModalLayout from '@components/CenteredModalLayout'; import ChangeWorkspaceMenuSectionList from '@components/ChangeWorkspaceMenuSectionList'; -import FeatureTrainingContent from '@components/FeatureTrainingContent'; +import FeatureTraining from '@components/FeatureTraining'; import useBeforeRemove from '@hooks/useBeforeRemove'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -40,24 +40,30 @@ function ChangePolicyEducationalModal() { width={variables.changePolicyEducationModalWidth} contentStyle={[styles.pt0, styles.pb0]} > - - - + + + + {translate('iou.changePolicyEducational.title')} + {translate('iou.changePolicyEducational.description')} + + + {translate('common.buttonConfirm')} + + ); } diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index bbf7c62d8ff6..74c8539ee889 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -42,7 +42,7 @@ import { } from '@libs/ReportUtils'; import refreshSearchAfterReportAction from '@libs/SearchRefreshUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {hasAppliedCommuterExclusion, isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils'; +import {hasAppliedCommuterExclusion, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -106,9 +106,11 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const navigateBackFromChangeWorkspacePath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_CHANGE_WORKSPACE.path); const hasCommuterExclusionDistanceRequest = reportTransactions.some((transaction) => hasAppliedCommuterExclusion(transaction)); + const hasDistanceRequest = reportTransactions.some((transaction) => isDistanceRequest(transaction)); const hasManualDistanceRequest = reportTransactions.some((transaction) => isManualDistanceRequest(transaction)); const hasOdometerDistanceRequest = reportTransactions.some((transaction) => isOdometerDistanceRequest(transaction)); - const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({ + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + isDistanceRequest: hasDistanceRequest, isManualDistanceRequest: hasManualDistanceRequest, isOdometerDistanceRequest: hasOdometerDistanceRequest, }); @@ -133,7 +135,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace if (!policyID || !policy) { return; } - if (blockManualOrOdometerDistanceRequestIfNeeded(policyID)) { + if (blockDistanceRequestIfNeeded(policyID)) { return; } if (shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriods, amountOwed, currentUserPersonalDetails.accountID)) { diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx index 4dcd99012fed..64af2b99cf86 100644 --- a/src/pages/EditReportFieldDropdown.tsx +++ b/src/pages/EditReportFieldDropdown.tsx @@ -2,6 +2,7 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -34,8 +35,13 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: const validFieldOptions = fieldOptions?.filter((option) => !!option)?.sort(localeCompare); + // Freeze the value selected when the picker opened so it drives the pinned "Selected" section for the whole open/focus cycle. + // The live value still drives the checkmark, so tapping a row marks it without reordering the list. The reorder happens only on reopen. + const initialFieldValue = useInitialSelection(fieldValue, {resetOnFocus: true}); + const sections = getReportFieldOptionsSection({ searchValue: debouncedSearchValue, + // Live value drives the checkmark, so tapping a row marks it immediately. selectedOptions: [ { keyForList: fieldValue, @@ -43,13 +49,14 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: text: fieldValue, }, ], + // Frozen value drives the pinned section, so the list doesn't reorder while selecting. + initiallySelectedValue: initialFieldValue, options: validFieldOptions, recentlyUsedOptions, translate, }); const policyReportFieldData = sections.at(0)?.data ?? []; - const selectedOptionKey = policyReportFieldData.filter((option) => option.searchText === fieldValue)?.at(0)?.keyForList; const textInputOptions = { value: searchValue, @@ -65,7 +72,8 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: shouldShowTextInput textInputOptions={textInputOptions} onSelectRow={(option) => onSubmit({[fieldKey]: !option?.text || fieldValue === option.text ? '' : option.text})} - initiallyFocusedItemKey={selectedOptionKey} + initiallyFocusedItemKey={initialFieldValue} + shouldUpdateFocusedIndex /> ); } diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx index 4536e454ffdb..c1f02a48dfff 100644 --- a/src/pages/EditReportFieldText.tsx +++ b/src/pages/EditReportFieldText.tsx @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {hasCircularReferences} from '@libs/Formula'; import type {FieldList} from '@libs/Formula'; +import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -43,13 +44,16 @@ type EditReportFieldTextPageProps = { function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldKey, fieldList, disabled = false}: EditReportFieldTextPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {inputCallbackRef} = useAutoFocusInput(); + const {inputCallbackRef} = useAutoFocusInput(true); const reportFieldName = Str.UCFirst(fieldName); const validate = useCallback( (values: FormOnyxValues) => { const errors: FormInputErrors = {}; - const inputValue = values[fieldKey].trim(); + // Validate the same value we submit: handleSubmit squashes line breaks to spaces before saving, so + // normalize here too. Otherwise a formula like `{field:Client\nName}` would be validated as a reference + // to `Client\nName` but saved as `{field:Client Name}`, letting a circular reference slip past this check. + const inputValue = StringUtils.lineBreaksToSpaces(values[fieldKey]).trim(); if (isRequired && inputValue === '') { errors[fieldKey] = translate('common.error.fieldRequired'); @@ -64,11 +68,23 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f [fieldName, fieldKey, isRequired, translate, fieldList], ); + const handleSubmit = useCallback( + (values: FormOnyxValues) => { + // The input can grow to multiple lines while editing, but report titles and text fields are single-line + // values. Squash any line breaks the user entered down to a single line before saving. + onSubmit({ + ...values, + [fieldKey]: StringUtils.lineBreaksToSpaces(values[fieldKey]), + }); + }, + [fieldKey, onSubmit], + ); + return ( diff --git a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx index 02d60c340150..2722a7cffb66 100644 --- a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx +++ b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx @@ -75,7 +75,6 @@ function UploadDocuments({onNext, isEditing, policyID}: UploadDocumentsProps) { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { - setIsPDSandFSGDownloadedTouched(true); return getFieldRequiredErrors(values, STEP_FIELDS, translate); }, [STEP_FIELDS, translate], @@ -126,6 +125,7 @@ function UploadDocuments({onNext, isEditing, policyID}: UploadDocumentsProps) { formID={ONYXKEYS.FORMS.ENTER_SINGER_INFO_FORM} submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} onSubmit={handleSubmitWithDownload} + onBeforeSubmit={() => setIsPDSandFSGDownloadedTouched(true)} validate={validate} style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx index 8e3c7e4ff0d5..2bf063c8a602 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx @@ -70,7 +70,6 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { const STEP_FIELDS: Array> = [COPY_OF_ID, ADDRESS_PROOF, PROOF_OF_DIRECTORS, CODICE_FISCALE]; const validate = (values: FormOnyxValues): FormInputErrors => { - setIsPDSandFSGDownloadedTouched(true); return getFieldRequiredErrors(values, STEP_FIELDS, translate); }; @@ -122,6 +121,7 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM} submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} onSubmit={handleSubmitWithDownload} + onBeforeSubmit={() => setIsPDSandFSGDownloadedTouched(true)} validate={validate} style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index c326e3b13437..5cd1f83218db 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -19,7 +19,6 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {search} from '@libs/actions/Search'; @@ -80,7 +79,6 @@ function ReportSubmitToContent({ canSubmitRef, }: ReportSubmitToContentProps) { const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const {translate, localeCompare, dateFnsLocale} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); const isInLandscapeMode = useIsInLandscapeMode(); @@ -458,15 +456,10 @@ function ReportSubmitToContent({ [handleSubmit, translate, keyboardActiveHeight, isInLandscapeMode, shouldUseNarrowLayout], ); - const containerStyle = useMemo(() => { - // `FixedFooter` already pads 20px below the Confirm button, so no extra bottom padding is added here. - const baseStyle = [styles.w100, styles.flex1, styles.pt3]; - if (isInLandscapeMode) { - return baseStyle; - } - - return [...baseStyle, StyleUtils.getMinimumHeight(CONST.POPOVER_REPORT_SUBMIT_TO_CONTENT_HEIGHT)]; - }, [StyleUtils, isInLandscapeMode, styles.flex1, styles.pt3, styles.w100]); + // `flex1` fills the popover height owned by the wrapper `View` in `useReportSubmitToPopover`, so the recipient list + // scrolls inside a constant-size popover rather than resizing it on the empty state. No bottom padding here because + // `FixedFooter` already pads below the Confirm button. + const containerStyle = [styles.w100, styles.flex1, styles.pt3]; if (shouldShowNotFoundView) { return ( diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index 69460b72c14f..099008f0f268 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -73,11 +73,10 @@ function ScheduleCallConfirmationPage() { let dateTimeString = ''; if (scheduleCallDraft?.timeSlot && scheduleCallDraft.date) { const dateString = DateUtils.formatInTimeZoneWithFallback(scheduleCallDraft.date, userTimezone, CONST.DATE.MONTH_DAY_YEAR_FORMAT, {locale: dateFnsLocale}); - const timeString = `${DateUtils.formatInTimeZoneWithFallback(scheduleCallDraft?.timeSlot, userTimezone, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})} - ${DateUtils.formatInTimeZoneWithFallback( + const timeString = `${DateUtils.formatTimeInTimeZoneWithPeriod(translate, scheduleCallDraft?.timeSlot, userTimezone)} - ${DateUtils.formatTimeInTimeZoneWithPeriod( + translate, addMinutes(scheduleCallDraft?.timeSlot, 30), userTimezone, - CONST.DATE.LOCAL_TIME_FORMAT, - {locale: dateFnsLocale}, )}`; const timezoneString = DateUtils.getZoneAbbreviation(new Date(scheduleCallDraft?.timeSlot), userTimezone); diff --git a/src/pages/ScheduleCall/ScheduleCallPage.tsx b/src/pages/ScheduleCall/ScheduleCallPage.tsx index 4e21e7203c0f..57be980bc646 100644 --- a/src/pages/ScheduleCall/ScheduleCallPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallPage.tsx @@ -242,9 +242,7 @@ function ScheduleCallPage() { enableHapticFeedback style={styles.twoColumnLayoutCol} > - - {DateUtils.formatInTimeZoneWithFallback(timeSlot.startTime, userTimezone, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})} - + {DateUtils.formatTimeInTimeZoneWithPeriod(translate, timeSlot.startTime, userTimezone)} ))} {timeFillerItem} diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 46791091eaf2..e37cb6adb5dc 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -335,7 +335,7 @@ function EmptySearchViewContent({ }; } else if (!hasResults || !hasTransactions) { content = { - ...defaultViewItemHeader.folder, + ...defaultViewItemHeader.expenses, title: translate('search.searchResults.emptyExpenseResults.title'), subtitle: translate(hasSeenTour ? 'search.searchResults.emptyExpenseResults.subtitleWithOnlyCreateButton' : 'search.searchResults.emptyExpenseResults.subtitle'), buttons: [ diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 954bb0a6da82..753b6f49a1b7 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -231,7 +231,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { return () => { // Cancel any pending send-message spans to prevent orphaned spans when navigating away - cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE); + cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE); }; }, [reportIDFromRoute]); diff --git a/src/pages/Search/useSearchEmptyStateIllustration/index.ts b/src/pages/Search/useSearchEmptyStateIllustration/index.ts index 2744a2b373aa..6da3efbe5d9c 100644 --- a/src/pages/Search/useSearchEmptyStateIllustration/index.ts +++ b/src/pages/Search/useSearchEmptyStateIllustration/index.ts @@ -4,7 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type UseSearchEmptyStateIllustration from './types'; const useSearchEmptyStateIllustration: UseSearchEmptyStateIllustration = () => { - const illustrations = useMemoizedLazyIllustrations(['Fireworks', 'FolderWithPapersAndWatch']); + const illustrations = useMemoizedLazyIllustrations(['Fireworks', 'FolderWithPapersAndWatch', 'EmptyStateSearchExpenses']); const styles = useThemeStyles(); return { @@ -16,6 +16,10 @@ const useSearchEmptyStateIllustration: UseSearchEmptyStateIllustration = () => { headerMedia: illustrations.FolderWithPapersAndWatch, headerContentStyles: [styles.emptyStateFolderStaticIllustration], }, + expenses: { + headerMedia: illustrations.EmptyStateSearchExpenses, + headerContentStyles: [styles.emptyStateFolderStaticIllustration], + }, }; }; diff --git a/src/pages/Search/useSearchEmptyStateIllustration/types.ts b/src/pages/Search/useSearchEmptyStateIllustration/types.ts index 04fa5939c748..c7b86c90132b 100644 --- a/src/pages/Search/useSearchEmptyStateIllustration/types.ts +++ b/src/pages/Search/useSearchEmptyStateIllustration/types.ts @@ -1,5 +1,5 @@ import type {EmptySearchViewItem} from '@pages/Search/EmptySearchView'; -type UseSearchEmptyStateIllustration = () => Record<'fireworks' | 'folder', Pick>; +type UseSearchEmptyStateIllustration = () => Record<'fireworks' | 'folder' | 'expenses', Pick>; export default UseSearchEmptyStateIllustration; diff --git a/src/pages/TrackTrainingPage.tsx b/src/pages/TrackTrainingPage.tsx index 5a0c4ddacf77..c0cd20de7c94 100644 --- a/src/pages/TrackTrainingPage.tsx +++ b/src/pages/TrackTrainingPage.tsx @@ -1,5 +1,5 @@ import CenteredModalLayout from '@components/CenteredModalLayout'; -import FeatureTrainingContent from '@components/FeatureTrainingContent'; +import FeatureTraining from '@components/FeatureTraining'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useLocalize from '@hooks/useLocalize'; @@ -38,18 +38,23 @@ function TrackTrainingPage() { return ( - { willShowAgainRef.current = willShowAgain; }} - videoURL={CONST.FEATURE_TRAINING[CONST.FEATURE_TRAINING.CONTENT_TYPES.TRACK_EXPENSE]?.VIDEO_URL} - illustrationAspectRatio={VIDEO_ASPECT_RATIO} - /> + > + + + + {translate('common.learnMore')} + {translate('common.buttonConfirm')} + + ); } diff --git a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx index 4e3262563ead..d120d24726ec 100644 --- a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx @@ -62,6 +62,11 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { const sourceReportOwnerAsAttendee = useReportOwnerAsAttendee(sourceTransaction); const targetReportOwnerAsAttendee = useReportOwnerAsAttendee(targetTransaction); + // The workspace of the report the merged expense is currently headed to, which is the one whose rules apply to it. + // An unreported expense lands in the self-DM, which has no workspace, so nothing resolves here. + const [chosenReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(mergeTransaction?.reportID)}`); + const [chosenReportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(chosenReport?.policyID)}`); + const [hasErrors, setHasErrors] = useState>>({}); const conflictFields = useMemo(() => { @@ -109,6 +114,8 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { return newErrors; }); + const selectedTransactionPolicy = transaction.transactionID === targetTransaction?.transactionID ? targetTransactionPolicy : sourceTransactionPolicy; + // Update both the field value and track which transaction was selected (persisted in Onyx) const currentSelections = mergeTransaction?.selectedTransactionByField ?? {}; const updatedValues = getMergeFieldUpdatedValues({ @@ -118,7 +125,11 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { getCurrencyDecimals, mergeTransaction, searchReports: [targetTransactionReport, sourceTransactionReport], - policy: transaction.transactionID === targetTransaction?.transactionID ? targetTransactionPolicy : sourceTransactionPolicy, + policy: selectedTransactionPolicy, + // Selecting a report is what moves the merged expense to a workspace, so it decides which workspace's + // rules apply to it: the workspace of the expense whose report was picked. Any other selection leaves + // the expense on the report already chosen, whose workspace is resolved from Onyx above. + destinationPolicy: field === 'reportID' ? selectedTransactionPolicy : chosenReportPolicy, }); setMergeTransactionKey(transactionID, { @@ -137,6 +148,7 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { targetTransaction?.transactionID, targetTransactionPolicy, sourceTransactionPolicy, + chosenReportPolicy, getCurrencyDecimals, ], ); diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 01aeab028da2..85e7280a94d7 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -24,12 +24,12 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); - const pickUpDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); - const dropOffDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); + const pickUpDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); + const dropOffDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); let cancellationText = reservation.cancellationPolicy; if (reservation.cancellationDeadline) { - cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline, dateFnsLocale)}`; + cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(translate, dateFnsLocale, reservation.cancellationDeadline)}`; } if (reservation.cancellationPolicy === null && reservation.cancellationDeadline === null) { diff --git a/src/pages/Travel/DynamicTravelUpgrade.tsx b/src/pages/Travel/DynamicTravelUpgrade.tsx index 5837b88eaf90..6887cf8601cf 100644 --- a/src/pages/Travel/DynamicTravelUpgrade.tsx +++ b/src/pages/Travel/DynamicTravelUpgrade.tsx @@ -9,6 +9,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getActivePolicies, isPaidGroupPolicy} from '@libs/PolicyUtils'; @@ -17,7 +18,7 @@ import UpgradeIntro from '@pages/workspace/upgrade/UpgradeIntro'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -34,7 +35,7 @@ function DynamicTravelUpgrade() { const isUpgraded = groupPaidPolicies.length > 0; const openWorkspaceConfirmation = () => { - Navigation.navigate(ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.getRoute(Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path)); }; return ( diff --git a/src/pages/Travel/TripDetailsPage.tsx b/src/pages/Travel/DynamicTripDetailsPage.tsx similarity index 93% rename from src/pages/Travel/TripDetailsPage.tsx rename to src/pages/Travel/DynamicTripDetailsPage.tsx index 2e92d9846dd6..a571ee400493 100644 --- a/src/pages/Travel/TripDetailsPage.tsx +++ b/src/pages/Travel/DynamicTripDetailsPage.tsx @@ -4,6 +4,7 @@ import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -14,6 +15,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; import type {TravelNavigatorParamList} from '@libs/Navigation/types'; import {getTripIDFromTransactionParentReportID} from '@libs/ReportUtils'; import {formatCancelledDescription, getReservationDetailsFromSequence, getReservationsFromTripReport} from '@libs/TripReservationUtils'; @@ -23,6 +25,7 @@ import {openTravelDotLink} from '@userActions/Link'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PersonalDetailsList} from '@src/types/onyx'; @@ -40,9 +43,9 @@ function pickTravelerPersonalDetails(personalDetails: OnyxEntry personalDetail?.login === travelerEmail); } -type TripDetailsPageProps = StackScreenProps; +type DynamicTripDetailsPageProps = StackScreenProps; -function TripDetailsPage({route}: TripDetailsPageProps) { +function DynamicTripDetailsPage({route}: DynamicTripDetailsPageProps) { const icons = useMemoizedLazyExpensifyIcons([ 'NewWindow', 'Plane', @@ -64,6 +67,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { const {isBetaEnabled} = usePermissions(); const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); const {isOffline} = useNetwork(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.path); const [isModifyTripLoading, setIsModifyTripLoading] = useState(false); const [isTripSupportLoading, setIsTripSupportLoading] = useState(false); @@ -90,7 +94,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { includeSafeAreaPaddingBottom shouldEnablePickerAvoiding={false} shouldEnableMaxHeight - testID="TripDetailsPage" + testID="DynamicTripDetailsPage" shouldShowOfflineIndicatorInWideScreen > Navigation.goBack(backPath)} icon={reservationIcon} iconHeight={20} iconWidth={20} @@ -171,4 +176,4 @@ function TripDetailsPage({route}: TripDetailsPageProps) { ); } -export default TripDetailsPage; +export default DynamicTripDetailsPage; diff --git a/src/pages/Travel/TripSummaryPage.tsx b/src/pages/Travel/DynamicTripSummaryPage.tsx similarity index 82% rename from src/pages/Travel/TripSummaryPage.tsx rename to src/pages/Travel/DynamicTripSummaryPage.tsx index eee9d2b0cdb3..aa80d2b01452 100644 --- a/src/pages/Travel/TripSummaryPage.tsx +++ b/src/pages/Travel/DynamicTripSummaryPage.tsx @@ -5,25 +5,29 @@ import {ReservationView} from '@components/ReportActionItem/TripDetailsView'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; import type {TravelNavigatorParamList} from '@libs/Navigation/types'; import CONFIG from '@src/CONFIG'; import * as TripReservationUtils from '@src/libs/TripReservationUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; -type TripSummaryPageProps = StackScreenProps; +type DynamicTripSummaryPageProps = StackScreenProps; -function TripSummaryPage({route}: TripSummaryPageProps) { +function DynamicTripSummaryPage({route}: DynamicTripSummaryPageProps) { const {translate} = useLocalize(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.path); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(route.params.transactionID)}`); @@ -34,7 +38,7 @@ function TripSummaryPage({route}: TripSummaryPageProps) { includeSafeAreaPaddingBottom={false} shouldEnablePickerAvoiding={false} shouldEnableMaxHeight - testID="TripSummaryPage" + testID="DynamicTripSummaryPage" shouldShowOfflineIndicatorInWideScreen > Navigation.goBack(backPath)} /> {reservationsData.map(({reservation, transactionID, sequenceIndex, isCancelled}) => { @@ -65,4 +70,4 @@ function TripSummaryPage({route}: TripSummaryPageProps) { ); } -export default TripSummaryPage; +export default DynamicTripSummaryPage; diff --git a/src/pages/Travel/VerifyAccountPage.tsx b/src/pages/Travel/DynamicVerifyAccountPage.tsx similarity index 75% rename from src/pages/Travel/VerifyAccountPage.tsx rename to src/pages/Travel/DynamicVerifyAccountPage.tsx index 8a0bdac326f9..a1e5a2615ecf 100644 --- a/src/pages/Travel/VerifyAccountPage.tsx +++ b/src/pages/Travel/DynamicVerifyAccountPage.tsx @@ -1,3 +1,4 @@ +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -10,16 +11,18 @@ import VerifyAccountPageBase from '@pages/settings/VerifyAccountPageBase'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback, useEffect} from 'react'; -type VerifyAccountPageProps = StackScreenProps; +type DynamicVerifyAccountPageProps = StackScreenProps; -function VerifyAccountPage({route}: VerifyAccountPageProps) { - const {backTo, policyID} = route.params; +function DynamicVerifyAccountPage({route}: DynamicVerifyAccountPageProps) { + const {policyID} = route.params ?? {}; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT.path); const [travelProvisioning] = useOnyx(ONYXKEYS.TRAVEL_PROVISIONING); const {isBetaEnabled} = usePermissions(); @@ -40,12 +43,12 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { }, []); const handleClose = useCallback(() => { - Navigation.goBack(backTo); - }, [backTo]); + Navigation.goBack(backPath); + }, [backPath]); return ( ; - -function WorkspaceConfirmationForTravelPage({route}: WorkspaceConfirmationForTravelPageProps) { +function DynamicWorkspaceConfirmationForTravelPage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -34,9 +28,10 @@ function WorkspaceConfirmationForTravelPage({route}: WorkspaceConfirmationForTra const activePolicy = useActivePolicy(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path); const goBack = () => { - Navigation.goBack(route.params?.backTo ?? createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_UPGRADE.path, ROUTES.TRAVEL_MY_TRIPS.route)); + Navigation.goBack(backPath); }; const onSubmit = (params: WorkspaceConfirmationSubmitFunctionParams) => { @@ -70,7 +65,7 @@ function WorkspaceConfirmationForTravelPage({route}: WorkspaceConfirmationForTra return ( { + setLastSyncedDraft(nextDraft); + saveConciergePromptDraft(nextDraft); + }, + undefined, + true, + ); + + // Onyx owns the draft, so it survives refresh and is wiped along with the rest of the cache. + if (draft !== lastSyncedDraft) { + cancelSaveDraft(); + setLastSyncedDraft(draft); + setValue(draft ?? ''); + setSelection({start: draft?.length ?? 0, end: draft?.length ?? 0}); + debouncedCommentMaxLengthValidation(draft ?? ''); + debouncedCommentMaxLengthValidation.flush(); + } + const [isFocused, setIsFocused] = useState(false); const [longPlaceholderHeight, setLongPlaceholderHeight] = useState(null); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); const actionButtonRef = useRef(null); + const animatedRef = useAnimatedRef(); + + // The native Composer only forwards its underlying input to a callback ref, so an object ref would never be populated. + const composerRef = useRef(null); + + const setComposerRef = (element: ComposerRef) => { + animatedRef(element); + composerRef.current = element; + }; const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); + + debouncedCommentMaxLengthValidation(''); + debouncedCommentMaxLengthValidation.flush(); + scheduleOnUI(forceClearInput, animatedRef); + cancelSaveDraft(); + setLastSyncedDraft(undefined); + saveConciergePromptDraft(null); }; const sendAttachment = (attachments: FileObject | FileObject[]) => { - askConciergeWithAttachment(attachments, value); - clearInput(); + interceptAnonymousUser(() => { + askConciergeWithAttachment(attachments, value); + clearInput(); + }); }; const {pickAttachments, PDFValidationComponent} = useConciergeAttachmentPicker(conciergeTargetReportID, sendAttachment); @@ -111,14 +164,18 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo // Default to the short copy until measured, so it never flashes a wrapped long placeholder that then collapses. const longPlaceholderFitsOneLine = longPlaceholderHeight !== null && longPlaceholderHeight <= SINGLE_LINE_PLACEHOLDER_MAX_HEIGHT; const placeholder = shouldUseNarrowLayout || !longPlaceholderFitsOneLine ? shortPlaceholder : longPlaceholder; - const canSubmit = shouldShowAskConcierge && value.trim().length > 0; + const canSubmit = shouldShowAskConcierge && value.trim().length > 0 && !isExceedingMaxLength; + + const canAddAttachment = shouldShowAskConcierge && !isExceedingMaxLength; const submit = () => { - if (!canSubmit) { + if (!canSubmit || debouncedCommentMaxLengthValidation.flush() === false) { return; } - askConcierge(value); - clearInput(); + interceptAnonymousUser(() => { + askConcierge(value); + clearInput(); + }); }; const handleKeyPress = (event: TextInputKeyPressEvent) => { @@ -140,123 +197,157 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo {dateLabel} {greeting} - - - - - - {({openPicker}) => { - const triggerAttachmentPicker = () => openPicker({onPicked: pickAttachments}); - return ( - <> - - { - e?.preventDefault(); - actionButtonRef.current?.blur(); - setIsMenuVisible((prev) => !prev); + + + + + + + {({openPicker}) => { + const triggerAttachmentPicker = () => openPicker({onPicked: pickAttachments}); + return ( + <> + + { + e?.preventDefault(); + actionButtonRef.current?.blur(); + interceptAnonymousUser(() => setIsMenuVisible((prev) => !prev)); + }} + style={({hovered, pressed}) => [ + styles.composerSizeButton, + StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered && canAddAttachment, pressed && canAddAttachment)), + ]} + > + {({hovered, pressed}) => ( + + )} + + + setIsMenuVisible(false)} + onItemSelected={() => { + setIsMenuVisible(false); + + // On Safari the file picker must be opened from within the user-initiated + // event handler, so it can't wait for the popover to finish closing. + if (isSafari()) { + triggerAttachmentPicker(); + return; + } + close(triggerAttachmentPicker); }} - style={({hovered, pressed}) => [ - styles.composerSizeButton, - StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered && shouldShowAskConcierge, pressed && shouldShowAskConcierge)), + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + menuItems={[ + { + icon: icons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + shouldCallAfterModalHide: shouldUseNarrowLayout, + }, ]} - > - {({hovered, pressed}) => ( - - )} - - - setIsMenuVisible(false)} - onItemSelected={() => { - setIsMenuVisible(false); - - // On Safari the file picker must be opened from within the user-initiated - // event handler, so it can't wait for the popover to finish closing. - if (isSafari()) { - triggerAttachmentPicker(); - return; - } - close(triggerAttachmentPicker); - }} - anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - }} - menuItems={[ - { - icon: icons.Paperclip, - text: translate('reportActionCompose.addAttachment'), - shouldCallAfterModalHide: shouldUseNarrowLayout, - }, - ]} - anchorRef={actionButtonRef} - /> - - ); - }} - + anchorRef={actionButtonRef} + /> + + ); + }} + + + - - - - setSelection(event.nativeEvent.selection)} - shouldCalculateCaretPosition - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onKeyPress={handleKeyPress} - maxLines={MAX_INPUT_LINES} - multiline - textAlignVertical="top" - placeholder={placeholder} - placeholderTextColor={theme.placeholderText} - accessibilityLabel={placeholder} - /> - {/* Hidden probe stretched to the input's width. Its height reveals whether the long placeholder wraps past one line. */} - setLongPlaceholderHeight(event.nativeEvent.layout.height)} - > - + { + setValue(text); + debouncedCommentMaxLengthValidation(text); + debouncedSaveDraft(text); + }} + selection={selection} + onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} + shouldCalculateCaretPosition + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onKeyPress={handleKeyPress} + onPasteFile={(files) => { + // Concierge isn't reachable yet, so there is nowhere to send the paste. Mirrors the disabled "+" button. + if (!shouldShowAskConcierge) { + return; + } + + interceptAnonymousUser(() => { + // Blur before the attachment preview modal takes over, so the keyboard doesn't stay up over it. + composerRef.current?.blur(); + pickAttachments(files); + }); + }} + maxLines={MAX_INPUT_LINES} + multiline + textAlignVertical="top" + placeholder={placeholder} + placeholderTextColor={theme.placeholderText} + accessibilityLabel={placeholder} + /> + {/* Hidden probe stretched to the input's width. Its height reveals whether the long placeholder wraps past one line. */} + setLongPlaceholderHeight(event.nativeEvent.layout.height)} > - {longPlaceholder} - + + {longPlaceholder} + + + + {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} + + - {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} - - - + {!!exceededMaxLength && ( + + + + )} {PDFValidationComponent} diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index fe5f92f0762a..cf747b998727 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -213,10 +213,13 @@ function ForYouSection({isConciergeMenuVisible, setIsConciergeMenuVisible}: ForY isOnboardingStatusKnown, }); + const willOnlyShowConciergePromptBox = timeSensitiveItems.length === 0 && hideForYou; + // The card always renders so the Concierge input stays on the home page. `hideForYou` only gates the "For you" // heading and todos or empty-state below it. When hidden with no time-sensitive content, the card is just the box. return ( { - const fileObjects = files.map((item) => cleanFileObjectName(cleanFileObject(item))); + // A pasted file arrives as a single FileObject on native and for base64/Google Workspace images on web, so both shapes are accepted. + const pickAttachments = (files: FileObject | FileObject[]) => { + const fileObjects = (Array.isArray(files) ? files : [files]).map((item) => cleanFileObjectName(cleanFileObject(item))); if (!fileObjects.length) { return; } diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 8449cb7f9c44..8ed189ae7519 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -64,6 +64,7 @@ function HomePage() { {!shouldUseNarrowLayout && ( diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts new file mode 100644 index 000000000000..85e77007be9c --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts @@ -0,0 +1,30 @@ +import useOnyx from '@hooks/useOnyx'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PrivatePersonalDetails} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +const hasHomeAndOfficeWorkspaceSelector = (policies: OnyxCollection) => + Object.values(policies ?? {}).some( + (policy) => policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + +const hasHomeAddressSelector = (privatePersonalDetails: OnyxEntry) => (privatePersonalDetails?.addresses ?? []).some((address) => !!address?.street?.trim()); + +/** + * Surfaces the "Add a home address" item under Home > Time sensitive when the user belongs to at + * least one active workspace that has the homeAndOffice commuter-exclusion method enabled but has + * no home address recorded in their private personal details. + */ +function useTimeSensitiveHomeAddress() { + const [hasHomeAndOfficeWorkspace] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasHomeAndOfficeWorkspaceSelector}); + const [hasHomeAddress] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {selector: hasHomeAddressSelector}); + + return { + shouldShowAddHomeAddress: !!hasHomeAndOfficeWorkspace && !hasHomeAddress, + }; +} + +export default useTimeSensitiveHomeAddress; diff --git a/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx b/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx new file mode 100644 index 000000000000..7efdece3239f --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx @@ -0,0 +1,36 @@ +import BaseWidgetItem from '@components/BaseWidgetItem'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; + +import Navigation from '@libs/Navigation/Navigation'; + +import colors from '@styles/theme/colors'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; + +import React from 'react'; + +function AddHomeAddress() { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Home']); + + return ( + Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(INPUT_IDS.ADDRESS_LINE_1))} + buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} + /> + ); +} + +export default AddHomeAddress; diff --git a/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx index 1ab74940341c..e74009c12c7f 100644 --- a/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx +++ b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx @@ -18,10 +18,12 @@ import useTimeSensitiveAddBankAccount from './hooks/useTimeSensitiveAddBankAccou import useTimeSensitiveAddPaymentCard from './hooks/useTimeSensitiveAddPaymentCard'; import useTimeSensitiveBilling from './hooks/useTimeSensitiveBilling'; import useTimeSensitiveCards from './hooks/useTimeSensitiveCards'; +import useTimeSensitiveHomeAddress from './hooks/useTimeSensitiveHomeAddress'; import useTimeSensitiveLockedBankAccount from './hooks/useTimeSensitiveLockedBankAccount'; import useTimeSensitiveSignerInfo from './hooks/useTimeSensitiveSignerInfo'; import ActivateCard from './items/ActivateCard'; import AddBankAccount from './items/AddBankAccount'; +import AddHomeAddress from './items/AddHomeAddress'; import AddPaymentCard from './items/AddPaymentCard'; import AddShippingAddress from './items/AddShippingAddress'; import AddVirtualCardPersonalDetails from './items/AddVirtualCardPersonalDetails'; @@ -61,6 +63,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { virtualCardsNeedingPersonalDetails, } = useTimeSensitiveCards(); const {shouldShowFixFailedBilling} = useTimeSensitiveBilling(); + const {shouldShowAddHomeAddress} = useTimeSensitiveHomeAddress(); const [connectionSyncProgress] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); // the selector derives both, so this never deep-compares employeeList/connections/customUnits per policy @@ -115,6 +118,10 @@ function useTimeSensitiveItems(): React.ReactNode[] { if (shouldShowValidateAccount) { items.push(); } + // Priority 1b: Add home address (commuter exclusions, homeAndOffice method) + if (shouldShowAddHomeAddress) { + items.push(); + } // Priority 2: Failed billing for existing customers if (shouldShowFixFailedBilling) { items.push(); diff --git a/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx b/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx index 6c65c8724bb7..45c58e048c30 100644 --- a/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx +++ b/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx @@ -8,13 +8,14 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {formatCancelledDescription, getTripReservationIcon} from '@libs/TripReservationUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Reservation} from '@src/types/onyx/Transaction'; import {differenceInCalendarDays} from 'date-fns'; @@ -107,7 +108,7 @@ function UpcomingTravelItem({reservation: upcomingReservation}: UpcomingTravelIt const subtitle = typeId ? `${relativeTime} ${CONST.DOT_SEPARATOR} ${typeId}` : relativeTime; const handlePress = () => { - Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(reportID, transactionID, reservation.reservationID, sequenceIndex)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.getRoute(reportID, transactionID, reservation.reservationID, sequenceIndex))); }; return ( diff --git a/src/pages/inbox/ReportLifecycleHandler.tsx b/src/pages/inbox/ReportLifecycleHandler.tsx index eec951c9e567..87a267c92ab9 100644 --- a/src/pages/inbox/ReportLifecycleHandler.tsx +++ b/src/pages/inbox/ReportLifecycleHandler.tsx @@ -51,7 +51,7 @@ function ReportLifecycleHandler({reportID}: ReportLifecycleHandlerProps) { cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${onyxReportID}`); // Cancel any pending send-message spans to prevent orphaned spans when navigating away - cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE); + cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE); }; }, [onyxReportID]); diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 63b8ebc6c3f5..02cfc9413fa3 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -183,6 +183,7 @@ import { getIOUReportActionDisplayMessage, getMovedActionMessage, getMovedTransactionMessage, + parseMovedTransactionReportIDs, getPolicyChangeLogCopyMessage, getPolicyChangeMessage, getReimbursementDeQueuedOrCanceledActionMessage, @@ -1167,7 +1168,14 @@ const ContextMenuActions: ContextMenuAction[] = [ } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP) { setClipboardMessage(Parser.htmlToText(getUpdatedOwnershipMessage(translate, reportAction, policy) ?? '')); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - setClipboardMessage(getUnreportedTransactionMessage(translate, reportAction, reportAttributes)); + const {fromReportID} = parseMovedTransactionReportIDs(reportAction); + setClipboardMessage( + getUnreportedTransactionMessage({ + translate, + fromReportID, + derivedReportName: fromReportID ? reportAttributes?.[fromReportID]?.reportName : undefined, + }), + ); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { Clipboard.setString(getMarkedReimbursedMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED)) { @@ -1378,7 +1386,15 @@ const ContextMenuActions: ContextMenuAction[] = [ ) { setClipboardMessage(getDelegateSubmitMessage(translate, reportAction, currentUserPersonalDetails.email)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { - setClipboardMessage(getMovedTransactionMessage(translate, reportAction, reportAttributes)); + const {fromReportID, toReportID, displayReportID} = parseMovedTransactionReportIDs(reportAction); + setClipboardMessage( + getMovedTransactionMessage({ + translate, + fromReportID, + toReportID, + derivedReportName: displayReportID ? reportAttributes?.[displayReportID]?.reportName : undefined, + }), + ); } else if (isMovedAction(reportAction)) { setClipboardMessage(getMovedActionMessage(translate, reportAction, originalReport)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT)) { diff --git a/src/pages/inbox/report/ParticipantLocalTime.tsx b/src/pages/inbox/report/ParticipantLocalTime.tsx index 38900b3ee60d..c68f6a409e0a 100644 --- a/src/pages/inbox/report/ParticipantLocalTime.tsx +++ b/src/pages/inbox/report/ParticipantLocalTime.tsx @@ -18,7 +18,12 @@ type ParticipantLocalTimeProps = { participant: PersonalDetails; }; -function getParticipantLocalTime(participant: PersonalDetails, getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'], dateFnsLocale: LocaleContextProps['dateFnsLocale']) { +function getParticipantLocalTime( + participant: PersonalDetails, + translate: LocaleContextProps['translate'], + getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'], + dateFnsLocale: LocaleContextProps['dateFnsLocale'], +) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null const reportRecipientTimezone = participant.timezone || CONST.DEFAULT_TIME_ZONE; const reportTimezone = getLocalDateFromDatetime(undefined, reportRecipientTimezone.selected); @@ -26,26 +31,26 @@ function getParticipantLocalTime(participant: PersonalDetails, getLocalDateFromD const reportRecipientDay = DateUtils.formatToDayOfWeek(reportTimezone, dateFnsLocale); const currentUserDay = DateUtils.formatToDayOfWeek(currentTimezone, dateFnsLocale); if (reportRecipientDay !== currentUserDay) { - return `${DateUtils.formatToLocalTime(reportTimezone, dateFnsLocale)} ${reportRecipientDay}`; + return `${DateUtils.formatToLocalTime(translate, reportTimezone)} ${reportRecipientDay}`; } - return `${DateUtils.formatToLocalTime(reportTimezone, dateFnsLocale)}`; + return `${DateUtils.formatToLocalTime(translate, reportTimezone)}`; } function ParticipantLocalTime({participant}: ParticipantLocalTimeProps) { const {translate, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); const styles = useThemeStyles(); - const [localTime, setLocalTime] = useState(() => getParticipantLocalTime(participant, getLocalDateFromDatetime, dateFnsLocale)); + const [localTime, setLocalTime] = useState(() => getParticipantLocalTime(participant, translate, getLocalDateFromDatetime, dateFnsLocale)); useEffect(() => { const timer = Timers.register( setInterval(() => { - setLocalTime(getParticipantLocalTime(participant, getLocalDateFromDatetime, dateFnsLocale)); + setLocalTime(getParticipantLocalTime(participant, translate, getLocalDateFromDatetime, dateFnsLocale)); }, 1000), ); return () => { clearInterval(timer); }; - }, [participant, getLocalDateFromDatetime, dateFnsLocale]); + }, [participant, translate, getLocalDateFromDatetime, dateFnsLocale]); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null const reportRecipientDisplayName = participant.firstName || participant.displayName; diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 98168bee7f15..852c6167b64d 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -7,9 +7,9 @@ import PopoverMenu from '@components/PopoverMenu'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -28,7 +28,15 @@ import getButtonState from '@libs/getButtonState'; import getIconForAction from '@libs/getIconForAction'; import Navigation from '@libs/Navigation/Navigation'; import {isGroupPolicyByType} from '@libs/PolicyUtils'; -import {canCreateTaskInReport, getPayeeName, hasViolations as hasViolationsReportUtils, isPolicyExpenseChat, isReportOwner, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; +import { + canCreateTaskInReport, + getPayeeName, + hasViolations as hasViolationsReportUtils, + isPolicyExpenseChat, + isReportOwner, + isTeachersUniteReport, + temporary_getMoneyRequestOptions, +} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {startDistanceRequest, startMoneyRequest} from '@userActions/IOU/MoneyRequest'; @@ -170,7 +178,12 @@ function AttachmentPickerWithMenuItems({ const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - const {isProduction} = useEnvironment(); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); const {setIsLoaderVisible} = useFullScreenLoaderActions(); const isReportArchived = useReportIsArchived(report?.reportID); @@ -231,8 +244,7 @@ function AttachmentPickerWithMenuItems({ } }; - const teacherUnitePolicyID = isProduction ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; - const isTeachersUniteReport = report?.policyID === teacherUnitePolicyID; + const isReportTeachersUnite = isTeachersUniteReport(report); /** * Returns the list of IOU Options @@ -262,10 +274,12 @@ function AttachmentPickerWithMenuItems({ shouldCallAfterModalHide: shouldUseNarrowLayout, sentryLabel: CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_MENU_TRACK_DISTANCE, onSelected: () => - selectOption( - () => startDistanceRequest(CONST.IOU.TYPE.SUBMIT, report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), draftTransactionIDs, lastDistanceExpenseType), - true, - ), + selectOption(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), draftTransactionIDs, lastDistanceExpenseType); + }, true), }, ], [CONST.IOU.TYPE.PAY]: [ @@ -299,10 +313,12 @@ function AttachmentPickerWithMenuItems({ shouldCallAfterModalHide: shouldUseNarrowLayout, sentryLabel: CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_MENU_TRACK_DISTANCE, onSelected: () => - selectOption( - () => startDistanceRequest(CONST.IOU.TYPE.TRACK, report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), draftTransactionIDs, lastDistanceExpenseType), - true, - ), + selectOption(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } + startDistanceRequest(CONST.IOU.TYPE.TRACK, report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), draftTransactionIDs, lastDistanceExpenseType); + }, true), }, ], [CONST.IOU.TYPE.INVOICE]: [ @@ -323,6 +339,7 @@ function AttachmentPickerWithMenuItems({ return moneyRequestOptionsList.flat().filter((item, index, self) => index === self.findIndex((t) => t.text === item.text)); }, [ accountID, + blockDistanceRequestIfNeeded, isDelegateAccessRestricted, isReportArchived, isRestrictedToPreferredPolicy, @@ -445,7 +462,7 @@ function AttachmentPickerWithMenuItems({ }; const menuItems = [ ...moneyRequestOptions, - ...(!isTeachersUniteReport ? createReportOption : []), + ...(!isReportTeachersUnite ? createReportOption : []), ...taskOption, { icon: icons.Paperclip, diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 7e8841eea50e..c970a824a33c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -353,7 +353,7 @@ function ComposerWithSuggestions({ const ignoreEditSelectionResetTimeoutRef = useRef | undefined>(undefined); const handleEditFocus = useCallback(() => { - focus(true, undefined, true); + focus(true, undefined, editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); onFocus(); if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING) { diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index b3c2c6533a5d..17ff0ca76f0b 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -7,18 +7,13 @@ import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {addAttachmentWithComment, addComment, clearAgentZeroProcessingIndicator} from '@libs/actions/Report'; -import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; -import {isEmailPublicDomain} from '@libs/LoginUtils'; +import {createTaskFromMarkdown} from '@libs/actions/Task'; import {rand64} from '@libs/NumberUtils'; -import {addDomainToShortMention} from '@libs/ParsingUtils'; -import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getAllReportActions} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction, generateReportID, isConciergeChatReport} from '@libs/ReportUtils'; -import {getAllPersonalDetailLogins} from '@libs/ShortMentionLogins'; import {startSpan} from '@libs/telemetry/activeSpans'; import getSendMessageListWeight from '@libs/telemetry/getSendMessageListWeight'; import getSendMessageSource from '@libs/telemetry/getSendMessageSource'; -import {generateAccountID} from '@libs/UserUtils'; import {useActionListContext} from '@pages/inbox/ActionListContext'; @@ -26,12 +21,8 @@ import {setIsComposerFullSize} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type * as OnyxTypes from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import {Str} from 'expensify-common'; import {useComposerActions, useComposerEditActions, useComposerEditState, useComposerMeta, useComposerSendState} from './ComposerContext'; import useComposerReportData from './useComposerReportData'; @@ -62,8 +53,6 @@ function useComposerSubmit(reportID: string) { const reportAncestors = useAncestors(report); const targetReportAncestors = useAncestors(targetReport); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; - /** * Add or edit a comment in the composer */ @@ -107,57 +96,8 @@ function useComposerSubmit(reportID: string) { return; } - const taskMatch = draftMessageTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskMatch) { - let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (taskTitle) { - const mention = taskMatch[1] ? taskMatch[1].trim() : ''; - const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); - const mentionWithDomain = addDomainToShortMention(mention, getAllPersonalDetailLogins(), currentUserPrivateDomain) ?? mention; - const isValidMention = Str.isValidEmail(mentionWithDomain); - - let assignee: OnyxEntry; - let assigneeChatReport; - if (mentionWithDomain) { - if (isValidMention) { - assignee = getPersonalDetailByEmail(mentionWithDomain); - if (!assignee) { - const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { - accountID: generateAccountID(mentionWithDomain), - login: mentionWithDomain, - }); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; - } - } else { - taskTitle = `@${mentionWithDomain} ${taskTitle}`; - } - } - - const taskCreatorAndAssigneeDetails = {[currentUserPersonalDetails.accountID]: currentUserPersonalDetails}; - if (assignee) { - taskCreatorAndAssigneeDetails[assignee.accountID] = assignee; - } - - createTaskAndNavigate({ - parentReport: report, - title: taskTitle, - description: '', - assigneeEmail: assignee?.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail, - currentUserDisplayName: currentUserPersonalDetails.displayName, - currentUserAvatar: currentUserPersonalDetails.avatar, - assigneeAccountID: assignee?.accountID, - assigneeChatReport, - policyID: report?.policyID, - isCreatedUsingMarkdown: true, - quickAction, - ancestors: reportAncestors, - taskCreatorAndAssigneeDetails, - }); - return; - } + if (createTaskFromMarkdown({text: draftMessageTrimmed, parentReport: report, currentUserPersonalDetails, quickAction, delegateAccountID, ancestors: reportAncestors})) { + return; } const optimisticReportActionID = rand64(); @@ -171,13 +111,6 @@ function useComposerSubmit(reportID: string) { [CONST.TELEMETRY.ATTRIBUTE_REPORT_ACTION_COUNT]: reportActionCount, [CONST.TELEMETRY.ATTRIBUTE_MONEY_REQUEST_PREVIEW_COUNT]: moneyRequestPreviewCount, }; - startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { - name: 'send-message', - op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, - attributes, - }); - // Dual-emit while validating the new anchor: same start, but ended at the message's onLayout - // (visibility) instead of the passive effect. Remove the effect-anchored span once compared. startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${optimisticReportActionID}`, { name: 'send-message-visible', op: CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE, diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index cf51c1406fc3..95e7979a6360 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -69,6 +69,7 @@ import ChatTransactionPreview from './ChatTransactionPreview'; import ConciergeAutoMatchVendorContent from './ConciergeAutoMatchVendorContent'; import ConfirmWhisperContent from './ConfirmWhisperContent'; import FraudAlertContent from './FraudAlertContent'; +import HomeAddressRequiredContent from './HomeAddressRequiredContent'; import IntegrationMessage from './IntegrationMessage'; import IntegrationSyncFailedMessage from './IntegrationSyncFailedMessage'; import JoinRequestContent from './JoinRequestContent'; @@ -514,6 +515,9 @@ function ActionContentRouter({ ); } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.HOME_ADDRESS_REQUIRED)) { + return ; + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ACTION_DELEGATE_SUBMIT)) { const delegateSubmitMessage = getDelegateSubmitMessage(translate, action, currentUserEmail); if (delegateSubmitMessage) { diff --git a/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx new file mode 100644 index 000000000000..9f4db9776f51 --- /dev/null +++ b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx @@ -0,0 +1,45 @@ +import Button from '@components/ButtonComposed'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; + +import useLocalize from '@hooks/useLocalize'; + +import Navigation from '@libs/Navigation/Navigation'; +import {getOriginalMessage, getReportActionText} from '@libs/ReportActionsUtils'; + +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import type {ReportAction} from '@src/types/onyx'; + +import React from 'react'; + +type HomeAddressRequiredContentProps = { + action: ReportAction; +}; + +function HomeAddressRequiredContent({action}: HomeAddressRequiredContentProps) { + const {translate} = useLocalize(); + + // The prompt is resolved once the member saves a home address. The backend clears the actionable state + // and stamps a resolution, so hide the CTA to avoid pointing at a task that is already done. + const isResolved = !!getOriginalMessage(action)?.resolution; + + return ( + + {!isResolved && ( + + + + )} + + ); +} + +export default HomeAddressRequiredContent; diff --git a/src/pages/inbox/report/comment/TextCommentFragment.tsx b/src/pages/inbox/report/comment/TextCommentFragment.tsx index 4cf8c26e8aaf..2ba09e309005 100644 --- a/src/pages/inbox/report/comment/TextCommentFragment.tsx +++ b/src/pages/inbox/report/comment/TextCommentFragment.tsx @@ -25,7 +25,6 @@ import type {StyleProp, TextStyle} from 'react-native'; import {Str} from 'expensify-common'; import isEmpty from 'lodash/isEmpty'; -import {useEffect} from 'react'; import {View} from 'react-native'; import RenderCommentHTML from './RenderCommentHTML'; @@ -71,14 +70,6 @@ function TextCommentFragment({fragment, styleAsDeleted, reportActionID, styleAsM const processedTextArray = splitTextWithEmojis(message); - // Original effect anchor, kept while the visible variant below is validated against it in Sentry. - useEffect(() => { - if (!reportActionID) { - return; - } - endSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`); - }, [reportActionID]); - const endSendMessageVisibleSpanOnLayout = () => { if (!reportActionID) { return; diff --git a/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx b/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx index f5b3d0eec877..43d51e43ecb9 100644 --- a/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx +++ b/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx @@ -23,6 +23,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ComponentType} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useEffect} from 'react'; type WithReportAndReportActionOrNotFoundProps = PlatformStackScreenProps< @@ -55,6 +56,7 @@ function WithReportOrNotFoundImpl interceptAnonymousUser(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } // Start the flow to start tracking a distance request startDistanceRequest(CONST.IOU.TYPE.CREATE, reportID, draftTransactionIDs, lastDistanceExpenseType, undefined, undefined, true); }) diff --git a/src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx b/src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx index e83b0ad7c8e9..b6556819d62d 100644 --- a/src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx +++ b/src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx @@ -45,7 +45,7 @@ function DynamicSplitExpenseCreateDateRangePage({route}: DynamicSplitExpenseCrea const {translate} = useLocalize(); const {currentSearchResults} = useSearchResultsContext(); - const {splitReportID: reportID, transactionID} = route.params; + const {splitReportID: reportID, originalTransactionID: transactionID} = route.params; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_SPLIT_EXPENSE_CREATE_DATE_RANGE.path); const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx similarity index 98% rename from src/pages/iou/SplitExpenseEditPage.tsx rename to src/pages/iou/DynamicSplitExpenseEditPage.tsx index 50d1255f2d5e..9c1b5089d962 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -15,6 +15,7 @@ import useAllTransactions from '@hooks/useAllTransactions'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -37,7 +38,7 @@ import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SplitExpenseParamList} from '@libs/Navigation/types'; +import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {arePolicyRulesEnabled, getDistanceRateCustomUnitRate, getTagLists, hasAnyPaidPolicy, isGroupPolicyByType, isTaxTrackingEnabled} from '@libs/PolicyUtils'; @@ -68,7 +69,7 @@ import {policyTypeSelector} from '@selectors/Policy'; import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; -type SplitExpenseEditPageProps = PlatformStackScreenProps; +type DynamicSplitExpenseEditPageProps = PlatformStackScreenProps; type SplitToggleRowProps = { label: string; @@ -90,7 +91,7 @@ function SplitToggleRow({label, isOn, onToggle}: SplitToggleRowProps) { ); } -function SplitExpenseEditPage({route}: SplitExpenseEditPageProps) { +function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); @@ -98,7 +99,8 @@ function SplitExpenseEditPage({route}: SplitExpenseEditPageProps) { const {convertToDisplayString, getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {currentSearchResults} = useSearchResultsContext(); - const {reportID, transactionID, splitExpenseTransactionID = '', backTo} = route.params; + const {reportID, originalTransactionID: transactionID, editSplitExpenseTransactionID: splitExpenseTransactionID = ''} = route.params; + const backTo = useDynamicBackPath(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.path); const [splitExpenseDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`); const [originalTransactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${splitExpenseDraftTransaction?.comment?.originalTransactionID}`); @@ -373,7 +375,7 @@ function SplitExpenseEditPage({route}: SplitExpenseEditPageProps) { ) : null; return ( - + { initDraftSplitExpenseDataForEdit(draftTransaction, item.transactionID, item.reportID ?? reportID); - Navigation.navigate(ROUTES.SPLIT_EXPENSE_EDIT.getRoute(item.reportID ?? reportID, originalTransactionID, item.transactionID, Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.getRoute(item.reportID ?? reportID, item.transactionID))); }, }); }; diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index faf02fa671e3..da0d01a34bb2 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -25,6 +25,7 @@ import useUserToInviteReports from '@hooks/useUserToInviteReports'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import goToSettings from '@libs/goToSettings'; import {isMovingTransactionFromTrackExpense} from '@libs/IOUUtils'; +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import Navigation from '@libs/Navigation/Navigation'; import {formatSectionsFromSearchTerm, getHeaderMessage, getParticipantsOption, getPolicyExpenseReportOption, isCurrentUser} from '@libs/OptionsListUtils'; import type {Option} from '@libs/OptionsListUtils'; @@ -322,7 +323,9 @@ function ParticipantSearchResults({ ); sections.push({...formatResults.section, sectionIndex: 0}); - const workspaceChats = (availableOptions.workspaceChats ?? []).filter((option) => !selectedParticipantKeys.has(getParticipantOptionKey(option))); + const workspaceChats = (availableOptions.workspaceChats ?? []).filter( + (option) => !selectedParticipantKeys.has(getParticipantOptionKey(option)) && !isTeachersUnitePolicyID(option.policyID), + ); if (workspaceChats.length > 0) { sections.push({ title: translate('workspace.common.workspace'), diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx index 95245d40c2b0..bc3a8d2924c5 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx @@ -5,6 +5,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; @@ -47,9 +48,8 @@ import {isPolicyExpenseChat as isPolicyExpenseChatUtil, isSelfDM} from '@libs/Re import {getDistanceInMeters, getRateID, getRequestType, getSelectedRouteKey, hasManualDistanceOverride, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; -import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -363,6 +363,11 @@ function DynamicIOURequestStepDistance({ return stop; }, []); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + }); + useEffect(() => { if (numberOfWaypoints <= numberOfPreviousWaypoints) { return; @@ -423,22 +428,18 @@ function DynamicIOURequestStepDistance({ */ const navigateToWaypointEditPage = useCallback( (index: number) => { - let iouWaypointType = CONST.IOU.TYPE.SUBMIT as IOUType; - if (isEditingSplit) { - iouWaypointType = CONST.IOU.TYPE.SPLIT_EXPENSE; - } // In the edit flow this page is wrapped in an OnyxTabNavigator, so Navigation.getActiveRoute() - // returns a URL with the tab suffix (e.g. "/distance-map") that doesn't match the stack entry - // — Navigation.goBack() then REPLACEs instead of POPs and crashes. Build the backTo URL + // returns a URL with the tab suffix (e.g. "/distance-map") that doesn't match the stack entry. + // Navigation.goBack() then REPLACEs instead of POPs and crashes, so build the base URL // explicitly there. The create flow has no tab navigator, so the production getActiveRoute() // path is correct (GH #90037). - const waypointBackTo = + const waypointBase = isEditing && backTo ? createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, report?.reportID ?? reportID), backTo) : Navigation.getActiveRoute(); - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(action, iouWaypointType, transactionID, report?.reportID ?? reportID, index.toString(), waypointBackTo)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(index), waypointBase)); }, - [action, iouType, transactionID, report?.reportID, reportID, backTo, isEditingSplit, isEditing], + [action, iouType, transactionID, report?.reportID, reportID, backTo, isEditing], ); const navigateToNextStep = useDistanceNavigation({ @@ -530,6 +531,9 @@ function DynamicIOURequestStepDistance({ ); const submitWaypoints = useCallback(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } // If there is any error or loading state, don't let user go to next page. if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || (!isEditing && isLoading)) { setShouldShowAtLeastTwoDifferentWaypointsError(true); @@ -618,6 +622,7 @@ function DynamicIOURequestStepDistance({ suppressDiscardPrompt(); navigateToNextStep(); }, [ + blockDistanceRequestIfNeeded, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, @@ -655,6 +660,9 @@ function DynamicIOURequestStepDistance({ ]); const submitManualDistance = useCallback(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } isManuallyEditing.current = false; // For a map-based distance edit, require valid waypoints even when saving from the Manual tab. @@ -750,6 +758,7 @@ function DynamicIOURequestStepDistance({ removeBackupTransaction(transaction?.transactionID); navigateBackAfterSave(); }, [ + blockDistanceRequestIfNeeded, transactionBackup, getHasSelectedRouteChanged, duplicateWaypointsError, diff --git a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx b/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx similarity index 95% rename from src/pages/iou/request/step/IOURequestStepWaypoint.tsx rename to src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx index 26fd16814ea2..f0d9d3c0a91b 100644 --- a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx @@ -8,6 +8,7 @@ import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useLocationBias from '@hooks/useLocationBias'; import useNetwork from '@hooks/useNetwork'; @@ -27,7 +28,7 @@ import {removeWaypoint, saveWaypoint} from '@userActions/Transaction'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {RecentWaypoint, Transaction} from '@src/types/onyx'; import type {Waypoint} from '@src/types/onyx/Transaction'; @@ -66,17 +67,18 @@ function recentWaypointsSelector(waypoints: RecentWaypoint[] = []): PredefinedPl })); } -type IOURequestStepWaypointProps = WithWritableReportOrNotFoundProps & { +type DynamicIOURequestStepWaypointProps = WithWritableReportOrNotFoundProps & { transaction: OnyxEntry; }; -function IOURequestStepWaypoint({ +function DynamicIOURequestStepWaypoint({ route: { - params: {action, backTo, iouType, pageIndex, reportID, transactionID}, + params: {action, iouType, pageIndex, transactionID}, }, transaction, -}: IOURequestStepWaypointProps) { +}: DynamicIOURequestStepWaypointProps) { const styles = useThemeStyles(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_WAYPOINT.path); const navigation = useNavigation(); const isFocused = navigation.isFocused(); const {translate} = useLocalize(); @@ -117,11 +119,7 @@ function IOURequestStepWaypoint({ (Number.isNaN(parsedWaypointIndex) || parsedWaypointIndex < 0 || parsedWaypointIndex > waypointCount || (filledWaypointCount < 2 && parsedWaypointIndex >= waypointCount)); const goBack = () => { - if (backTo) { - Navigation.goBack(backTo); - return; - } - Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); + Navigation.goBack(backPath); }; const validate = (values: FormOnyxValues<'waypointForm'>): Partial> => { @@ -217,7 +215,7 @@ function IOURequestStepWaypoint({ includeSafeAreaPaddingBottom onEntryTransitionEnd={() => textInput.current?.focus()} shouldEnableMaxHeight - testID="IOURequestStepWaypoint" + testID="DynamicIOURequestStepWaypoint" > Navigation.dismissModal()} - shouldBlockParticipantSelection={blockManualOrOdometerDistanceRequestIfNeeded} + shouldBlockParticipantSelection={blockDistanceRequestIfNeeded} /> )} diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index bd1e86c50697..96f7cb8ee5e6 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -2,6 +2,7 @@ import DistanceRequestRenderItem from '@components/DistanceRequest/DistanceReque import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useFetchRoute from '@hooks/useFetchRoute'; @@ -10,8 +11,8 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrevious from '@hooks/usePrevious'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -23,13 +24,14 @@ import {init, stop} from '@libs/actions/MapboxToken'; import {openDraftDistanceExpense, removeWaypoint, updateWaypoints as updateWaypointsUtil} from '@libs/actions/Transaction'; import {getLatestErrorField} from '@libs/ErrorUtils'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; import {doesMoneyRequestDraftHaveUserInput, getRateID, getRequestType} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; @@ -75,7 +77,8 @@ function IOURequestStepDistanceMap({ const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const isArchived = useReportIsArchived(report?.reportID); const selfDMReport = useSelfDMReport(); - const policy = usePolicy(report?.policyID); + const {policy} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType}); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({policyID: policy?.id, isDistanceRequest: true}); const personalPolicy = usePersonalPolicy(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -193,14 +196,9 @@ function IOURequestStepDistanceMap({ * Takes the user to the page for editing a specific waypoint * @param index of the waypoint to edit */ - const navigateToWaypointEditPage = useCallback( - (index: number) => { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, report?.reportID ?? reportID, index.toString(), Navigation.getActiveRoute()), - ); - }, - [action, transactionID, report?.reportID, reportID], - ); + const navigateToWaypointEditPage = useCallback((index: number) => { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(index))); + }, []); const navigateToNextStep = useDistanceNavigation({ iouType, @@ -290,6 +288,9 @@ function IOURequestStepDistanceMap({ ); const submitWaypoints = useCallback(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } // If there is any error or loading state, don't let user go to next page. if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || isLoading) { setShouldShowAtLeastTwoDifferentWaypointsError(true); @@ -297,7 +298,7 @@ function IOURequestStepDistanceMap({ } suppressDiscardPrompt(); navigateToNextStep(); - }, [duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, isLoadingRoute, isLoading, suppressDiscardPrompt, navigateToNextStep]); + }, [blockDistanceRequestIfNeeded, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, isLoadingRoute, isLoading, suppressDiscardPrompt, navigateToNextStep]); const renderItem = useCallback( ({item, drag, isActive, getIndex}: RenderItemParams) => { diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx index 8672b8bee7de..5db57e42467b 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx @@ -1,3 +1,4 @@ +import FeatureTraining from '@components/FeatureTraining'; import FeatureTrainingModal from '@components/FeatureTrainingModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -25,17 +26,22 @@ function MultiScanEducationalModal() { return ( + > + + + + {translate('iou.scanMultipleReceipts')} + {translate('iou.scanMultipleReceiptsDescription')} + + {translate('common.buttonConfirm')} + + ); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 915c849995b8..055e0e1ef9c2 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -1,21 +1,32 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; + import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; import withWritableReportOrNotFound from '@pages/iou/request/step/withWritableReportOrNotFound'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + import React from 'react'; import type IOURequestStepScanProps from './types'; import ScanRouter from './ScanRouter'; -function IOURequestStepScan({ +function DynamicIOURequestStepScan({ report, route: { - params: {action, iouType, reportID, transactionID, backTo, backToReport}, + params: {action, iouType, reportID, transactionID, backToReport}, + name, }, transaction, }: Omit) { + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SCAN.path); + // The page is also mounted as a tab of the static create screen, where there is no dynamic suffix to strip and + // nothing to go back to within the flow. `ScanRouter` uses the presence of `backTo` to pick the receipt-edit variant. + const backTo = name === SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN ? backPath : undefined; + return ( & { + WithWritableReportOrNotFoundProps & { /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ transaction: OnyxEntry; }; @@ -24,7 +24,7 @@ type ReceiptFile = { transactionID: string; }; -type ScanRoute = PlatformStackRouteProp; +type ScanRoute = PlatformStackRouteProp; export default IOURequestStepScanProps; export type {ReceiptFile, ScanRoute}; diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx index d460236f024a..78d22b71ec59 100644 --- a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx +++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx @@ -1,6 +1,7 @@ import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; @@ -8,7 +9,7 @@ import {removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import type {IOUAction, IOURequestType, IOUType} from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Report, Transaction} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Receipt} from '@src/types/onyx/Transaction'; @@ -128,7 +129,7 @@ function ReceiptFileValidator({ return; } if (requestType === CONST.IOU.REQUEST_TYPE.MANUAL) { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, initialTransactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, initialTransactionID, reportID))); return; } removeDraftTransactionsByIDs(draftTransactionIDs, true); diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 8faf643fb855..bbc777a7ba89 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -1,4 +1,5 @@ import useActivePolicy from '@hooks/useActivePolicy'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; @@ -330,6 +331,13 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const transactionIDs = transactions?.map((tx) => tx.transactionID); const [storedTransactions] = useTransactionsByID(transactionIDs); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest, + isManualDistanceRequest, + isOdometerDistanceRequest, + }); + function performPostBatchCleanup({ participant, shouldHandleNavigation, @@ -929,6 +937,10 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } function createTransaction(locationPermissionGranted = false, shouldHandleNavigation = true) { + if (blockDistanceRequestIfNeeded()) { + return; + } + setIsConfirmed(true); const trimmedComment = transaction?.comment?.comment?.trim() ?? ''; diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index 2130b19c330f..39e6c6235211 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -36,7 +36,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.CREATE | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DISTANCE | typeof SCREENS.MONEY_REQUEST.STEP_AMOUNT - | typeof SCREENS.MONEY_REQUEST.STEP_WAYPOINT + | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_WAYPOINT | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DESCRIPTION | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DATE | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_TAX_AMOUNT @@ -48,7 +48,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_TAX_RATE | typeof SCREENS.MONEY_REQUEST.STEP_VENDOR - | typeof SCREENS.MONEY_REQUEST.STEP_SCAN + | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SEND_FROM | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_REPORT | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_COMPANY_INFO diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index 86c28246d5f0..e5c3f15d96cd 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -22,6 +22,7 @@ import type {Report} from '@src/types/onyx'; import type {ComponentType} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useEffect} from 'react'; type WithWritableReportOrNotFoundOnyxProps = { @@ -33,7 +34,7 @@ type WithWritableReportOrNotFoundOnyxProps = { }; type MoneyRequestRouteName = - | typeof SCREENS.MONEY_REQUEST.STEP_WAYPOINT + | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_WAYPOINT | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DESCRIPTION | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DATE | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY @@ -49,7 +50,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_PARTICIPANTS | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_MERCHANT | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_TAX_AMOUNT - | typeof SCREENS.MONEY_REQUEST.STEP_SCAN + | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SEND_FROM | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_REPORT | typeof SCREENS.MONEY_REQUEST.DYNAMIC_STEP_COMPANY_INFO @@ -98,6 +99,7 @@ function WithWritableReportOrNotFoundImpl Navigation.goBack( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( - CONST.IOU.ACTION.CREATE, - iouType, - transactionID, - reportID, + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), + // The confirmation page is the explicit base so the scan step still goes back to it once the receipt is retaken. ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID), ), ), @@ -576,12 +586,14 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const getDestinationRoute = () => { return isOdometerImage ? ROUTES.ODOMETER_IMAGE.getRoute(action ?? CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, imageType, isEditingConfirmation, backToReport) - : ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( - action ?? CONST.IOU.ACTION.EDIT, - iouType, - draftTransactionID ?? transaction?.transactionID, - report?.reportID, - Navigation.getActiveRoute(), + : // `getDestinationRoute` runs after the modal is dismissed, so the base is the screen underneath it. + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( + action ?? CONST.IOU.ACTION.EDIT, + iouType, + draftTransactionID ?? transaction?.transactionID, + report?.reportID, + ), ); }; diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx index b130c5f62cf2..cbe046be65bc 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx @@ -24,6 +24,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {View} from 'react-native'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import AddAttachmentModalCarouselView from './AddAttachmentModalCarouselView'; @@ -55,6 +56,7 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); @@ -73,8 +75,28 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr }, [reportActions, reportActionID]); const fetchReport = useCallback(() => { - openReport({reportID, introSelected, conciergeChat, reportActionID, betas, hasReportActions, currentUserAccountID}); - }, [reportID, introSelected, conciergeChat, reportActionID, betas, hasReportActions, currentUserAccountID]); + openReport({ + reportID, + introSelected, + conciergeChat, + reportActionID, + betas, + hasReportActions, + currentUserAccountID, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + }); + }, [ + reportID, + introSelected, + conciergeChat, + reportActionID, + betas, + hasReportActions, + currentUserAccountID, + guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + ]); // Close the modal if user loses write access (e.g., admin switches "Who can post" to Admins only) useEffect(() => { diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx index eb32acb1d14c..866c7d1433ca 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx @@ -25,6 +25,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {View} from 'react-native'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {SafeString} from 'expensify-common'; import React, {useEffect, useRef} from 'react'; @@ -51,6 +52,7 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const originalReportID = useOriginalReportID(reportID, reportActionID ? (reportActions?.[reportActionID ?? CONST.DEFAULT_NUMBER_ID] ?? {reportActionID}) : undefined); @@ -79,8 +81,29 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen return; } - openReport({reportID: reportActionReportID, introSelected, conciergeChat, reportActionID, betas, hasReportActions, currentUserAccountID}); - }, [reportActionReportID, shouldFetchReport, introSelected, conciergeChat, reportActionID, betas, hasReportActions, currentUserAccountID]); + openReport({ + reportID: reportActionReportID, + introSelected, + conciergeChat, + reportActionID, + betas, + hasReportActions, + currentUserAccountID, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + }); + }, [ + reportActionReportID, + shouldFetchReport, + introSelected, + conciergeChat, + reportActionID, + betas, + hasReportActions, + currentUserAccountID, + guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + ]); const onCarouselAttachmentChange = (attachment: Attachment) => { const routeToNavigate = ROUTES.REPORT_ATTACHMENTS.getRoute({ diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/AddAgentPage.tsx index 16a8d2faad0d..ffb869f343f4 100644 --- a/src/pages/settings/Agents/AddAgentPage.tsx +++ b/src/pages/settings/Agents/AddAgentPage.tsx @@ -143,7 +143,7 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { if (getIsNarrowLayout()) { // Reveal the DM under the modal before dismissing so we navigate directly to it in one animation, // instead of dismissing to the agents list first and navigating to the DM afterward. - Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID)); + Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID, undefined, undefined, ROUTES.SETTINGS_AGENTS)); return; } diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx index ab8892f24a7f..d2f05d1ecebf 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx @@ -128,7 +128,7 @@ function StatusClearAfterPage() { }, []); const customStatusDate = DateUtils.extractDate(statusDraftCustomClearAfterDate ?? ''); - const customStatusTime = DateUtils.extractTime12Hour(statusDraftCustomClearAfterDate ?? ''); + const customStatusTime = DateUtils.getTime12HourWithTranslatedPeriod(translate, statusDraftCustomClearAfterDate ?? ''); const listFooterContent = useMemo(() => { if (draftPeriod !== CONST.CUSTOM_STATUS_TYPES.CUSTOM) { diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index ea693ae0afa4..46f5a38280e2 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -33,6 +33,8 @@ import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; import {getFormattedAddress, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {expensifyLoginsSelector, getContactMethodsOptions, getLoginListBrickRoadIndicator} from '@libs/UserUtils'; +import useTimeSensitiveHomeAddress from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress'; + import {clearAgentAvatarUpdateError} from '@userActions/Agent'; import CONST from '@src/CONST'; @@ -49,6 +51,7 @@ import type {ScrollView as RNScrollView} from 'react-native'; import type {ValueOf} from 'type-fest'; import {useRoute} from '@react-navigation/native'; +import {homeAndOfficeCommuterExclusionPolicyNameSelector} from '@selectors/Policy'; import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; @@ -86,6 +89,8 @@ function ProfilePage() { const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; const privateDetails = privatePersonalDetails ?? {}; const legalName = `${privateDetails.legalFirstName ?? ''} ${privateDetails.legalLastName ?? ''}`.trim(); + const {shouldShowAddHomeAddress: shouldShowAddHomeAddressGBR} = useTimeSensitiveHomeAddress(); + const [commuterExclusionsWorkspaceName] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: homeAndOfficeCommuterExclusionPolicyNameSelector}); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); const {isActingAsDelegate} = useDelegateNoAccessState(); @@ -152,7 +157,15 @@ function ProfilePage() { Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(fieldToFocus)); }; - const privateOptions = [ + const privateOptions: Array<{ + description: string; + title: string; + testID: string; + sentryLabel: string; + action: () => void; + brickRoadIndicator?: ValueOf; + furtherDetails?: string; + }> = [ { description: translate('privatePersonalDetails.legalName'), title: legalName, @@ -181,6 +194,8 @@ function ProfilePage() { testID: 'address-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.ADDRESS, action: () => navigateToPrivateDetails(INPUT_IDS.ADDRESS_LINE_1), + brickRoadIndicator: shouldShowAddHomeAddressGBR ? CONST.BRICK_ROAD_INDICATOR_STATUS.INFO : undefined, + furtherDetails: commuterExclusionsWorkspaceName ? translate('privatePersonalDetails.commuterExclusionsHint', {workspaceName: commuterExclusionsWorkspaceName}) : undefined, }, ]; @@ -301,6 +316,7 @@ function ProfilePage() { wrapperStyle={styles.sectionMenuItemTopDescription} onPress={detail.action} brickRoadIndicator={detail.brickRoadIndicator} + furtherDetails={detail.furtherDetails} pressableTestID={detail?.testID} sentryLabel={detail.sentryLabel} /> diff --git a/src/pages/settings/Security/CloseAccountPage.tsx b/src/pages/settings/Security/CloseAccountPage.tsx index bd3f3be2ab24..34021a827b28 100644 --- a/src/pages/settings/Security/CloseAccountPage.tsx +++ b/src/pages/settings/Security/CloseAccountPage.tsx @@ -1,3 +1,4 @@ +import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -36,7 +37,7 @@ function CloseAccountPage() { const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - // The account being closed can be a workspace's RuleBot agent (e.g. when accessed via copilot). Closing it would leave the workspace's Agent rules without an enforcer, so it stays open until those rules are removed. + // The account being closed can be a workspace's RuleBot agent. Closing it would leave the workspace's Agent rules without an enforcer, so it stays open until those rules are removed. const ruleBotEnforcedPolicy = getRuleBotEnforcedPolicy(session?.accountID, policies); const styles = useThemeStyles(); @@ -110,55 +111,57 @@ function CloseAccountPage() { includeSafeAreaPaddingBottom testID="CloseAccountPage" > - Navigation.goBack()} - /> - - + Navigation.goBack()} + /> + - {translate('closeAccountPage.reasonForLeavingPrompt')} - - - {translate('closeAccountPage.enterDefaultContactToConfirm')} {userEmailOrPhone} - - - - + + {translate('closeAccountPage.reasonForLeavingPrompt')} + + + {translate('closeAccountPage.enterDefaultContactToConfirm')} {userEmailOrPhone} + + + + + ); } diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index 263d19b6619d..e2a0febbfb5b 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -183,6 +183,12 @@ function SecuritySettingsPage() { icon: icons.ClosedSign, sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.CLOSE_ACCOUNT, action: async () => { + // Copiloting into an agent is also a delegate session and must fall through to the + // agent-delete flow below, so only plain delegates are blocked here. + if (isActingAsDelegate && !isCopilotingIntoAgent) { + showDelegateNoAccessModal(); + return; + } if (isAccountLocked) { showLockedAccountModal(); return; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index d7fa40ea1bb9..6c2b0394760a 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -5,10 +5,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useLocalize from '@hooks/useLocalize'; import useRootNavigationState from '@hooks/useRootNavigationState'; +import useShouldCollectInternationalDepositDetails from '@hooks/useShouldCollectInternationalDepositDetails'; import useSubPage from '@hooks/useSubPage'; import {clearCorpayBankAccountFields} from '@libs/actions/BankAccounts'; import {clearDraftValues} from '@libs/actions/FormActions'; +import {hasValidAccountDetailsInternationalFields} from '@libs/BankAccountUtils'; import getActiveTabName from '@libs/Navigation/helpers/getActiveTabName'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation from '@libs/Navigation/Navigation'; @@ -38,6 +40,7 @@ import BankAccountDetails from './subPages/BankAccountDetails'; import BankInformation from './subPages/BankInformation'; import Confirmation from './subPages/Confirmation'; import CountrySelection from './subPages/CountrySelection'; +import InternationalBankAccountDetails from './subPages/InternationalBankAccountDetails'; import Success from './subPages/Success'; import {getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, testValidation} from './utils'; @@ -54,6 +57,7 @@ type InternationalDepositAccountContentProps = { const pages = [ {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.COUNTRY, component: CountrySelection}, {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS, component: BankAccountDetails}, + {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS, component: InternationalBankAccountDetails}, {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE, component: AccountType}, {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.BANK_INFORMATION, component: BankInformation}, {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS, component: AccountHolderInformation}, @@ -61,17 +65,6 @@ const pages = [ {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.SUCCESS, component: Success}, ]; -function getSkippedPages(skipAccountTypeStep: boolean, skipAccountHolderInformationStep: boolean) { - const skippedSteps = []; - if (skipAccountTypeStep) { - skippedSteps.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE); - } - if (skipAccountHolderInformationStep) { - skippedSteps.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS); - } - return skippedSteps; -} - function InternationalDepositAccountContent({ privatePersonalDetails, corpayFields, @@ -86,16 +79,31 @@ function InternationalDepositAccountContent({ const fieldsMap = getFieldsMap(corpayFields); const values = getSubstepValues(privatePersonalDetails, corpayFields, bankAccountList, draftValues, country, fieldsMap); + const bankCountry = values.bankCountry; + const shouldCollectInternationalDepositDetails = useShouldCollectInternationalDepositDetails(bankCountry); const initialAccountHolderDetailsValues = getInitialPersonalDetailsValues(privatePersonalDetails); - const startFrom = getInitialSubstep(values, fieldsMap); - const skipAccountTypeStep = isEmptyObject(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE]); const skipAccountHolderInformationStep = testValidation(initialAccountHolderDetailsValues, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS]); - const skippedPages = getSkippedPages(skipAccountTypeStep, skipAccountHolderInformationStep); + // Skip only when collection isn't needed, or when the account details step already captured a real IBAN and a + // SWIFT value. Completing `iban`/`swiftCode` on the dedicated step must not omit it from the wizard. + const skipInternationalBankAccountDetailsStep = !shouldCollectInternationalDepositDetails || hasValidAccountDetailsInternationalFields(values.accountNumber, values.swiftBicCode); + + const startFrom = getInitialSubstep(values, fieldsMap, skipInternationalBankAccountDetailsStep); + + const skippedPages = []; + if (skipInternationalBankAccountDetailsStep) { + skippedPages.push(CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + } + if (skipAccountTypeStep) { + skippedPages.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE); + } + if (skipAccountHolderInformationStep) { + skippedPages.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS); + } const route = useRoute>(); const topmostFullScreenRoute = useRootNavigationState((state) => state?.routes.findLast((r) => isFullScreenName(r.name))); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx index c07973c1e959..340731189386 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx @@ -1,5 +1,6 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useLoadDepositAccountSetup from '@hooks/useLoadDepositAccountSetup'; import useOnyx from '@hooks/useOnyx'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -29,7 +30,13 @@ function InternationalDepositAccount({route}: InternationalDepositAccountProps) const [isAccountLoading, isLoadingMetadata] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {selector: isLoadingPersonalBankAccountSelector}); const backTo = route.params?.backTo; - const isLoading = isLoadingOnyxValue(privatePersonalDetailsMetadata, corpayFieldsMetadata, bankAccountListMetadata, draftValuesMetadata, countryMetadata, isLoadingMetadata); + // The reimbursement countries drive which steps the flow shows and where it starts, so gate the content until + // they load to keep the substep hooks from initializing with stale/empty data. + const isLoadingDepositAccountSetup = useLoadDepositAccountSetup(); + + const isLoading = + isLoadingDepositAccountSetup || + isLoadingOnyxValue(privatePersonalDetailsMetadata, corpayFieldsMetadata, bankAccountListMetadata, draftValuesMetadata, countryMetadata, isLoadingMetadata); if (isLoading) { return ; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/BankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/BankAccountDetails.tsx index 70c2110891a0..8a9af75a0844 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/BankAccountDetails.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/BankAccountDetails.tsx @@ -11,13 +11,14 @@ import useInternationalBankAccountFormSubmit from '@hooks/useInternationalBankAc import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useShouldCollectInternationalDepositDetails from '@hooks/useShouldCollectInternationalDepositDetails'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getTextInputAutocorrectProps from '@libs/getTextInputAutocorrectProps'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; -import {getValidationErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; +import {getAccountDetailsFieldsMap, getValidationErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; import {fetchCorpayFields} from '@userActions/BankAccounts'; @@ -33,9 +34,11 @@ function BankAccountDetails({isEditing, onNext, onMove, formValues, fieldsMap}: const styles = useThemeStyles(); const theme = useTheme(); const {isOffline} = useNetwork(); + const shouldCollectInternationalDepositDetails = useShouldCollectInternationalDepositDetails(formValues.bankCountry); + const accountDetailsFields = getAccountDetailsFieldsMap(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS], shouldCollectInternationalDepositDetails); const handleSubmit = useInternationalBankAccountFormSubmit({ - fieldIds: Object.keys(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS] ?? {}), + fieldIds: Object.keys(accountDetailsFields), onNext, shouldSaveDraft: true, }); @@ -53,9 +56,9 @@ function BankAccountDetails({isEditing, onNext, onMove, formValues, fieldsMap}: const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { - return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS], translate); + return getValidationErrors(values, accountDetailsFields, translate); }, - [fieldsMap, translate], + [accountDetailsFields, translate], ); const icons = useMemoizedLazyExpensifyIcons(['QuestionMark']); @@ -88,7 +91,7 @@ function BankAccountDetails({isEditing, onNext, onMove, formValues, fieldsMap}: shouldShowFullPageOfflineView /> - {Object.values(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS] ?? {}).map((field) => { + {Object.values(accountDetailsFields).map((field) => { const isValuePicker = (field.valueSet ?? []).length > 0; return ( { if ((field.valueSet ?? []).length > 0) { @@ -113,7 +118,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp }, ]; - for (const [fieldName, field] of Object.entries(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS] ?? {})) { + for (const [fieldName, field] of Object.entries(accountDetailsFields)) { summaryItems.push({ id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), @@ -125,6 +130,30 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp }); } + if (shouldShowInternationalDetailOnConfirmation(formValues.iban, formValues.accountNumber)) { + summaryItems.push({ + id: `${CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS}-iban`, + description: translate('bankAccount.iban'), + title: formValues.iban, + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + }, + }); + } + + if (shouldShowInternationalDetailOnConfirmation(formValues.swiftCode, formValues.swiftBicCode)) { + summaryItems.push({ + id: `${CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS}-swiftCode`, + description: translate('bankAccount.swiftBicCode'), + title: formValues.swiftCode, + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + }, + }); + } + for (const [fieldName, field] of Object.entries(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE] ?? {})) { summaryItems.push({ id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE}-${fieldName}`, diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx new file mode 100644 index 000000000000..0dca9937f7e7 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx @@ -0,0 +1,58 @@ +/** + * Sub-page for collecting international bank account details (IBAN + SWIFT/BIC) in the Corpay deposit-account flow. + */ +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import InternationalBankAccountDetailsForm, {IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID} from '@components/SubStepForms/InternationalBankAccountDetailsStep'; + +import useInternationalBankAccountFormSubmit from '@hooks/useInternationalBankAccountFormSubmit'; +import useLocalize from '@hooks/useLocalize'; + +import {getDisabledInternationalBankAccountFields, getInternationalBankAccountDetailsErrors, getInternationalBankAccountDetailsValues} from '@libs/BankAccountUtils'; +import {getFieldRequiredErrors} from '@libs/ValidationUtils'; + +import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; + +const STEP_FIELDS = [IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID]; + +function InternationalBankAccountDetails({isEditing, onNext, formValues}: CustomSubPageProps) { + const {translate} = useLocalize(); + + const handleSubmit = useInternationalBankAccountFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: true, + }); + + // Pre-fill from what the account details step already captured, until the user edits this step directly. + const {iban: ibanDefaultValue, swiftCode: swiftCodeDefaultValue} = getInternationalBankAccountDetailsValues( + formValues[IBAN_INPUT_ID], + formValues[SWIFT_CODE_INPUT_ID], + formValues.accountNumber, + formValues.swiftBicCode, + ); + const {isIBANDisabled, isSwiftCodeDisabled} = getDisabledInternationalBankAccountFields(formValues.accountNumber, formValues.swiftBicCode); + + const validate = (values: FormOnyxValues): FormInputErrors => ({ + ...getFieldRequiredErrors(values, STEP_FIELDS, translate), + ...getInternationalBankAccountDetailsErrors(values[IBAN_INPUT_ID], values[SWIFT_CODE_INPUT_ID], translate, isSwiftCodeDisabled), + }); + + return ( + + ); +} + +export default InternationalBankAccountDetails; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts index 79b7dd4a8ff8..800ec4aafd0e 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts +++ b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts @@ -1,6 +1,7 @@ import type {FormOnyxValues} from '@components/Form/types'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {hasValidInternationalBankAccountDetails} from '@libs/BankAccountUtils'; import {addErrorMessage} from '@libs/ErrorUtils'; import {getCurrentAddress} from '@libs/PersonalDetailsUtils'; @@ -114,13 +115,22 @@ function testValidation(values: InternationalBankAccountForm, fieldsMap: CorpayF return true; } -function getInitialSubstep(values: InternationalBankAccountForm, fieldsMap: Record, CorpayFieldsMap>) { +function getInitialSubstep( + values: InternationalBankAccountForm, + fieldsMap: Record, CorpayFieldsMap>, + skipInternationalBankAccountDetailsStep: boolean, +) { if (values.bankCountry === '' || isEmptyObject(fieldsMap)) { return CONST.CORPAY_FIELDS.INDEXES.MAPPING.COUNTRY_SELECTOR; } if (values.bankCurrency === '' || !testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS])) { return CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS; } + // When the international bank account details step is required but not yet satisfied, start there so a resumed or + // deep-linked flow can't land past it and submit without the IBAN/SWIFT. + if (!skipInternationalBankAccountDetailsStep && !hasValidInternationalBankAccountDetails(values.iban, values.swiftCode, values.accountNumber, values.swiftBicCode)) { + return CONST.CORPAY_FIELDS.INDEXES.MAPPING.INTERNATIONAL_BANK_ACCOUNT_DETAILS; + } if (!testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE])) { return CONST.CORPAY_FIELDS.INDEXES.MAPPING.ACCOUNT_TYPE; } @@ -150,4 +160,28 @@ function getValidationErrors(values: FormOnyxValues normalizedLabel.includes(keyword)); +} + +function getAccountDetailsFieldsMap(accountDetailsFields: CorpayFieldsMap | undefined, shouldCollectInternationalDepositDetails: boolean): CorpayFieldsMap { + const fields = accountDetailsFields ?? {}; + if (!shouldCollectInternationalDepositDetails) { + return fields; + } + + // When we need to collect international bank account details, we'll check the initial set of bank account fields and mark the international fields as required. This minimizes the need to ask for more details in the next page. + const nextFields: CorpayFieldsMap = {}; + for (const [fieldName, field] of Object.entries(fields)) { + nextFields[fieldName] = isInternationalBankAccountFieldLabel(field.label) + ? { + ...field, + isRequired: true, + } + : field; + } + return nextFields; +} + +export {getFieldsMap, getSubstepValues, getInitialPersonalDetailsValues, getInitialSubstep, testValidation, getValidationErrors, getAccountDetailsFieldsMap}; diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 429868c71276..6535eaabef22 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -246,12 +246,15 @@ function PaymentMethodList({ status: BankAccountConnectionStatus, onActionPress: (e: GestureResponderEvent | KeyboardEvent | undefined) => void, onUnlockPress?: (e: GestureResponderEvent | KeyboardEvent | undefined) => void, + isPendingDelete = false, ): PaymentMethodItem['connectionStatus'] => ({ statusText: translate(status.labelKey), statusTone: status.tone, tooltipText: status.tooltipKey ? translate(status.tooltipKey) : undefined, message: status.messageKey ? translate(status.messageKey) : undefined, actionText: status.actionKey ? translate(status.actionKey) : undefined, + // An account queued for deletion is struck through, so its action is disabled rather than hidden. + isActionDisabled: isPendingDelete, onActionPress: () => { if (status.requiresUnlockHandler) { (onUnlockPress ?? onActionPress)(undefined); @@ -625,7 +628,14 @@ function PaymentMethodList({ canDismissError: true, isMissingPersonalInfo, brickRoadIndicator: shouldShowConnectionStatus ? (bankConnectionStatus?.brickRoadIndicator ?? existingBrickRoadIndicator) : existingBrickRoadIndicator, - connectionStatus: bankConnectionStatus ? mapBankStatusToRowStatus(bankConnectionStatus, paymentMethodPress, paymentMethodThreeDotsPress) : undefined, + connectionStatus: bankConnectionStatus + ? mapBankStatusToRowStatus( + bankConnectionStatus, + paymentMethodPress, + paymentMethodThreeDotsPress, + paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ) + : undefined, }; }); return combinedPaymentMethods; diff --git a/src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/index.native.tsx b/src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/index.native.tsx index 563235c1d27a..85686a59dc9f 100644 --- a/src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/index.native.tsx +++ b/src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/index.native.tsx @@ -16,6 +16,7 @@ import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import getPlaidInstitutionID from '@libs/PlaidUtils'; import {handleRestrictedEvent} from '@userActions/App'; import {setPlaidEvent} from '@userActions/BankAccounts'; @@ -88,8 +89,8 @@ function FixPersonalCardConnectionPage({route}: FixPersonalCardConnectionPagePro const handlePlaidLinkSuccess = ({publicToken, metadata}: {publicToken: string; metadata: PlaidLinkOnSuccessMetadata | LinkSuccessMetadata}) => { Log.info('[PlaidLink] Fix personal card success'); - const plaidConnectedFeed = (metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.institution_id ?? (metadata?.institution as LinkSuccessMetadata['institution'])?.id; - const plaidAccounts = metadata?.accounts; + const plaidConnectedFeed = getPlaidInstitutionID(metadata.institution); + const plaidAccounts = metadata.accounts; if (!plaidConnectedFeed || !plaidAccounts?.length) { return; } diff --git a/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx b/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx index fdefea8b7b02..5bd502e5e958 100644 --- a/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx +++ b/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx @@ -14,6 +14,7 @@ import {setAddNewPersonalCardStepAndData} from '@libs/actions/PersonalCards'; import getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import Log from '@libs/Log'; +import getPlaidInstitutionID from '@libs/PlaidUtils'; import Navigation from '@navigation/Navigation'; @@ -154,8 +155,9 @@ function PlaidConnectionStep({feed, onExit}: {feed?: CompanyCardFeedWithDomainID // on success we need to move to bank connection screen with token, bank name = plaid Log.info('[PlaidLink] Success!'); - const plaidConnectedFeed = (metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.institution_id ?? (metadata?.institution as LinkSuccessMetadata['institution'])?.id; - const plaidConnectedFeedName = (metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.name ?? (metadata?.institution as LinkSuccessMetadata['institution'])?.name; + const institution = metadata.institution; + const plaidConnectedFeed = getPlaidInstitutionID(institution); + const plaidConnectedFeedName = institution?.name; setAddNewPersonalCardStepAndData({ step: CONST.PERSONAL_CARDS.STEP.BANK_CONNECTION, @@ -163,7 +165,7 @@ function PlaidConnectionStep({feed, onExit}: {feed?: CompanyCardFeedWithDomainID publicToken, plaidConnectedFeed, plaidConnectedFeedName, - plaidAccounts: metadata?.accounts, + plaidAccounts: metadata.accounts, }, }); }; diff --git a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx index bd666348d3dc..2fc9ff740d98 100644 --- a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx +++ b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx @@ -8,6 +8,7 @@ import TextInput from '@components/TextInput'; import useAncestors from '@hooks/useAncestors'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -38,6 +39,7 @@ function DynamicNewTaskDetailsPage() { const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${task?.parentReportID}`); const ancestors = useAncestors(parentReport); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); const [taskCreatorAndAssigneeDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { selector: personalDetailsListSelector([currentUserPersonalDetails.accountID, task?.assigneeAccountID]), }); @@ -89,6 +91,7 @@ function DynamicNewTaskDetailsPage() { currentUserEmail: currentUserPersonalDetails.email ?? '', currentUserDisplayName: currentUserPersonalDetails.displayName, currentUserAvatar: currentUserPersonalDetails.avatar, + delegateAccountID, assigneeAccountID: task.assigneeAccountID, assigneeChatReport: task.assigneeChatReport, policyID: CONST.POLICY.OWNER_EMAIL_FAKE, diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 14595d4b61c6..e03153c182cc 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -9,6 +9,7 @@ import ScrollView from '@components/ScrollView'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -44,6 +45,7 @@ function DynamicNewTaskPage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const reportAttributes = useReportAttributes(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); const [taskCreatorAndAssigneeDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { selector: personalDetailsListSelector([currentUserPersonalDetails.accountID, task?.assigneeAccountID]), }); @@ -117,6 +119,7 @@ function DynamicNewTaskPage() { currentUserEmail: currentUserPersonalDetails.email ?? '', currentUserDisplayName: currentUserPersonalDetails.displayName, currentUserAvatar: currentUserPersonalDetails.avatar, + delegateAccountID, assigneeAccountID: task.assigneeAccountID, assigneeChatReport: task.assigneeChatReport, policyID: parentReport?.policyID, diff --git a/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx b/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx index 6689291f1d62..6344ff7fc961 100644 --- a/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx +++ b/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx @@ -7,6 +7,7 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import type {ListItem} from '@components/SelectionList/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useHasOutstandingChildTask from '@hooks/useHasOutstandingChildTask'; import useLocalize from '@hooks/useLocalize'; @@ -54,6 +55,7 @@ function DynamicTaskAssigneeSelectorModal() { const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, { selector: delegateEmailSelector, @@ -173,6 +175,7 @@ function DynamicTaskAssigneeSelectorModal() { currentUserAccountID: currentUserPersonalDetails.accountID, hasOutstandingChildTask, delegateEmail, + delegateAccountID, assigneeAccountID: option?.accountID, assigneeChatReport, isOptimisticReport, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 0d6eb9fbb866..a7b74adfa728 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -607,7 +607,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(memberOption); } - if (hasAtLeastOneNonAdminRole && !hasAtLeastOnePayer && canAssignElevatedRoles) { + // Admin is a valid payer role, so the payer may be promoted to Admin (Admin and Payments Admin are the two roles that can pay). + if (hasAtLeastOneNonAdminRole && canAssignElevatedRoles) { options.push(adminOption); } @@ -629,7 +630,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(peopleAdminOption); } - if (hasAtLeastOneNonPaymentsAdminRole && isControlPolicy(policy) && !hasAtLeastOnePayer && canAssignElevatedRoles) { + // Payments Admin is a valid payer role, so the payer may be changed to Payments Admin (Admin and Payments Admin are the two roles that can pay). + if (hasAtLeastOneNonPaymentsAdminRole && isControlPolicy(policy) && canAssignElevatedRoles) { options.push(paymentsAdminOption); } diff --git a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx index 1e09e37bbd45..7d6dc46e7f40 100644 --- a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx @@ -12,6 +12,7 @@ import AddressPage from '@pages/AddressPage'; import {updateAddress} from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -60,6 +61,8 @@ function WorkspaceOverviewAddressPage({policy}: WorkspaceOverviewAddressPageProp Navigation.goBack(backPath); }; + const requiresFullAddressForExclusions = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + return ( ); } diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx index f2c2dccb7f5c..9415c5463f2f 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx @@ -39,15 +39,6 @@ import {isUserValidatedSelector} from '@selectors/Account'; import React, {useMemo} from 'react'; import {View} from 'react-native'; -const ADVANCED_CSV_COLUMNS = new Set([ - CONST.CSV_IMPORT_COLUMNS.ORIGINAL_TRANSACTION_DATE, - CONST.CSV_IMPORT_COLUMNS.ORIGINAL_AMOUNT, - CONST.CSV_IMPORT_COLUMNS.ORIGINAL_CURRENCY, - CONST.CSV_IMPORT_COLUMNS.COMMENT, - CONST.CSV_IMPORT_COLUMNS.CATEGORY, - CONST.CSV_IMPORT_COLUMNS.TAG, -]); - type WorkspaceCompanyCardsSettingsPageProps = PlatformStackScreenProps; function WorkspaceCompanyCardsSettingsPage({ @@ -81,8 +72,6 @@ function WorkspaceCompanyCardsSettingsPage({ const isPending = !!selectedFeedData?.pending; const isDirectFeedType = isDirectFeed(feed); const isCsvFeed = isCSVUploadFeed(feed); - const storedMappings = selectedFeedData?.uploadLayoutSettings?.columnMappings; - const hadAdvancedFields = !!storedMappings && Object.keys(storedMappings).some((col) => ADVANCED_CSV_COLUMNS.has(col)); const statementCloseDate = useMemo(() => { if (!selectedFeedData?.statementPeriodEndDay) { @@ -205,7 +194,6 @@ function WorkspaceCompanyCardsSettingsPage({ data: { layoutType: feed, companyCardLayoutName: selectedFeedData?.customFeedName ?? feedName ?? '', - useAdvancedFields: hadAdvancedFields, existingInstanceID: selectedFeedData?.uploadLayoutSettings?.instanceID ?? null, domainAccountID: domainOrWorkspaceAccountID, }, diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index 524f0a22069a..d5d29c71552c 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -1,6 +1,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {ColumnRole} from '@components/ImportColumn'; import ImportSpreadsheetColumns from '@components/ImportSpreadsheetColumns'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import useCloseImportPage from '@hooks/useCloseImportPage'; @@ -35,6 +36,28 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; type CompanyCardsImportedPageProps = PlatformStackScreenProps; +/** + * All company card CSV mapping fields are always available (the previous "advanced fields" toggle was removed), + * so every role below is offered in the column-mapping dropdown for every import. + */ +function getCompanyCardImportColumnRoles(translate: LocaleContextProps['translate']): ColumnRole[] { + return [ + {text: translate('workspace.companyCards.addNewCard.csvColumns.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.cardNumber'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.cardName'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NAME}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.postedDate'), value: CONST.CSV_IMPORT_COLUMNS.POSTED_DATE, isRequired: true}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.merchant'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT, isRequired: true}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.amount'), value: CONST.CSV_IMPORT_COLUMNS.AMOUNT, isRequired: true}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.currency'), value: CONST.CSV_IMPORT_COLUMNS.CURRENCY, isRequired: true}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.originalTransactionDate'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_TRANSACTION_DATE}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.originalAmount'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_AMOUNT}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.originalCurrency'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_CURRENCY}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.comment'), value: CONST.CSV_IMPORT_COLUMNS.COMMENT}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.category'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.tag'), value: CONST.CSV_IMPORT_COLUMNS.TAG}, + ]; +} + function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const {translate} = useLocalize(); const [spreadsheet, spreadsheetMetadata] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); @@ -48,7 +71,6 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const [isImportingTransactions, setIsImportingTransactions] = useState(false); const {setIsClosing} = useCloseImportPage(); const showImportSpreadsheetConfirmModal = useImportSpreadsheetConfirmModal(); - const shouldUseAdvancedFields = addNewCard?.data?.useAdvancedFields ?? false; const layoutName = addNewCard?.data?.companyCardLayoutName ?? ''; const prefilledLayoutType = addNewCard?.data?.layoutType; const generatedLayoutType = useMemo( @@ -59,44 +81,16 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); - const columnRoles: ColumnRole[] = (() => { - const baseRoles: ColumnRole[] = [ - {text: translate('workspace.companyCards.addNewCard.csvColumns.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.cardNumber'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.cardName'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NAME}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.postedDate'), value: CONST.CSV_IMPORT_COLUMNS.POSTED_DATE, isRequired: true}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.merchant'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT, isRequired: true}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.amount'), value: CONST.CSV_IMPORT_COLUMNS.AMOUNT, isRequired: true}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.currency'), value: CONST.CSV_IMPORT_COLUMNS.CURRENCY, isRequired: true}, - ]; - - if (!shouldUseAdvancedFields) { - return baseRoles; - } - - const advancedRoles: ColumnRole[] = [ - {text: translate('workspace.companyCards.addNewCard.csvColumns.originalTransactionDate'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_TRANSACTION_DATE}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.originalAmount'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_AMOUNT}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.originalCurrency'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_CURRENCY}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.comment'), value: CONST.CSV_IMPORT_COLUMNS.COMMENT}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.category'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.tag'), value: CONST.CSV_IMPORT_COLUMNS.TAG}, - ]; - - return [...baseRoles, ...advancedRoles]; - })(); + const columnRoles: ColumnRole[] = getCompanyCardImportColumnRoles(translate); const savedColumnMappings = Object.entries(workspaceCardFeeds?.settings?.companyCards ?? {}).find(([feedKey]) => feedKey === layoutType)?.[1]?.uploadLayoutSettings?.columnMappings; const hasAppliedSavedMappings = useRef(false); const lastProcessedDataRef = useRef(spreadsheet?.data); - const lastAdvancedFieldsRef = useRef(shouldUseAdvancedFields); useEffect(() => { - // Reset the flag when new spreadsheet data is loaded, or when the set of selectable roles changes. - if (spreadsheet?.data !== lastProcessedDataRef.current || shouldUseAdvancedFields !== lastAdvancedFieldsRef.current) { + if (spreadsheet?.data !== lastProcessedDataRef.current) { hasAppliedSavedMappings.current = false; lastProcessedDataRef.current = spreadsheet?.data; - lastAdvancedFieldsRef.current = shouldUseAdvancedFields; } if (hasAppliedSavedMappings.current) { @@ -113,7 +107,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { savedColumnMappings, columnRoles.map((role) => role.value), ); - }, [spreadsheet?.data, savedColumnMappings, columnRoles, shouldUseAdvancedFields]); + }, [spreadsheet?.data, savedColumnMappings, columnRoles]); const requiredColumns = columnRoles.filter((role) => role.isRequired); const {containsHeader = true} = spreadsheet ?? {}; @@ -264,3 +258,4 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { } export default CompanyCardsImportedPage; +export {getCompanyCardImportColumnRoles}; diff --git a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx index 98acb1f7284c..debea4119c4a 100644 --- a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx +++ b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx @@ -1,17 +1,19 @@ import Button from '@components/ButtonComposed'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import Switch from '@components/Switch'; import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import localFileDownload from '@libs/localFileDownload'; + import Navigation from '@navigation/Navigation'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -27,13 +29,28 @@ import {useRoute} from '@react-navigation/native'; import React, {useState} from 'react'; import {View} from 'react-native'; +// cspell:disable +// Example CSV shared with customers so they can see how to structure a company card import file. +// Sourced from the Expensify Classic "Manage Company Cards" help article. +const CSV_TEMPLATE_FILE_NAME = 'Expensify_company_card_import_template.csv'; +const CSV_TEMPLATE_CONTENT = [ + 'Card Number,Posted Date,Merchant,Posted Amount,Posted Currency,Sales Date,Original Amount,Original Currency,Comment,Category,Tag,Unique ID', + '902212XXXXXX1234,2019-01-26,ACE Hardware,13.41,EUR,2019-01-23,12,USD,Impromptu supply run,Other,Client Project 4,982000000000000', + '902212XXXXXX1234,2019-01-24,McDonalds,4.48,EUR,2019-01-23,5,USD,Lunch at worksite,Meals,Client Project 4,375000000000000', + '902212XXXXXX8999,2019-01-27,United Airlines,"1,200.00",USD,2019-01-24,"1,200.00",USD,Flight to client site,Travel,Client Project 8,135000000000000', + '902212XXXXXX8999,2019-01-28,United Airlines,"-1,200.00",USD,2019-01-25,"-1,200.00",USD,Flight refund,Travel,Client Project 8,735000000000000', + '902212XXXXXX8999,2019-01-28,Sheraton NYC,357,USD,2019-01-24,357,USD,Overnight stay due to flight cancellation,Lodging,Client Project 8,529000000000000', + '902212XXXXXX8999,2019-01-29,Shell Oil,54,USD,2019-01-25,100,USD,Gas,Fuel,Client Project 8,765400000000000', +].join('\n'); +// cspell:enable + function ImportFromFileStep() { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['Download']); const route = useRoute>(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); - const shouldUseAdvancedFields = addNewCard?.data?.useAdvancedFields ?? false; const companyCardLayoutName = addNewCard?.data?.companyCardLayoutName ?? ''; const [hasError, setHasError] = useState(false); const {policyID} = route.params; @@ -42,8 +59,8 @@ function ImportFromFileStep() { setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE}); }; - const toggleAdvancedFields = (value: boolean) => { - setAddNewCompanyCardStepAndData({data: {useAdvancedFields: value}}); + const downloadTemplate = () => { + localFileDownload(CSV_TEMPLATE_FILE_NAME, CSV_TEMPLATE_CONTENT, translate); }; const navigateToImport = () => { @@ -72,9 +89,13 @@ function ImportFromFileStep() { contentContainerStyle={styles.flexGrow1} addBottomSafeAreaPadding > - - - + + {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionStart')} + {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.templateLink')} + {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionMiddle')} + {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.helpGuideLink')} + {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionEnd')} + - - - {translate('workspace.companyCards.addNewCard.useAdvancedFields')} - - - - + +