diff --git a/.eslintrc.js b/.eslintrc.js index aa98b7bdc464..9a3a9998c836 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -150,8 +150,14 @@ module.exports = { { selector: ['variable', 'property'], format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + // This filter excludes variables and properties that start with "private_" to make them valid. + // + // Examples: + // - "private_a" → valid + // - "private_test" → valid + // - "private_" → not valid filter: { - regex: '^private_[a-z][a-zA-Z0-9]+$', + regex: '^private_[a-z][a-zA-Z0-9]*$', match: false, }, }, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36d4248fcc3c..cc9cad591925 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: key: ${{ runner.os }}-jest - name: Jest tests - run: NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules" npx jest --silent --shard=${{ fromJSON(matrix.chunk) }}/${{ strategy.job-total }} --max-workers ${{ steps.cpu-cores.outputs.count }} + run: NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules" npm test -- --silent --shard=${{ fromJSON(matrix.chunk) }}/${{ strategy.job-total }} --max-workers ${{ steps.cpu-cores.outputs.count }} storybookTests: if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' || github.event_name == 'workflow_call' }} diff --git a/contributingGuides/CLA.md b/CLA.md similarity index 100% rename from contributingGuides/CLA.md rename to CLA.md diff --git a/README.md b/README.md index 1263b1e66b3a..78b192b214c1 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ * [Offline First](contributingGuides/OFFLINE_UX.md) * [Contributing to Expensify](contributingGuides/CONTRIBUTING.md) * [Expensify Code of Conduct](CODE_OF_CONDUCT.md) -* [Contributor License Agreement](contributingGuides/CLA.md) +* [Contributor License Agreement](CLA.md) * [React StrictMode](contributingGuides/STRICT_MODE.md) * [Left Hand Navigation(LHN)](contributingGuides/LEFT_HAND_NAVIGATION.md) diff --git a/android/app/build.gradle b/android/app/build.gradle index f2e22693e65b..2f9182e9e7d6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -99,6 +99,10 @@ def enableProguardInReleaseBuilds = true def jscFlavor = 'org.webkit:android-jsc:+' android { + androidResources { + noCompress += ["bundle"] + } + ndkVersion rootProject.ext.ndkVersion buildToolsVersion rootProject.ext.buildToolsVersion @@ -110,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009009800 - versionName "9.0.98-0" + versionCode 1009009901 + versionName "9.0.99-1" // 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/checkbox.svg b/assets/images/checkbox.svg new file mode 100644 index 000000000000..d6b31ce82519 --- /dev/null +++ b/assets/images/checkbox.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index d50fa927fa95..8ad26ac31e01 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -179,6 +179,12 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): // We are importing this worker as a string by using asset/source otherwise it will default to loading via an HTTPS request later. // This causes issues if we have gone offline before the pdfjs web worker is set up as we won't be able to load it from the server. { + // eslint-disable-next-line prefer-regex-literals + test: new RegExp('node_modules/pdfjs-dist/build/pdf.worker.min.mjs'), + type: 'asset/source', + }, + { + // eslint-disable-next-line prefer-regex-literals test: new RegExp('node_modules/pdfjs-dist/legacy/build/pdf.worker.min.mjs'), type: 'asset/source', }, diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 14b571308bb5..0774caa5ad3f 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -173,7 +173,7 @@ Additionally, if you want to discuss an idea with the open source community with ### Submit your pull request for final review 14. When you are ready to submit your pull request for final review, make sure the following checks pass: - 1. CLA - You must sign our [Contributor License Agreement](https://github.com/Expensify/App/blob/main/contributingGuides/CLA.md) by following the CLA bot instructions that will be posted on your PR + 1. CLA - You must sign our [Contributor License Agreement](https://github.com/Expensify/App/blob/main/CLA.md) by following the CLA bot instructions that will be posted on your PR 2. Tests - All tests must pass before a merge of a pull request 3. Lint - All code must pass lint checks before a merge of a pull request 15. Please never force push when a PR review has already started (because this messes with the PR review history) diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index a8cfd9e87b52..8153030ca97a 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -54,11 +54,6 @@ platforms: icon: /assets/images/hand-card.svg description: Explore the perks and benefits of the Expensify Card. - - href: travel - title: Travel - icon: /assets/images/plane.svg - description: Manage all your corporate travel needs with Expensify Travel. - - href: copilots-and-delegates title: Copilots & Delegates icon: /assets/images/envelope-receipt.svg diff --git a/docs/articles/expensify-classic/connections/ADP.md b/docs/articles/expensify-classic/connections/ADP.md deleted file mode 100644 index 47cbd2fdc1f3..000000000000 --- a/docs/articles/expensify-classic/connections/ADP.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: How to use the ADP integration -description: Expensify’s ADP integration lets you pay out expense reports outside of the Expensify platform. Expensify creates a Custom Export Format that can be uploaded to ADP directly. ---- -# Overview -Expensify’s ADP integration lets you pay out expense reports outside of the Expensify platform. Expensify creates a Custom Export Format that can be uploaded to ADP directly. - -You’ll need to be on the Control Plan to create a Custom Export Format. - -Your employee list in ADP can also be imported into Expensify via Expensify’s People table in CSV format, which will speed up the process of importing the correct values to sync up your employee’s reports with ADP. This feature is available on all plans. - -# How to use the ADP integration - -## Step 1: Set up the ADP import file - -A basic setup for an ADP import file includes five columns. In order (from left to right), these columns are: - -- **Company Code** - See “Edit Company” page in ADP -- **Batch ID** - Found in “Edit Company” -- **File #** - Employee number in ADP -- **Earnings 3 Code** - See “Edit Profit Center Group” page -- **Earnings 3 Amount** - Found in “Edit Profit Center Group” - -There is a **File #** for each employee that you’re tracking in **Expensify** located under “**RUN Powered by ADP**” - navigate to **Reports tab > Tax Reports > Wage > Tax Register**. - -In **Expensify**, the **File #** is entered in the **Custom Field 1 or 2** column in the **Members table**. -The **Earnings 3 Code** is the ADP code that corresponds to a payroll account you’re tracking in **Expensify**. The **Earnings 3 Amount** is the total of a given expense you’re sending to payroll. - -In **Expensify**, you can enter the **Earnings 3 Code** at **Settings > Workspaces > [Group Workspace Name] > Categories > Categories [Category Name] > Edit Rules > Add under Payroll Code**. - -## Step 2:Create your ADP Export Format - -For a basic setup, visit **Settings > Workspaces > [Group Workspace Name] > Export Formats** and add these column headings and corresponding formulas: - -- **Name:** Company Code - - **Formula:** [From Step 1.] - -- **Name:** BatchID - - **Formula:** [From Step 1.] - -- **Name:** File # - - **Formula:** {report:submit.from:customfield1} - -- **Name:** Earnings 3 Code - - **Formula:** {expense:category:payrollcode} - -- **Name:** Earnings 3 Amount - - **Formula:** {expense:amount} - -The Company Code column is hardcoded with your company’s code in ADP. Similarly, the Batch ID is hard coded with whatever Batch ID your company is using in ADP. - -## Step 3.:Export to CSV or XLS - -To export the file, do the following: - -1. Go to your "Reports" page in Expensify -2. Select the reports you want to export -3. Click "Export to..." and choose your custom ADP format -4. Your download will begin automatically and be delivered in CSV or XLS format - -## Step 4: Upload to ADP - -You should be able to upload your ADP file directly to ADP without any changes. - -# Deep Dive - -## Using the ADP integration - -You can set Custom Fields and Payroll Codes in bulk using a CSV upload in Expensify’s settings pages. - -If you have additional requirements for your ADP upload, for example, additional headings or datasets, reach out to your Expensify Account Manager who will assist you in customizing your ADP export. Expensify Account Managers are trained to accommodate your data requests and help you retrieve them from the system. - -{% include faq-begin.md %} - -- Do I need to convert my employee list into new column headings so I can upload it to Expensify? - -Yes, you’ll need to convert your ADP employee data to the same headings as the spreadsheet that can be downloaded from the Members table in Expensify. - -- Can I add special fields/items to my ADP Payroll Custom Export Format? - -Yes! You can ask your Expensify Account Manager to help you prepare your ADP Payroll export so that it meets your specific requirements. Just reach out to them via the Chat option in Expensify and they’ll help you get set up. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/connections/Connect-to-ADP.md b/docs/articles/expensify-classic/connections/Connect-to-ADP.md new file mode 100644 index 000000000000..c2deb9213813 --- /dev/null +++ b/docs/articles/expensify-classic/connections/Connect-to-ADP.md @@ -0,0 +1,80 @@ +--- +title: How to Use the ADP Integration +description: Expensify’s ADP integration allows you to pay out expense reports outside of Expensify. It creates a Custom Export Format that can be directly uploaded to ADP. +--- + +Expensify’s ADP integration enables you to process expense report payouts outside of Expensify. It generates a **Custom Export Format** that can be uploaded to ADP. + +- You must be on the **Control Plan** to create a Custom Export Format. +- You can import your ADP employee list into Expensify as a **CSV file** via the **People table**. This helps sync employee expense reports with ADP and is available on all plans. + +--- + +# Setting Up the ADP Integration + +## Step 1: Prepare the ADP Import File +Your ADP import file should contain the following **five columns**: + +1. **Company Code** - Found in **Edit Company** in ADP. +2. **Batch ID** - Found in **Edit Company** in ADP. +3. **File #** - The employee number in ADP (located under **RUN Powered by ADP > Reports > Tax Reports > Wage > Tax Register**). +4. **Earnings 3 Code** - Found in **Edit Profit Center Group** in ADP. +5. **Earnings 3 Amount** - Found in **Edit Profit Center Group** in ADP. + +In **Expensify**: +- The **File #** is entered in **Custom Field 1 or 2** in the **Members table**. +- The **Earnings 3 Code** corresponds to a payroll account tracked in Expensify. +- The **Earnings 3 Amount** is the total expense amount sent to payroll. +- To enter the **Earnings 3 Code**, go to **Settings > Workspaces > [Group Workspace Name] > Categories > [Category Name] > Edit Rules > Add under Payroll Code**. + +--- + +## Step 2: Create Your ADP Export Format +1. Go to **Settings > Workspaces > [Group Workspace Name] > Export Formats**. +2. Add the following column headings and formulas: + +| Column Name | Formula | +|------------------------|----------------------------------------| +| Company Code | Hardcoded from Step 1 | +| Batch ID | Hardcoded from Step 1 | +| File # | `{report:submit.from:customfield1}` | +| Earnings 3 Code | `{expense:category:payrollcode}` | +| Earnings 3 Amount | `{expense:amount}` | + +The **Company Code** and **Batch ID** should be hardcoded with the values used in ADP. + +--- + +## Step 3: Export Your File +To generate and download the ADP file: + +1. Go to the **Reports** page in Expensify. +2. Select the reports you want to export. +3. Click **Export to...** and choose your custom ADP format. +4. The file will download in **CSV or XLS format**. + +--- + +## Step 4: Upload to ADP +Once exported, you can **upload the file directly to ADP** without modifications. + +--- + +# Additional Features + +## Bulk Updating Custom Fields and Payroll Codes +You can update **Custom Fields** and **Payroll Codes** in bulk using a CSV upload in Expensify’s settings. + +## Customizing Your ADP Export +If you need **additional columns, headings, or datasets**, contact your **Expensify Account Manager** via the Chat option in Expensify for assistance. + +--- + +# FAQ + +## Do I need to adjust my ADP employee list before uploading it to Expensify? +Yes. Convert your ADP employee data to match the column headings used in Expensify’s **Members table CSV export**. + +## Can I add custom fields to my ADP Payroll Export? +Yes! Your Expensify Account Manager can help customize your ADP Payroll export to fit your needs. + diff --git a/docs/articles/expensify-classic/travel/Approve-travel-expenses.md b/docs/articles/expensify-classic/travel/Approve-travel-expenses.md deleted file mode 100644 index 585e930a3dde..000000000000 --- a/docs/articles/expensify-classic/travel/Approve-travel-expenses.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Approve travel expenses -description: Determine how travel expenses are approved ---- -
- -Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. - -- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. -- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. -- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. - -# Set approval method - -1. Click the **Travel** tab. -2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select **Policies**. -4. Under General, select approval methods for Flights, Hotels, Cars and Rail. - -![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} - -# Approve travel - -![Screenshot of Expensify Travel approval email](https://help.expensify.com/assets/images/Travel_Email.png){:width="100%"} - -## Soft approval - -Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to decline it if needed. - -- To approve the booking, no action is required. -- To decline the booking, click **Decline booking** within 24 hours. Then click **Deny Booking**. - -## Hard approval - -Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to accept or decline the booking. - -To approve the booking, click **Approve booking**. Then click **Approve**. -To decline the booking, click **Decline booking**. Then click **Deny**. - -# FAQs - -## Are extended approval windows given for trips booked over the weekend or during company holidays? - -No, the approval window will always be 24 hours from when the trip is booked. - -## How does Expensify Travel handle approvals when the assigned approver is out of office? - -It is recommended to have multiple approvers for travel, as there is no delegated approval for out-of-office approvers. - -## Can travelers upload a document when submitting a trip for approval? - -Travelers are unable to add a document when submitting a trip for approval, but the company can add a ‘reason code’ in the Out of Policy rules that the traveler can complete at checkout. The traveler can then add the document to the expense report in Expensify when submitting the report. - -
- -
- -Travel expenses follow the same approval workflow as other expenses. Admins can configure travel expenses to be approved as soft approval, hard approval or passive approval. The approval method for in-policy and out-of-policy bookings can be managed under the **Policies** section in the **Program** menu for Expensify Travel. - -- **Soft Approval**: Bookings are automatically approved as long as a manager does not decline them within 24 hours. However, this also means that if a manager does not decline the expenses, the arrangements will be booked even if they are out of policy. If a booking is declined, it is refunded based on the voiding/refund terms of the service provider. -- **Hard Approval**: Bookings are automatically canceled/voided and refunded if a manager does not approve them within 24 hours. -- **Passive Approval**: Managers are informed of out-of-policy travel, but there is no action to be taken. - -# Set approval method - -1. Click the + icon in the bottom left menu and select **Book travel**. -2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select **Policies**. -4. Under General, select approval methods for Flights, Hotels, Cars and Rail. - -![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} - -# Approve travel - -![Screenshot of Expensify Travel approval email](https://help.expensify.com/assets/images/Travel_Email.png){:width="100%"} - -## Soft approval - -Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to decline it if needed. - -- To approve the booking, no action is required. -- To decline the booking, click **Decline booking** within 24 hours. Then click **Deny Booking**. - -## Hard approval - -Once an employee has booked a trip, their approver will receive an email notifying them of the booking with a prompt to accept or decline the booking. - -To approve the booking, click **Approve booking**. Then click **Approve**. -To decline the booking, click **Decline booking**. Then click **Deny**. - -# FAQs - -## Are extended approval windows given for trips booked over the weekend or during company holidays? - -No, the approval window will always be 24 hours from when the trip is booked. - -## How does Expensify Travel handle approvals when the assigned approver is out of office? - -It is recommended to have multiple approvers for travel, as there is no delegated approval for out-of-office approvers. - -## Can travelers upload a document when submitting a trip for approval? - -Travelers are unable to add a document when submitting a trip for approval, but the company can add a ‘reason code’ in the Out of Policy rules that the traveler can complete at checkout. The traveler can then add the document to the expense report in Expensify when submitting the report. - -
diff --git a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md deleted file mode 100644 index f48d069e21dc..000000000000 --- a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Book with Expensify Travel -description: How to book flights, hotels, cars, trains, and more with Expensify Travel ---- - -Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. - -With Expensify Travel, you can: -- Search and book travel arrangements all in one place -- Book travel for yourself or for someone else -- Get real-time support by chat or phone -- Manage all your T&E expenses in Expensify -- Create specific rules for booking travel -- Enable approvals for out-of-policy trips -- Book with any credit card on the market -- Book with the Expensify Card to get cash back and automatically reconcile transactions - -There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. - -# Book travel - -To book travel from the Expensify web app, - -1. Click the **Travel** tab. -2. Click **Book or manage travel**. -3. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. -4. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). -5. Select all the details for the arrangement you want to book. -6. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. - -The traveler is emailed an itinerary of the booking. Additionally, -- Their travel details are added to a Trip chat room under their primary workspace. -- An expense report for the trip is created. -- If booked with an Expensify Card, the trip is automatically reconciled. - -{% include info.html %} -The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable. -{% include end-info.html %} - -# Edit or cancel travel arrangements - -Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee. diff --git a/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md b/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md deleted file mode 100644 index 2b2731fae117..000000000000 --- a/docs/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -title: Configure travel policy and preferences -description: Set and update travel policies and preferences for your Expensify Workspace ---- -
- -As a Workspace Admin, you can set travel policies for all travel booked under a workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. - -# Create or update a travel policy - -Workspace admins can create different travel policies that provide travel rules for different groups of travelers. When using Expensify Travel for the first time, you will need to create a new Travel Policy. - -To create or update a travel policy, - -1. Click the **Travel** tab. -2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select **Policies**. -4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. -5. Use the **Edit members** section to select the group of employees that belong to this policy. - 1. **To select an existing policy:** Select the policy in the left menu. - 2. **To add a new policy:** Click **Add new** under Employee or Non-employee in the left menu. Then under the Edit members section, select the group of employees that belong to this policy. - -{% include info.html %} -The Company name in Expensify Travel is the domain of the Expensify workspace billing owner -A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. -{% include end-info.html %} - -10. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -11. Click the paperclip icon next to each setting to de-couple it from your default policy. -12. Update the desired settings. - -# General - -Determine the currency and [approval type](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses#set-approval-method) for different types of travel bookings. You can also upload a PDF of your company travel policy for employees to access when booking travel. - -![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} - -# Flight - -Flight preferences include multiple sections with different settings: - -- **Flights not allowed to be booked:** Restrict specific flight classes from being booked by employees. -- **Cabin class settings:** Set the highest cabin class allowed for booking and whether employees can accept cabin upgrades. -- **Budget:** Set a percentage or fare cap for the median or cheapest fares on flights, and a maximum capped price. Flight fares above this cap will be considered out of policy. You can also add customization to allow for different caps for different flight durations. -- **Lowest logical fare settings:** Set a preference for layovers, number of stops, flight time window, and airport connection changes. Expensify automatically takes these preferences into account when showing the lowest logical travel fare. -- **Add Ons and Preferences:** Select what types of add-ons your employees can book, including: - - Additional baggage - - Early check-in - - Seat preference - - No add-ons allowed -- **Booking windows:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. -- **Refundable/changeable tickets:** Allow employees to book fully or partially refundable fares. To allow all options, you can leave this as the default option. -- **Maximum CO2 per kilometer:** If the CO2 per passenger km exceeds this threshold, the flight will be marked as out of policy. You can leave the entry blank if you do not wish to use this policy. -- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy flight booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. - -# Hotel - -- **Restrict booking by keyword:** Keywords entered here will be compared to the hotel rate description. If any of the keywords are found, the hotel rate will be restricted. You also have the option to provide a reason why each keyword is restricted. -- **Hotel rates not allowed to be booked:** Restrict specific hotel rates (such as non-refundable, prepaid, and requires deposit) from being booked by employees. This overrides all other hotel policy rules. You also have the option to add a reason why these options aren’t available. -- **Maximum price:** Set a maximum price per night for bookings. You can also select the Customizations by location option to set a maximum price for each location. -- **Booking window:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. -- **Cancellation policy:** Allow your employees to book fully or partially refundable rooms. To allow all options, you can leave this as the default option. -- **Experience:** Set hotel ratings that are in and out of policy. -- **Nightly median rate:** Determine which hotels to consider when calculating the median price. You can set a radius around the search location and include/disclude hotels above or below a certain rating. -- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy hotel booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. - -# Car - -- **Car categories not allowed:** Restrict specific car types from being booked by employees. This overrides any other car policy. -- **Car categories in policy:** Define the types of cars that are in policy. If you do not list a specific car category, it will still be booked as long as it isn’t included in the Car categories not allowed setting. However, the booking will be classed as out of policy. -- **Car engine types not allowed:** Restrict specific engine types from being booked by employees regardless of other policy settings. -- **Maximum price:** Set a daily price cap per car (not including taxes and fees). -- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy car booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. - -# Rail - -- **Maximum price:** Set a maximum price per booking or customise by rail trip duration. -- **Highest travel class:** Set a maximum travel class per booking or customise by rail trip duration. -- **Booking window:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the journey time. -- **Out-of-policy reason code for rail:** If enabled, travelers will be asked to enter a reason code for an out-of-policy rail booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. - -# FAQ - -How do travel policy rules interact with Expensify’s [approval flows](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses)? - -Travel policy rules define what can and can’t be booked by your employees while they’re making the booking. Once a booking is placed and the travel itself is [approved](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses), the expense will appear in Expensify. It will then be coded, submitted, pushed through the existing expense approval process as defined by your workspace, and exported to your preferred accounting platform (if applicable). - -
- -
- -As a Workspace Admin, you can set travel policies for all travel booked under your workspace, including approval methods, flight booking class, and hotel star preferences. You can also create multiple policies with specific conditions for particular groups of employees and/or non-employees. - -# Create a travel policy - -Workspace admins can create different travel policies that provide travel rules for different groups of travelers. When using Expensify Travel for the first time, you will need to create a new Travel Policy. - -To create or update a travel policy, - -1. Click the + icon in the bottom left menu and select **Book travel**. -2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select **Policies**. -4. Under Employee or Non-employee in the left menu, click **Add new** to create a new policy. -5. Use the **Edit members** section to select the group of employees that belong to this policy. - 1. **To select an existing policy:** Select the policy in the left menu. - 2. **To add a new policy:** Click **Add new** under Employee or Non-employee in the left menu. Then under the Edit members section, select the group of employees that belong to this policy. - -{% include info.html %} -A Legal Entity in Expensify Travel is the equivalent of an Expensify Workspace. -{% include end-info.html %} - -10. Select which travel preferences you want to modify: General, flight, hotel, car, or rail. -11. Click the paperclip icon next to each setting to de-couple it from your default policy. -12. Update the desired settings. - -# General - -Determine the currency and [approval type](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses#set-approval-method) for different types of travel bookings. You can also upload a PDF of your company travel policy for employees to access when booking travel. - -![Screenshot of Expensify Travel policy approval settings](https://help.expensify.com/assets/images/Travel_Policy.png){:width="100%"} - -# Flight - -Flight preferences include multiple sections with different settings: - -- **Flights not allowed to be booked:** Restrict specific flight classes from being booked by employees. -- **Cabin class settings:** Set the highest cabin class allowed for booking and whether employees can accept cabin upgrades. -- **Budget:** Set a percentage or fare cap for the median or cheapest fares on flights, and a maximum capped price. Flight fares above this cap will be considered out of policy. You can also add customization to allow for different caps for different flight durations. -- **Lowest logical fare settings:** Set a preference for layovers, number of stops, flight time window, and airport connection changes. Expensify automatically takes these preferences into account when showing the lowest logical travel fare. -- **Add Ons and Preferences:** Select what types of add-ons your employees can book, including: - - Additional baggage - - Early check-in - - Seat preference - - No add-ons allowed -- **Booking windows:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. -- **Refundable/changeable tickets:** Allow employees to book fully or partially refundable fares. To allow all options, you can leave this as the default option. -- **Maximum CO2 per kilometer:** If the CO2 per passenger km exceeds this threshold, the flight will be marked as out of policy. You can leave the entry blank if you do not wish to use this policy. -- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy flight booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. - -# Hotel - -- **Restrict booking by keyword:** Keywords entered here will be compared to the hotel rate description. If any of the keywords are found, the hotel rate will be restricted. You also have the option to provide a reason why each keyword is restricted. -- **Hotel rates not allowed to be booked:** Restrict specific hotel rates (such as non-refundable, prepaid, and requires deposit) from being booked by employees. This overrides all other hotel policy rules. You also have the option to add a reason why these options aren’t available. -- **Maximum price:** Set a maximum price per night for bookings. You can also select the Customizations by location option to set a maximum price for each location. -- **Booking window:** Add a time limit to prevent employees from booking less than a certain number of days in advance to prohibit bookings too close to the flight time. -- **Cancellation policy:** Allow your employees to book fully or partially refundable rooms. To allow all options, you can leave this as the default option. -- **Experience:** Set hotel ratings that are in and out of policy. -- **Nightly median rate:** Determine which hotels to consider when calculating the median price. You can set a radius around the search location and include/disclude hotels above or below a certain rating. -- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy hotel booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. - -# Car - -- **Car categories not allowed:** Restrict specific car types from being booked by employees. This overrides any other car policy. -- **Car categories in policy:** Define the types of cars that are in policy. If you do not list a specific car category, it will still be booked as long as it isn’t included in the Car categories not allowed setting. However, the booking will be classed as out of policy. -- **Car engine types not allowed:** Restrict specific engine types from being booked by employees regardless of other policy settings. -- **Maximum price:** Set a daily price cap per car (not including taxes and fees). -- **Out of policy reason codes:** If enabled, travelers will be asked to enter a reason code for an out-of-policy car booking. This gives them a way to provide context for why the booking is still being placed. You can also modify the reason codes by clicking Manage reason codes below the toggle. - -# FAQ - -How do travel policy rules interact with Expensify’s [approval flows](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses)? - -Travel policy rules define what can and can’t be booked by your employees while they’re making the booking. Once a booking is placed and the travel itself is [approved](https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses), the expense will appear in Expensify. It will then be coded, submitted, pushed through the existing expense approval process as defined by your workspace, and exported to your preferred accounting platform (if applicable). - -
diff --git a/docs/articles/expensify-classic/travel/Track-Travel-Analytics.md b/docs/articles/expensify-classic/travel/Track-Travel-Analytics.md deleted file mode 100644 index 18506215635e..000000000000 --- a/docs/articles/expensify-classic/travel/Track-Travel-Analytics.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Track Travel Analytics -description: Get insight into company travel bookings to ensure real-time duty of care reporting and travel policy compliance. ---- -
- -Expensify Travel provides insights into company travel bookings to ensure real-time duty of care reporting and travel policy compliance. These analytics help Workspace Admins: - -- See global employee locations with a real-time employee location map -- Analyze travel spend based on details such as trip, traveler, or carrier -- Monitor booking trends and adherence to travel policy compliance -- Generate environmental, social, and governance (ESG) reporting - -To view your analytics, - -1. Click the + icon in the bottom left menu and select **Book travel**. -2. Click **Book travel**. -3. Click the **Analytics** tab at the top of the screen. - -From here, you can see a variety of reports, including the Duty of Care report, Spend, and ESG metrics. - -## Duty of Care report - -Duty of care is a legal obligation for employers to safeguard the health, safety, and well-being of their employees both in the office and during business trips. With Expensify’s Duty of Care analytics, you can view a global map showing real-time employee locations. - -1. Click the **Analytics** tab at the top and select Travelers. -2. Use the map to see employee locations. If desired, you can use the filters above the map to show past and future trips, or travel booked to specific locations. - -## Spend and compliance report - -Workspace Admins can analyze travel data based on a variety of trip, traveler, and compliance attributes. - -1. Click the **Analytics** tab at the top and select Company Reports. -2. Review the overview data, or select a specific report from the left menu. -3. Click the three dot menu on the right of the screen to download the report as a PDF. - -## ESG report - -Expensify Travel provides various ESG metrics, including carbon footprint analysis, sustainability scores, and ethical travel spending. - -1. Click the **Analytics** tab at the top and select Company Reports. -2. Click **Air Manifest** in the left menu. -3. Review the CO2 Emissions column in the table. - -
diff --git a/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md b/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md index 15a74cf925fa..8f8d076700a4 100644 --- a/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md +++ b/docs/articles/new-expensify/connections/netsuite/Netsuite-Troubleshooting.md @@ -1,442 +1,366 @@ --- -title: Netsuite Troubleshooting -description: Troubleshoot common NetSuite sync and export errors. +title: NetSuite Troubleshooting +description: Troubleshoot common NetSuite sync and export errors. --- -Synchronizing and exporting data between Expensify and NetSuite can streamline your financial processes, but occasionally, users may encounter errors that prevent a smooth integration. These errors often arise from discrepancies in settings, missing data, or configuration issues within NetSuite or Expensify. +Synchronizing and exporting data between Expensify and NetSuite helps streamline financial processes, but errors can occasionally disrupt the integration. These errors typically arise from missing data, incorrect settings, or configuration issues in NetSuite or Expensify. -This troubleshooting guide aims to help you identify and resolve common sync and export errors, ensuring a seamless connection between your financial management systems. By following the step-by-step solutions provided for each specific error, you can quickly address issues and maintain accurate and efficient expense reporting and data management. +This guide provides step-by-step solutions for resolving common NetSuite sync and export errors, ensuring accurate and efficient expense reporting and data management. -# ExpensiError NS0005: Please enter value(s) for Department, Location or Class - -**Why does this happen?** - -This error occurs when the classification (like Location) is required at the header level of your transaction form in NetSuite. - -For expense reports and journal entries, NetSuite uses classifications from the employee record default. Expensify only exports this information at the line item level. - -For vendor bills, these classifications can't be mandatory because we use the vendor record instead of the employee record, and vendor records don’t have default classifications. - -## How to fix it for vendor bills +--- +# ExpensiError NS0005: Please Enter Value(s) for Department, Location, or Class -Note: When exporting as a Vendor Bill, we pull from the vendor record, not the employee. Therefore, employee defaults don’t apply at the header ("main") level. This error appears if your NetSuite transaction form requires those fields. +## Why does this happen? +This error occurs when NetSuite requires classifications (Department, Location, or Class) at the header level, but Expensify only exports them at the line item level. +## Fix for Vendor Bills 1. Go to **Customization > Forms > Transaction Forms**. -2. Click **"Edit"** on your preferred vendor bill form. -3. Go to **Screen Fields > Main**. -4. Uncheck both **"Show"** and **"Mandatory"** for the listed fields in your error message. -5. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) -6. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. - -## How to fix it for journal entries and expense reports - -Note: If you see this error when exporting a Journal Entry or Expense Report, it might be because the report submitter doesn’t have default settings for Departments, Classes, or Locations. +2. Click **Edit** on your preferred Vendor Bill form. +3. Navigate to **Screen Fields > Main**. +4. Uncheck **Show** and **Mandatory** for the fields listed in the error message. +5. Sync NetSuite in Expensify: **Settings > Workspaces > Workspace Name > Accounting > Three-dot menu > Sync Now**. +6. Reattempt the export. +## Fix for Journal Entries and Expense Reports 1. Go to **Lists > Employees** in NetSuite. -2. Click **"Edit"** next to the employee's name who submitted the report. -3. Scroll down to the **Classification** section. -4. Select a default **Department**, **Class**, and **Location** for the employee. -5. Click **Save**. -6. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) -7. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. - - -# ExpensiError NS0012: Currency Does Not Exist In NetSuite - -**Why does this happen? (scenario 1)** - -When dealing with foreign transactions, Expensify sends the conversion rate and currency of the original expense to NetSuite. If the currency isn't listed in your NetSuite subsidiary, you'll see an error message saying the currency does not exist in NetSuite. - -## How to fix it - -1. Ensure the currency in Expensify matches what's in your NetSuite subsidiary. -2. If you see an error saying 'The currency X does not exist in NetSuite', re-sync your connection to NetSuite through the workspace admin section in Expensify. -3. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. - -**Why does this happen? (scenario 2)** - -This error can happen if you’re using a non-OneWorld NetSuite instance and exporting a currency other than EUR, GBP, USD, or CAD. - -## How to fix it - -1. Head to NetSuite. -2. Go to **Setup > Enable Features**. -3. Check the **Multiple Currencies** box. - -Once you've done this, you can add the offending currency by searching **New Currencies** in the NetSuite global search. - -# ExpensiError NS0021: Invalid tax code reference key - -**Why does this happen?** - -This error usually indicates an issue with the Tax Group settings in NetSuite, which can arise from several sources. - -## How to fix it - -If a Tax Code on Sales Transactions is mapped to a Tax Group, an error will occur. To fix this, the Tax Code must be mapped to a Tax Code on Purchase Transactions instead. - -To verify if a Tax Code is for Sales or Purchase transactions, view the relevant Tax Code(s). - -**For Australian Taxes:** - -Ensure your Tax Groups are mapped correctly: -- **GST 10%** to **NCT-AU** (not the Sales Transaction Tax Code TS-AU) -- **No GST 0%** to **NCF-AU** (not the Sales Transaction Tax Code TFS-AU) - -### Tax Group Type -Tax Groups can represent different types of taxes. For compatibility with Expensify, ensure the tax type is set to GST/VAT. - -### Enable Tax Groups -Some subsidiaries require you to enable Tax Groups. Go to **Set Up Taxes** for the subsidiary's country and ensure the Tax Code lists include both Tax Codes and Tax Groups. - -# ExpensiError NS0023: Employee Does Not Exist in NetSuite (Invalid Employee) - -**Why does this happen?** - -This can happen if the employee’s subsidiary in NetSuite doesn’t match the subsidiary selected for the connection in Expensify. - -## How to fix it +2. Click **Edit** next to the employee who submitted the report. +3. Scroll to **Classification** and assign a **Department**, **Class**, and **Location**. +4. Click **Save**. +5. Sync NetSuite in Expensify. +6. Reattempt the export. -1. **Check the Employee's Subsidiary** - - Go to the employee record in NetSuite. - - Confirm the employee's subsidiary matches what’s listed as the subsidiary at the workspace level. - - To find this in Expensify navigate to **Settings > Workspaces > click workspace name > Accounting > Subsidiary**. - - If the subsidiaries don’t match, update the subsidiary in Expensify to match what’s listed in NetSuite. - - Sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) -2. **Verify Access Restrictions:** - - Go to **Lists > Employees > Employees > [Select Employee] > Edit > Access**. - - Uncheck **Restrict Access to Expensify**. -3. **Additional Checks:** - - Ensure the email on the employee record in NetSuite matches the email address of the report submitter in Expensify. - - In NetSuite, make sure the employee's hire date is in the past and/or the termination date is in the future. -4. **Currency Match for Journal Entries:** - - If exporting as Journal Entries, ensure the currency for the NetSuite employee record, NetSuite subsidiary, and Expensify workspace all match. - - In NetSuite, go to the **Human Resources** tab > **Expense Report Currencies**, and add the subsidiary/policy currency if necessary. - -# ExpensiError NS0085: Expense Does Not Have Appropriate Permissions for Settings an Exchange Rate in NetSuite - -**Why does this happen?** - -This error occurs when the exchange rate settings in NetSuite aren't updated correctly. - -## How to fix it - -1. In NetSuite, go to Customization > Forms > Transaction Forms. -2. Search for the form type that the report is being exported as (Expense Report, Journal Entry, or Vendor Bill) and click Edit next to the form that has the Preferred checkbox checked. - - **For Expense Reports:** - - Go to Screen Fields > Expenses (the Expenses tab farthest to the right). - - Ensure the Exchange Rate field under the Description column has the Show checkbox checked. - - **For Vendor Bills:** - - Go to Screen Fields > Main. - - Ensure the Exchange Rate field under the Description column has the Show checkbox checked. - - **For Journal Entries:** - - Go to Screen Fields > Lines. - - Ensure the Exchange Rate field under the Description column has the Show checkbox checked. - - Go to Screen Fields > Main and ensure the Show checkbox is checked in the Exchange Rate field under the Description column. -3. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) -4. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. - -# ExpensiError NS0079: The Transaction Date is Not Within the Date Range of Your Accounting Period - -**Why does this happen?** - -The transaction date you specified is not within the date range of your accounting period. When the posting period settings in NetSuite are not configured to allow a transaction date outside the posting period, you can't export a report to the next open period, which is why you’ll run into this error. - -## How to fix it - -1. In NetSuite, navigate to Setup > Accounting > Accounting Preferences. -2. Under the General Ledger section, ensure the field Allow Transaction Date Outside of the Posting Period is set to Warn. -3. Then, choose whether to export your reports to the First Open Period or the Current Period. +--- +# ExpensiError NS0012: Currency Does Not Exist in NetSuite -**Additionally, ensure the Export to Next Open Period feature is enabled within Expensify:** -1. Navigate to **Settings > Workspaces > Workspace Name > Accounting > Export. -2. Scroll down and confirm that the toggle for **Export to next open period** is enabled. +## Why does this happen? +This occurs when: +- Expensify sends a currency not listed in your NetSuite subsidiary. +- You are using a non-OneWorld NetSuite instance and exporting a currency other than EUR, GBP, USD, or CAD. -If any configuration settings are updated on the NetSuite connection, be sure to sync the connection before trying the export again. +## How to Fix It +1. Ensure the currency in Expensify matches NetSuite. +2. Sync NetSuite in Expensify. +3. Enable **Multiple Currencies** in NetSuite: **Setup > Enable Features**. +4. Add the missing currency via **New Currencies** in the NetSuite global search. +5. Reattempt the export. -# ExpensiError NS0055: The Vendor You are Trying to Export to Does Not Have Access to the Currency X +--- +# ExpensiError NS0021: Invalid Tax Code Reference Key -**Why does this happen?** +## Why does this happen? +This error usually results from an issue with Tax Group settings in NetSuite, such as a Tax Code being mapped incorrectly. -This error occurs when a vendor tied to a report in Expensify does not have access to a currency on the report in NetSuite. The vendor used in NetSuite depends on the type of expenses on the report you're exporting. +## How to Fix It +1. Verify that Tax Codes on Sales Transactions are not mapped to Tax Groups. +2. Ensure the correct Tax Code is assigned to Purchase Transactions. +3. For Australian users: + - **GST 10%** should be mapped to **NCT-AU** (not TS-AU). + - **No GST 0%** should be mapped to **NCF-AU** (not TFS-AU). +4. Ensure Tax Groups are enabled under **Set Up Taxes** in NetSuite. +5. Reattempt the export. -- For **reimbursable** (out-of-pocket) expenses, this is the employee who submitted the report. -- For **non-reimbursable** (e.g., company card) expenses, this is the default vendor set via the Settings > Workspaces > click workspace name > Accounting > Export settings. +--- +# ExpensiError NS0023: Employee Does Not Exist in NetSuite -## How to fix it +## Why does this happen? +This occurs when the employee’s subsidiary in NetSuite does not match the one selected for the connection in Expensify. -To fix this, the vendor needs to be given access to the applicable currency: -1. In NetSuite, navigate to Lists > Relationships > Vendors to access the list of Vendors. -2. Click Edit next to the Vendor tied to the report: - - For reimbursable (out-of-pocket) expenses, this is the report's submitter. - - For non-reimbursable (e.g., company card) expenses, this is the default vendor set via **Settings > Workspaces > click workspace name > Accounting > Export > click Export company card expenses as > Default vendor.** -3. Navigate to the Financial tab. -4. Scroll down to the Currencies section and add all the currencies that are on the report you are trying to export. -5. Click Save. +## How to Fix It +1. Verify the employee's subsidiary in NetSuite. +2. Confirm the Expensify workspace’s subsidiary under **Settings > Workspaces > Accounting > Subsidiary**. +3. Check **Lists > Employees > Edit > Access** and uncheck **Restrict Access to Expensify**. +4. Ensure the employee’s email matches in both NetSuite and Expensify. +5. Sync NetSuite in Expensify. +6. Reattempt the export. -# ExpensiError NS0068: You do not have permission to set a value for element - “Created From” +--- +# ExpensiError NS0085: Expense Lacks Permissions to Set Exchange Rate -**Why does this happen?** +## Why does this happen? +This occurs when NetSuite’s exchange rate settings are not configured correctly. -This error typically occurs due to insufficient permissions or misconfigured settings in NetSuite on the preferred transaction form for your export type. +## How to Fix It +1. Go to **Customization > Forms > Transaction Forms**. +2. Select the form being used for export (Expense Report, Journal Entry, or Vendor Bill) and click **Edit**. +3. Ensure the **Exchange Rate** field is set to **Show** under: + - **Screen Fields > Expenses** (Expense Reports) + - **Screen Fields > Main** (Vendor Bills) + - **Screen Fields > Lines** and **Screen Fields > Main** (Journal Entries) +4. Sync NetSuite in Expensify. +5. Reattempt the export. -## How to fix it +--- -1. In NetSuite, go to Customization > Forms > Transaction Forms. -2. Search for the form type that the report is being exported as in NetSuite (Expense Report, Journal Entry, Vendor Bill, or if the report total is negative, Vendor Credit). -3. Click Edit next to the form that has the Preferred checkbox checked. -4. Go to Screen Fields > Main and ensure the field Created From has the Show checkbox checked. -5. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) -6. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. +# ExpensiError NS0079: Transaction Date Outside Accounting Period -## ExpensiError NS0068: Reports with Expensify Card expenses +## Why does this happen? +NetSuite prevents transactions from being posted outside of designated accounting periods. -**Why does this happen?** +## How to Fix It +1. In NetSuite, go to **Setup > Accounting > Accounting Preferences**. +2. Under **General Ledger**, set **Allow Transaction Date Outside of Posting Period** to **Warn**. +3. Enable **Export to Next Open Period** in Expensify under **Settings > Workspaces > Accounting > Export**. +4. Sync NetSuite in Expensify. +5. Reattempt the export. -Expensify Card expenses export as Journal Entries. If you encounter this error when exporting a report with Expensify Card non-reimbursable expenses, ensure the field Created From has the Show checkbox checked for Journal Entries in NetSuite. +--- +# ExpensiError NS0055: Vendor Lacks Access to Currency -## How to fix it -1. In NetSuite, go to Customization > Forms > Transaction Forms. -2. Click Edit next to the journal entry form that has the Preferred checkbox checked. -3. Ensure the field Created From has the Show checkbox checked. -4. Sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) -5. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. +## Why does this happen? +This occurs when a vendor in NetSuite is not configured to accept a specific currency. -# ExpensiError NS0037: You do not have permission to set a value for element - “Receipt URL” +## How to Fix It +1. In NetSuite, go to **Lists > Relationships > Vendors**. +2. Edit the vendor assigned to the report. +3. Under the **Financial** tab, add the missing currency. +4. Click **Save**. +5. Sync NetSuite in Expensify. +6. Reattempt the export. -**Why does this happen?** +--- +# ExpensiError NS0068: Missing "Created From" Permission -This error typically occurs due to insufficient permissions or misconfigured settings in NetSuite on the preferred transaction form for your export type. +## Why does this happen? +This occurs due to insufficient permissions on the transaction form being used for export. -## How to fix it +## How to Fix It +1. Go to **Customization > Forms > Transaction Forms**. +2. Edit the form marked as **Preferred**. +3. Ensure the **Created From** field is set to **Show** under **Screen Fields > Main**. +4. Sync NetSuite in Expensify. +5. Reattempt the export. -1. In NetSuite, go to Customization > Forms > Transaction Forms. -2. Search for the form type that the report is being exported as in NetSuite (Expense Report, Journal Entry, or Vendor Bill). -3. Click Edit next to the form that has the Preferred checkbox checked. - - If the report is being exported as an Expense Report: - - Go to Screen Fields > Expenses (the Expenses tab farthest to the right). - - Ensure the field ReceiptURL has the Show checkbox checked. - - If the report is being exported as a Journal Entry: - - Go to Screen Fields > Lines. - - Ensure the field ReceiptURL has the Show checkbox checked. - - If the report is being exported as a Vendor Bill: - - Go to Screen Fields > Main. - - Ensure the field ReceiptURL has the Show checkbox checked. -4. Sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) -5. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. +--- +# ExpensiError NS0109: NetSuite Login Failed -# ExpensiError NS0042: Error creating vendor - this entity already exists +## Why does this happen? +This error indicates a problem with the authentication tokens used to connect NetSuite and Expensify. -**Why does this happen?** +## How to Fix It +1. Review the [NetSuite Connection Guide](https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite). +2. If using an existing token, create a new one and update the connection in Expensify. +3. Sync NetSuite in Expensify. +4. Reattempt the export. -This error occurs when a vendor record already exists in NetSuite, but Expensify is still attempting to create a new one. This typically means that Expensify cannot find the existing vendor during export. -- The vendor record already exists in NetSuite, but there may be discrepancies preventing Expensify from recognizing it. -- The email on the NetSuite vendor record does not match the email of the report submitter in Expensify. -- The vendor record might not be associated with the correct subsidiary in NetSuite. +--- +# ExpensiError NS0037: You Do Not Have Permission to Set a Value for “Receipt URL” -## How to fix it +## Why does this happen? +This error occurs when the **Receipt URL** field is not visible in NetSuite's transaction form settings. -1. **Check Email Matching:** - - Ensure the email on the NetSuite vendor record matches the email of the report submitter in Expensify. - - If it doesn’t match update the existing vendor record in NetSuite to match the report submitter's email and name. - - If there is no email listed, add the email address of the report’s submitter to the existing vendor record in NetSuite. -2. **Check Subsidiary Association:** - - Ensure the vendor record is associated with the same subsidiary selected in the connection configurations - - You can review this under **Settings > Workspaces > click workspace name > Accounting > Subsidiary.** -3. **Automatic Vendor Creation:** - - If you want Expensify to automatically create vendors, ensure the "Auto-create employees/vendors" option is enabled under **Settings > Workspaces > click workspace name > Accounting > Advanced.** - - If appropriate, delete the existing vendor record in NetSuite to allow Expensify to create a new one. -4. After making the necessary changes, sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) -5. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. +## How to Fix It +1. **Go to NetSuite**: Navigate to **Customization > Forms > Transaction Forms**. +2. **Find the transaction form**: Locate the form used for the export type (Expense Report, Journal Entry, or Vendor Bill). +3. **Edit the form**: + - **Expense Reports**: Go to **Screen Fields > Expenses** and ensure **ReceiptURL** is set to **Show**. + - **Journal Entries**: Go to **Screen Fields > Lines** and ensure **ReceiptURL** is set to **Show**. + - **Vendor Bills**: Go to **Screen Fields > Main** and ensure **ReceiptURL** is set to **Show**. +4. **Save the changes** and **sync NetSuite in Expensify** (**Settings > Workspaces > Accounting > Sync Now**). +5. **Retry the export**. -# ExpensiError NS0109: Failed to login to NetSuite, please verify your credentials +--- -**Why does this happen?** +# ExpensiError NS0042: Error Creating Vendor - This Entity Already Exists -This error indicates a problem with the tokens created for the connection between Expensify and NetSuite. The error message will say, "Login Error. Please check your credentials." +## Why does this happen? +Expensify is trying to create a new vendor in NetSuite, but a vendor with the same name or email **already exists**. -## How to fix it +## How to Fix It +1. **Verify vendor details in NetSuite**: + - Go to **Lists > Relationships > Vendors** and search for the vendor's name and email. +2. **Ensure email matches**: + - The email in NetSuite should match the email of the **report submitter in Expensify**. + - If missing, update the NetSuite vendor record with the correct email. +3. **Check subsidiary association**: + - Ensure the vendor belongs to the **same subsidiary** as set in Expensify (**Settings > Workspaces > Accounting > Subsidiary**). +4. **Enable automatic vendor creation (if needed)**: + - In Expensify, go to **Settings > Workspaces > Accounting > Advanced** and enable **Auto-create employees/vendors**. +5. **Sync NetSuite in Expensify** and **retry the export**. -1. Review the [Connect to NetSuite](https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite) guide and follow steps 1 and 2 exactly as outlined. -2. If you're using an existing token and encounter a problem, you may need to create a new token. +--- -# ExpensiError NS0123 Login Error: Please make sure that the Expensify integration is enabled +# ExpensiError NS0045: Expenses Not Categorized with a NetSuite Account -**Why does this happen?** +## Why does this happen? +This error occurs when expenses in Expensify are assigned to a **category that does not exist in NetSuite** or **was not imported into Expensify**. -This error indicates that the Expensify integration is not enabled in NetSuite. +## How to Fix It +1. **Check the missing category in NetSuite**: + - Search for the category using the **NetSuite Global Search**. + - Ensure it is **active** and correctly named. + - Confirm it is associated with the correct **subsidiary**. +2. **Re-sync categories**: + - In Expensify, go to **Settings > Workspaces > Accounting > Sync Now**. +3. **Reapply the category in Expensify**: + - Open the report, select the affected expense(s), and **reapply the correct category**. +4. **Retry the export**. -## How to fix it +--- -1. **Enable the Expensify Integration:** - - In NetSuite, navigate to Setup > Integrations > Manage Integrations. - - Ensure that the Expensify Integration is listed and that the State is Enabled. -2. **If you can't find the Expensify integration:** - - Click "Show Inactives" to see if Expensify is listed as inactive. - - If Expensify is listed, update its state to Enabled. -3. Once the Expensify integration is enabled, sync the NetSuite connection in Expensify (**Settings > Workspaces > Workspace Name > Accounting > three-dot menu > Sync Now**.) +# ExpensiError NS0046: Billable Expenses Not Coded with a NetSuite Customer or Billable Project -# ExpensiError NS0045: Expenses Not Categorized with a NetSuite Account +## Why does this happen? +In NetSuite, **billable expenses** must be assigned to a **Customer** or **Billable Project**. If they are missing, this error occurs. -**Why does this happen?** +## How to Fix It +1. **Check the affected expenses in Expensify**: + - Open the report and review **each billable expense**. + - Confirm that a **Customer or Project** tag is assigned. +2. **Update the expense**: + - Apply the correct **Customer or Project** in Expensify. +3. **Retry the export**. -This happens when approved expenses are categorized with an option that didn’t import from NetSuite. For NetSuite to accept expense coding, it must first exits and be imported into Expensify from NetSuite. +--- -## How to fix it +# ExpensiError NS0061: Please Enter Value(s) for: Tax Code -1. Log into NetSuite -2. Do a global search for the missing record. - - Ensure the expense category is active and correctly named. - - Ensure the category is associated with the correct subsidiary that the Expensify workspace is linked to. -3. Sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) -4. Go back to the report, click on the offending expense(s), and re-apply the category in question. -5. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. +## Why does this happen? +This error occurs when attempting to export **expense reports to a NetSuite Canadian subsidiary** that requires a **Tax Code**, but none is set. +## How to Fix It +1. **Enable Tax in NetSuite**: + - Go to **Setup > Company > Enable Features** and confirm that **Tax Codes** are enabled. +2. **Ensure the Tax Code exists**: + - In NetSuite, go to **Setup > Accounting > Tax Codes** and confirm the correct tax codes exist. +3. **Assign a Tax Posting Account in Expensify**: + - Go to **Settings > Workspaces > Accounting > Export** and select a **Journal Entry tax posting account**. +4. **Sync NetSuite in Expensify** and **retry the export**. -# ExpensiError NS0061: Please Enter Value(s) for: Tax Code +--- -**Why does this happen?** +# ExpensiError NS0068 (Expensify Card Expenses): Missing "Created From" Permission -This error typically occurs when attempting to export expense reports to a Canadian subsidiary in NetSuite for the first time and/or if your subsidiary in NetSuite has Tax enabled. +## Why does this happen? +Expensify Card expenses export as **Journal Entries**. If the **Created From** field is not visible in the Journal Entry form, this error occurs. -## How to fix it +## How to Fix It +1. **Edit the Journal Entry form in NetSuite**: + - Go to **Customization > Forms > Transaction Forms**. + - Click **Edit** next to the preferred Journal Entry form. + - Navigate to **Screen Fields > Main**. + - Ensure **Created From** is set to **Show**. +2. **Save the changes** and **sync NetSuite in Expensify**. +3. **Retry the export**. -To fix this, you need to enable Tax in the NetSuite configuration settings. +--- -1. Go to **Settings > Workspaces > click workspace name > Accounting > Export**. - - Select a Journal Entry tax posting account if you plan on exporting any expenses with taxes. -2. Wait for the connection to sync, it will automatically do so after you make a change. -3. Attempt the export again. +# ExpensiError NS0123: Login Error - Expensify Integration Not Enabled -**Note:** Expenses created before Tax was enabled might need to have the newly imported taxes applied to them retroactively to be exported. +## Why does this happen? +This error occurs when **Expensify is not enabled** as an integration in NetSuite. -# Error creating employee: Your current role does not have permission to access this record. +## How to Fix It +1. **Check if Expensify is enabled in NetSuite**: + - Go to **Setup > Integrations > Manage Integrations**. + - Look for **Expensify Integration** and ensure its **State** is **Enabled**. +2. **If Expensify is missing**: + - Click **Show Inactives** to see if Expensify is listed. + - If it appears, **reactivate it**. +3. **Sync NetSuite in Expensify** and **retry the export**. -**Why does this happen?** +--- -This error indicates that the credentials or role used to connect NetSuite to Expensify do not have the necessary permissions within NetSuite. You can find setup instructions for configuring permissions in NetSuite [here](https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite). +# Error Creating Employee: Your Role Does Not Have Permission to Access This Record -## How to fix it +## Why does this happen? +The **NetSuite role** used for the Expensify connection **does not have permission** to create or access employees. -1. If permissions are configured correctly, confirm the report submitter exists in the subsidiary set for the workspace connection and that their Expensify email address matches the email on the NetSuite Employee Record. -2. If the above is true, try toggling off _Auto create employees/vendors_ under the **Settings > Workspaces > Group > click workspace name > Accounting > Advanced tab of the NetSuite configuration window. -3. Sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) -4. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. +## How to Fix It +1. **Verify permissions in NetSuite**: + - Follow the [NetSuite Setup Guide](https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite) to configure the correct permissions. +2. **Ensure the report submitter exists in NetSuite**: + - The email in NetSuite should match the **report submitter’s email in Expensify**. + - The employee must belong to the **correct subsidiary**. +3. **Disable automatic employee creation (if needed)**: + - In Expensify, go to **Settings > Workspaces > Accounting > Advanced**. + - Toggle **Auto-create employees/vendors** **off**. +4. **Sync NetSuite in Expensify** and **retry the export**. -# Elimination Settings for X Do Not Match +--- -**Why does this happen?** +# ExpensiError: Elimination Settings for X Do Not Match -This error occurs when an Intercompany Payable account is set as the default in the Default Payable Account field in the NetSuite subsidiary preferences, and the Accounting Approval option is enabled for Expense Reports. +## Why does this happen? +This occurs when an **Intercompany Payable account** is set as the default **Payable Account** in NetSuite **subsidiary preferences** while **Accounting Approval** is enabled for Expense Reports. -## How to fix it +## How to Fix It +1. **Edit the Default Payable Account for Expense Reports**: + - In NetSuite, go to **Setup > Company > Subsidiaries**. + - Click **Edit** next to the affected subsidiary. + - Go to the **Preferences** tab. + - Select a **valid payable account** for **Default Payable Account for Expense Reports**. +2. **Repeat this for all subsidiaries** to ensure consistency. +3. **Sync NetSuite in Expensify** and **retry the export**. -Set the Default Payable Account for Expense Reports on each subsidiary in NetSuite to ensure the correct payable account is active. +--- -1. Navigate to Subsidiaries: - - Go to Setup > Company > Subsidiaries. -2. Edit Subsidiary Preferences: - - Click Edit for the desired subsidiary. - - Go to the Preferences tab. -3. Set Default Payable Account: - - Choose the preferred account for Default Payable Account for Expense Reports. +# FAQ -Repeat these steps for each subsidiary to ensure the settings are correct, and then sync the NetSuite connection in Expensify (**Settings > Workspaces > click workspace name > Accounting > three-dot menu > Sync Now**.) +## Why Are Reports Exporting as _Accounting Approved_ Instead of _Paid in Full_? -# ExpensiError NS0046: Billable Expenses Not Coded with a NetSuite Customer or Billable Project +This happens due to: +- **Missing Locations, Classes, or Departments in the Bill Payment Form** +- **Incorrect Expensify Workspace Settings** -**Why does this happen?** +## How to fix for Missing Locations, Classes, or Departments -NetSuite requires billable expenses to be assigned to a Customer or a Project that is configured as billable to a Customer. If this is not set up correctly in NetSuite, this error can occur. +If your accounting classifications require locations, classes, or departments but they are not set to "Show" in your bill payment form, update them in NetSuite: -## How to fix it +- Go to **Customization > Forms > Transaction Forms**. +- Find the **preferred Bill Payment form** (checkmarked). +- Click **Edit or Customize**. +- Under **Screen Fields > Main**, enable "Show" for **Department, Class, and Location**. -1. Check the billable expenses and confirm that a Customer or Project tag is selected. -2. Make any necessary adjustments to the billable expense. -3. Attempt the export again by clicking on Search, then clicking the Approved (company card expenses) or Paid (reimbursable expenses) filter. -Click on the report in question and it will open in the right-hand panel. -Click on Export to NetSuite to try to export again. +## How to fix for Incorrect Expensify Workspace Settings: +Check your NetSuite connection settings in Expensify: -{% include faq-begin.md %} -## Why are reports exporting as _Accounting Approved_ instead of _Paid in Full_? +- Go to **Settings > Workspaces > [Select Workspace] > Accounting > Advanced**. +- Ensure: + - **Sync Reimbursed Reports** is enabled with a payment account selected. + - **Journal Entry Approval Level** is set to **Approved for Posting**. + - **A/P Approval Account** matches the account used for bill payments. -**This can occur for two reasons:** -- Missing Locations, Classes, or Departments in the Bill Payment Form -- Incorrect Settings in Expensify Workspace Configuration +**To verify the A/P Approval Account:** +- Open the **bill or expense report** causing the issue. +- Click **Make Payment**. +- Ensure the account matches what is set in Expensify. -**Missing Locations, Classes, or Departments in Bill Payment Form:** If locations, classes, or departments are required in your accounting classifications but are not marked as 'Show' on the preferred bill payment form, this error can occur, and you will need to update the bill payment form in NetSuite: +Lastly, confirm that the **A/P Approval Account** is selected on the **Expense Report List**. -1. Go to Customization > Forms > Transaction Forms. -2. Find your preferred (checkmarked) Bill Payment form. -3. Click Edit or Customize. -4. Under the Screen Fields > Main tab, check 'Show' near the department, class, and location options. +--- -**Incorrect Settings in Expensify Workspace Configuration:** To fix this, you'll want to confirm the NetSuite connection settings are set up correctly in Expensify: +## Why Are Reports Exporting as _Pending Approval_? -1. Head to **Settings > Workspaces > click workspace name > Accounting > Advanced.** -2. **Ensure the following settings are correct:** - - Sync Reimbursed Reports: Enabled and payment account chosen. - - Journal Entry Approval Level: Approved for Posting. - - A/P Approval Account: This must match the current account being used for bill payment. -3. **Verify A/P Approval Account:** - - To ensure the A/P Approval Account matches the account in NetSuite: - - Go to your bill/expense report causing the error. - - Click Make Payment. - - This account needs to match the account selected in your Expensify configuration. -4. **Check Expense Report List:** - - Make sure this is also the account selected on the expense report by looking at the expense report list. +If reports are marked **"Pending Approval"** instead of **"Approved"**, adjust NetSuite approval settings. -Following these steps will help ensure that reports are exported as "Paid in Full" instead of "Accounting Approved." +**For Journal Entries/Vendor Bills:** +- Go to **Setup > Accounting > Accounting Preferences** in NetSuite. +- Under the **General** tab, uncheck **Require Approvals on Journal Entries**. +- Under the **Approval Routing** tab, disable approval for **Journal Entries/Vendor Bills**. -## Why are reports exporting as _Pending Approval_? -If reports are exporting as "Pending Approval" instead of "Approved," you'll need to adjust the approval preferences in NetSuite. +**Note:** This applies to all Journal Entries, not just Expensify reports. -**Exporting as Journal Entries/Vendor Bills:** -1. In NetSuite, go to Setup > Accounting > Accounting Preferences. -2. On the **General** tab, uncheck **Require Approvals on Journal Entries**. -3. On the **Approval Routing** tab, uncheck Journal Entries/Vendor Bills to remove the approval requirement for Journal Entries created in NetSuite. +**For Expense Reports:** +- Go to **Setup > Company > Enable Features**. +- Under the **Employee** tab, uncheck **Approval Routing** to remove approval for Expense Reports. -**Note:** This change affects all Journal Entries, not just those created by Expensify. +**Note:** This also affects purchase orders. -**Exporting as Expense Reports:** -1. In NetSuite, navigate to Setup > Company > Enable Features. -2. On the "Employee" tab, uncheck "Approval Routing" to remove the approval requirement for Expense Reports created in NetSuite. Please note that this setting also applies to purchase orders. +--- -## How do I Change the Default Payable Account for Reimbursable Expenses in NetSuite? +## How to Change the Default Payable Account for Reimbursable Expenses in NetSuite -NetSuite is set up with a default payable account that is credited each time reimbursable expenses are exported as Expense Reports to NetSuite (once approved by the supervisor and accounting). If you need to change this to credit a different account, follow the below steps: +When exporting reimbursable expenses, NetSuite uses a default payable account. To change this: **For OneWorld Accounts:** -1. Navigate to Setup > Company > Subsidiaries in NetSuite. -2. Next to the subsidiary you want to update, click Edit. -3. Click the Preferences tab. -4. In the Default Payable Account for Expense Reports field, select the desired payable account. -5. Click Save. +- Go to **Setup > Company > Subsidiaries**. +- Click **Edit** next to the subsidiary. +- Under **Preferences**, update the **Default Payable Account for Expense Reports**. +- Click **Save**. **For Non-OneWorld Accounts:** -1. Navigate to Setup > Accounting > Accounting Preferences in NetSuite. -2. Click the Time & Expenses tab. -3. Under the Expenses section, locate the Default Payable Account for Expense Reports field and choose the preferred account. -4. Click Save. +- Go to **Setup > Accounting > Accounting Preferences**. +- Under the **Time & Expenses** tab, update the **Default Payable Account for Expense Reports**. +- Click **Save**. + -{% include faq-end.md %} diff --git a/docs/articles/new-expensify/connections/xero/Configure-Xero.md b/docs/articles/new-expensify/connections/xero/Configure-Xero.md index b417d6169a1e..79e9404c7c7e 100644 --- a/docs/articles/new-expensify/connections/xero/Configure-Xero.md +++ b/docs/articles/new-expensify/connections/xero/Configure-Xero.md @@ -2,8 +2,19 @@ title: Configure Xero description: How to configure your settings for Xero --- - -To configure your Xero settings, complete the steps below. + +# Best Practices Using Xero + +Using Expensify with Xero brings a seamless, efficient approach to managing expenses. With automatic syncing, expense reports flow directly into Xero, reducing manual entry and errors while giving real-time visibility into spending. This integration speeds up approvals, simplifies reimbursements, and provides clear insights for smarter budgeting and compliance. Together, Expensify and Xero make expense management faster, more accurate, and stress-free. + +# Accessing the Xero Configuration Settings + +Xero is connected at the workspace level, and each workspace can have a unique configuration that dictates how the connection functions. To access the connection settings: + +1. Click your profile image or icon in the bottom left menu. +2. Scroll down and click **Workspaces** in the left menu. +3. Select the workspace you want to access settings for. +4. Click **Accounting** in the left menu. # Step 1: Configure import settings diff --git a/docs/articles/new-expensify/expenses-&-payments/create-per-diem-expense.md b/docs/articles/new-expensify/expenses-&-payments/create-per-diem-expense.md new file mode 100644 index 000000000000..2a0677969417 --- /dev/null +++ b/docs/articles/new-expensify/expenses-&-payments/create-per-diem-expense.md @@ -0,0 +1,91 @@ +--- +title: Configure and submit Per Diem expenses +description: Learn how to create and submit a Per Diem expense, including selecting a workspace, destination, time details, and sub-rates. +--- +
+ +# Configuring Per Diem in a workspace + +Per Diem is available as a feature under the **Spend** section within **More Features** in the workspace settings. Once enabled, it will appear as a dedicated menu item in the workspace settings LHN. + +## Uploading and exporting Per Diem rates + +Admins can manage Per Diem rates by uploading or exporting data. + +- To upload rates, use the **Import spreadsheet** option. +- To download existing rates, use the **Download CSV** option. + +Both options are accessible from the **three-dot menu** in the page header. + +## Editing or deleting Per Diem rates + +Each Per Diem rate is listed as an individual line item. Admins can: + +- Select a **single** rate or **multiple** rates. +- Edit a rate by clicking on it and adjusting the details. +- Delete rates using the **"X selected" drop-down menu**. + +## Setting the default Per Diem category + +Admins can assign a default category to Per Diem expenses: + +1. Click the **Settings** button in the top-right corner. +2. In the right-hand panel, select **Default category**. +3. Choose from the available categories. + +# FAQ + +## Why don’t I see the Per Diem option when submitting an expense? +The Per Diem option is only available if you are a member of a workspace with Per Diem enabled. If you are submitting an expense outside a workspace (such as in a group chat or DM), the option will not appear. + +## Can I bulk-edit or delete Per Diem rates? +Yes, you can select multiple rates at once and apply bulk actions such as editing or deleting. + +# How to create a Per Diem expense + +If your workspace has **Per Diem** enabled, you can create a Per Diem expense directly from the **Submit Expense** flow. Follow the steps below to complete the process. + +## Submitting a Per Diem expense + +### 1. Open the expense submission flow +- Tap **Submit Expense** from the **Global Create** menu. +- If your workspace has **Per Diem** enabled, you’ll see the **Per Diem** option. + +> **Note:** If you're submitting an expense from a group chat or DM (outside a workspace), you won’t see the **Per Diem** option. + +### 2. Select a workspace +- If you are a member of multiple workspaces with Per Diem enabled, select the workspace you want to submit the expense under. +- If you only belong to one workspace with Per Diem enabled, this step will be skipped. + +### 3. Choose a destination +- Select the country or region where the Per Diem expense applies. + +### 4. Enter time details +- Set the **Start Date** and **Start Time**. +- Set the **End Date** and **End Time**. + +### 5. Select your Per Diem sub-rate +- Choose a **sub-rate** (e.g., Full day, Breakfast, Dinner). +- Enter the **quantity** (e.g., the number of days or meals covered). + +### 6. Review and adjust details +- You can go back and update any previous selections. +- Add more **sub-rates** if needed (e.g., if your trip includes multiple types of expenses). +- Optionally, add a **category, tag, or description** to your expense. + +### 7. Submit the Per Diem expense +- Once everything is correct, tap **Submit Expense**. + +![Open the expense submission flow, and follow the prompts to submit a Per Diem expense]({{site.url}}/assets/images/perdiem_05.png){:width="100%"} + +## Quick access to Per Diem expenses + +If you create Per Diem expenses frequently, you can add them to the **Quick Action Button (QAB)** for faster access. The QAB helps surface your most common actions, making it easy to submit Per Diem expenses with fewer taps. + +# FAQ + +## Why don’t I see the Per Diem option when submitting an expense? +The **Per Diem** option only appears if you are a member of a workspace with **Per Diem enabled**. If you're submitting an expense outside of a workspace (such as in a group chat or DM), this option won’t be available. + +## Can I create Per Diem expenses for multiple days? +Yes! When selecting your **Start Date** and **End Date**, you can add multiple **sub-rates** to cover different parts of your trip (e.g., Full day, Breakfast, Dinner). diff --git a/docs/expensify-classic/hubs/travel/index.html b/docs/expensify-classic/hubs/travel/index.html deleted file mode 100644 index 7c8c3d363d5e..000000000000 --- a/docs/expensify-classic/hubs/travel/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Travel ---- - -{% include hub.html %} diff --git a/docs/redirects.csv b/docs/redirects.csv index f4970373fb7a..28e76bf0ba9a 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -636,4 +636,9 @@ https://help.expensify.com/articles/new-expensify/settings/Switch-to-light-or-da https://help.expensify.com/articles/new-expensify/settings/Add-personal-information,https://help.expensify.com/articles/new-expensify/settings/Manage-Profile-and-Account-Preferences https://help.expensify.com/articles/new-expensify/settings/Update-your-name,https://help.expensify.com/articles/new-expensify/settings/Manage-Profile-and-Account-Preferences https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Assign-Company-Cards,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards -https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Configure-Company-Card-Settings,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards \ No newline at end of file +https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Configure-Company-Card-Settings,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards +https://help.expensify.com/articles/expensify-classic/travel/Approve-travel-expenses.md,https://help.expensify.com/articles/new-expensify/travel/Approve-travel-expenses +https://help.expensify.com/articles/expensify-classic/travel/Book-with-Expensify-Travel.md,https://help.expensify.com/articles/new-expensify/travel/Book-with-Expensify-Travel +https://help.expensify.com/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md,https://help.expensify.com/articles/new-expensify/travel/Configure-travel-policy-and-preferences +https://help.expensify.com/articles/expensify-classic/travel/Track-Travel-Analytics.md,https://help.expensify.com/articles/new-expensify/travel/Track-Travel-Analytics +https://help.expensify.com/articles/expensify-classic/connections/ADP,https://help.expensify.com/articles/expensify-classic/connections/Connect-to-ADP diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 39ce30e2ad64..edf347a21d5c 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.98 + 9.0.99 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.0.98.0 + 9.0.99.1 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8b582bea0735..555e7fca003e 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.98 + 9.0.99 CFBundleSignature ???? CFBundleVersion - 9.0.98.0 + 9.0.99.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 90b593948766..b4c4c9c3095c 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.98 + 9.0.99 CFBundleVersion - 9.0.98.0 + 9.0.99.1 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index b0d2af709954..9cb9b1ed5dee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.98-0", + "version": "9.0.99-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.98-0", + "version": "9.0.99-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -76,7 +76,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "^1.0.22", + "react-fast-pdf": "^1.0.26", "react-map-gl": "^7.1.3", "react-native": "0.76.3", "react-native-advanced-input-mask": "1.2.1", @@ -6968,8 +6968,10 @@ }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.10", + "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", @@ -6987,8 +6989,10 @@ }, "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": { "version": "3.1.0", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "semver": "^6.0.0" }, @@ -7001,16 +7005,20 @@ }, "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir/node_modules/semver": { "version": "6.3.1", + "dev": true, "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" } }, "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { "version": "5.0.0", + "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "abbrev": "1" }, @@ -14950,7 +14958,7 @@ }, "node_modules/abbrev": { "version": "1.1.1", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/abort-controller": { @@ -15035,7 +15043,7 @@ }, "node_modules/agent-base": { "version": "6.0.2", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -15411,7 +15419,7 @@ }, "node_modules/aproba": { "version": "1.2.0", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/archiver": { @@ -15477,8 +15485,10 @@ }, "node_modules/are-we-there-yet": { "version": "2.0.0", + "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" @@ -15489,8 +15499,10 @@ }, "node_modules/are-we-there-yet/node_modules/readable-stream": { "version": "3.6.2", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -17304,9 +17316,11 @@ }, "node_modules/canvas": { "version": "2.11.2", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@mapbox/node-pre-gyp": "^1.0.0", "nan": "^2.17.0", @@ -17632,7 +17646,9 @@ } }, "node_modules/clsx": { - "version": "2.0.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -17693,7 +17709,7 @@ }, "node_modules/color-support": { "version": "1.1.3", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "color-support": "bin.js" @@ -18140,7 +18156,7 @@ }, "node_modules/console-control-strings": { "version": "1.1.0", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/constants-browserify": { @@ -18884,7 +18900,7 @@ }, "node_modules/decompress-response": { "version": "6.0.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -18898,7 +18914,7 @@ }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -19146,7 +19162,7 @@ }, "node_modules/delegates": { "version": "1.0.0", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/denodeify": { @@ -21544,6 +21560,16 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "dev": true, @@ -22758,9 +22784,8 @@ }, "node_modules/fs-constants": { "version": "1.0.0", - "dev": true, - "license": "MIT", - "peer": true + "devOptional": true, + "license": "MIT" }, "node_modules/fs-extra": { "version": "9.1.0", @@ -22841,8 +22866,10 @@ }, "node_modules/gauge": { "version": "3.0.2", + "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", @@ -23082,6 +23109,13 @@ "giget": "dist/cli.mjs" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/github-slugger": { "version": "2.0.0", "dev": true, @@ -23438,7 +23472,7 @@ }, "node_modules/has-unicode": { "version": "2.0.1", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/hasown": { @@ -23877,7 +23911,7 @@ }, "node_modules/https-proxy-agent": { "version": "5.0.1", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -28203,6 +28237,8 @@ }, "node_modules/make-cancellable-promise": { "version": "1.3.2", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.3.2.tgz", + "integrity": "sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww==", "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" @@ -28232,7 +28268,9 @@ "license": "ISC" }, "node_modules/make-event-props": { - "version": "1.6.1", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.2.tgz", + "integrity": "sha512-iDwf7mA03WPiR8QxvcVHmVWEPfMY1RZXerDVNCRYW7dUr2ppH3J58Rwb39/WG39yTZdRSxr3x+2v22tvI0VEvA==", "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" @@ -28612,6 +28650,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.3.0.tgz", "integrity": "sha512-nqXPXbso+1dcKDpPCXvwZyJILz+vSLqGGOnDrYHQYE+B8n9JTCekVLC65AfCpR4ggVyA/45Y0iR9LDyS2iI+zA==", + "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" }, @@ -29490,6 +29529,13 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, "node_modules/mlly": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz", @@ -29546,8 +29592,10 @@ }, "node_modules/nan": { "version": "2.17.0", + "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/nanoid": { "version": "3.3.8", @@ -29567,6 +29615,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -29635,7 +29690,7 @@ }, "node_modules/node-abi": { "version": "3.65.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -29912,8 +29967,10 @@ }, "node_modules/npmlog": { "version": "5.0.1", + "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "are-we-there-yet": "^2.0.0", "console-control-strings": "^1.1.0", @@ -30862,9 +30919,10 @@ } }, "node_modules/path2d": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/path2d/-/path2d-0.2.1.tgz", - "integrity": "sha512-Fl2z/BHvkTNvkuBzYTpTuirHZg6wW9z8+4SND/3mDTEcYbbNKWAy21dz9D3ePNNwrrK8pqZO5vLPZ1hLF6T7XA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/path2d/-/path2d-0.2.2.tgz", + "integrity": "sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==", + "license": "MIT", "optional": true, "engines": { "node": ">=6" @@ -30889,17 +30947,40 @@ } }, "node_modules/pdfjs-dist": { - "version": "4.4.168", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.4.168.tgz", - "integrity": "sha512-MbkAjpwka/dMHaCfQ75RY1FXX3IewBVu6NGZOcxerRFlaBiIkZmUoR0jotX5VUzYZEXAGzSFtknWs5xRKliXPA==", + "version": "4.8.69", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.8.69.tgz", + "integrity": "sha512-IHZsA4T7YElCKNNXtiLgqScw4zPd3pG9do8UrznC757gMd7UPeHSL2qwNNMJo4r79fl8oj1Xx+1nh2YkzdMpLQ==", + "license": "Apache-2.0", "engines": { "node": ">=18" }, "optionalDependencies": { - "canvas": "^2.11.2", - "path2d": "^0.2.0" + "canvas": "^3.0.0-rc2", + "path2d": "^0.2.1" + } + }, + "node_modules/pdfjs-dist/node_modules/canvas": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.0.tgz", + "integrity": "sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" } }, + "node_modules/pdfjs-dist/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, "node_modules/pe-library": { "version": "0.4.0", "dev": true, @@ -31200,6 +31281,59 @@ "version": "2.0.0", "license": "ISC" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -31912,13 +32046,13 @@ } }, "node_modules/react-fast-pdf": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.22.tgz", - "integrity": "sha512-bU1YEHFfazKFSdmNAauD267GtjVHdcuE39jyHJQ8CRI8ZWWLwckZ8azPuE25i+hodCBmQuTNBdg6Gx4OhP8HOQ==", + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.26.tgz", + "integrity": "sha512-90ZzyfYtJYLLNV782kOrRRZt2C0M6p0DoCL80kIdhq5/63Y+6+/tzpF5aO0tmnA2G0uM6Pm+plwPsG0bWUjmJg==", "license": "MIT", "dependencies": { - "react-pdf": "^9.1.1", - "react-window": "^1.8.10" + "react-pdf": "9.2.0", + "react-window": "^1.8.11" }, "engines": { "node": ">=20.10.0", @@ -31926,7 +32060,7 @@ }, "peerDependencies": { "lodash": "4.x", - "pdfjs-dist": "4.x", + "pdfjs-dist": "4.8.69", "react": "18.x", "react-dom": "18.x" } @@ -32851,16 +32985,17 @@ } }, "node_modules/react-pdf": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.1.tgz", - "integrity": "sha512-Cn3RTJZMqVOOCgLMRXDamLk4LPGfyB2Np3OwQAUjmHIh47EpuGW1OpAA1Z1GVDLoHx4d5duEDo/YbUkDbr4QFQ==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.2.0.tgz", + "integrity": "sha512-FILVJWfzaBKmF+MSppBnhqTC+HEgbDIpaycBaVkCZfLl2CUeMOd5r0kFYivKSGWR5g2l74dYsBB+xMPx0C0eTw==", + "license": "MIT", "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^1.3.1", "make-event-props": "^1.6.0", "merge-refs": "^1.3.0", - "pdfjs-dist": "4.4.168", + "pdfjs-dist": "4.8.69", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, @@ -33075,7 +33210,9 @@ } }, "node_modules/react-window": { - "version": "1.8.10", + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.0.0", @@ -33085,8 +33222,8 @@ "node": ">8.0.0" }, "peerDependencies": { - "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/read-binary-file-arch": { @@ -34059,7 +34196,7 @@ }, "node_modules/set-blocking": { "version": "2.0.0", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/set-function-length": { @@ -34232,8 +34369,10 @@ }, "node_modules/simple-get": { "version": "3.1.1", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "decompress-response": "^4.2.0", "once": "^1.3.1", @@ -34242,8 +34381,10 @@ }, "node_modules/simple-get/node_modules/decompress-response": { "version": "4.2.1", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mimic-response": "^2.0.0" }, @@ -34253,8 +34394,10 @@ }, "node_modules/simple-get/node_modules/mimic-response": { "version": "2.1.0", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8" }, @@ -35392,11 +35535,30 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, "node_modules/tar-stream": { "version": "2.2.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -35410,9 +35572,8 @@ }, "node_modules/tar-stream/node_modules/readable-stream": { "version": "3.6.2", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -36124,6 +36285,19 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "license": "Unlicense" @@ -36819,6 +36993,8 @@ }, "node_modules/warning": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" @@ -37473,7 +37649,7 @@ }, "node_modules/wide-align": { "version": "1.1.5", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" diff --git a/package.json b/package.json index 12b027c1b9a9..4b1f7c936bc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.98-0", + "version": "9.0.99-1", "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.", @@ -143,7 +143,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "^1.0.22", + "react-fast-pdf": "^1.0.26", "react-map-gl": "^7.1.3", "react-native": "0.76.3", "react-native-advanced-input-mask": "1.2.1", diff --git a/src/CONST.ts b/src/CONST.ts index 55f0dafd8517..5e4fbb4adacf 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -738,10 +738,10 @@ const CONST = { DEFAULT_ROOMS: 'defaultRooms', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', SPOTNANA_TRAVEL: 'spotnanaTravel', + PREVENT_SPOTNANA_TRAVEL: 'preventSpotnanaTravel', REPORT_FIELDS_FEATURE: 'reportFieldsFeature', NETSUITE_USA_TAX: 'netsuiteUsaTax', COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit', - CATEGORY_AND_TAG_APPROVERS: 'categoryAndTagApprovers', PER_DIEM: 'newDotPerDiem', NEWDOT_MERGE_ACCOUNTS: 'newDotMergeAccounts', NEWDOT_MANAGER_MCTEST: 'newDotManagerMcTest', @@ -1142,6 +1142,7 @@ const CONST = { REIMBURSEMENT_SETUP_REQUESTED: 'REIMBURSEMENTSETUPREQUESTED', // Deprecated OldDot Action REJECTED: 'REJECTED', REMOVED_FROM_APPROVAL_CHAIN: 'REMOVEDFROMAPPROVALCHAIN', + DEMOTED_FROM_WORKSPACE: 'DEMOTEDFROMWORKSPACE', RENAMED: 'RENAMED', REPORT_PREVIEW: 'REPORTPREVIEW', SELECTED_FOR_RANDOM_AUDIT: 'SELECTEDFORRANDOMAUDIT', // OldDot Action @@ -2612,6 +2613,14 @@ const CONST = { SMARTREPORT: 'SMARTREPORT', BILLCOM: 'BILLCOM', }, + APPROVAL_MODE_TRANSLATION_KEYS: { + OPTIONAL: 'submitAndClose', + BASIC: 'submitAndApprove', + ADVANCED: 'advanced', + DYNAMICEXTERNAL: 'dynamictExternal', + SMARTREPORT: 'smartReport', + BILLCOM: 'billcom', + }, ROOM_PREFIX: '#', CUSTOM_UNIT_RATE_BASE_OFFSET: 100, OWNER_EMAIL_FAKE: '_FAKE_', @@ -2688,6 +2697,10 @@ const CONST = { AUTOREPORTING_OFFSET: 'autoReportingOffset', GENERAL_SETTINGS: 'generalSettings', }, + EXPENSE_REPORT_RULES: { + PREVENT_SELF_APPROVAL: 'preventSelfApproval', + MAX_EXPENSE_AGE: 'maxExpenseAge', + }, CONNECTIONS: { NAME: { // Here we will add other connections names when we add support for them @@ -6159,6 +6172,7 @@ const CONST = { DRAFTS: 'drafts', OUTSTANDING: 'outstanding', APPROVED: 'approved', + DONE: 'done', PAID: 'paid', }, INVOICE: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index dce55b29b876..a66f00a87dd6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -672,8 +672,6 @@ const ONYXKEYS = { REPORT_PHYSICAL_CARD_FORM_DRAFT: 'requestPhysicalCardFormDraft', REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm', REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', - GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', - GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', REPORT_FIELDS_EDIT_FORM: 'reportFieldsEditForm', REPORT_FIELDS_EDIT_FORM_DRAFT: 'reportFieldsEditFormDraft', REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', @@ -815,7 +813,6 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: FormTypes.IntroSchoolPrincipalForm; [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.ReportVirtualCardFraudForm; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.ReportPhysicalCardForm; - [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm; [ONYXKEYS.FORMS.REPORT_FIELDS_EDIT_FORM]: FormTypes.ReportFieldsEditForm; [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm; [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM]: FormTypes.PersonalBankAccountForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b98717b51f5d..bbba90d53d28 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -133,7 +133,7 @@ const ROUTES = { SETTINGS_TIMEZONE_SELECT: 'settings/profile/timezone/select', SETTINGS_PRONOUNS: 'settings/profile/pronouns', SETTINGS_PREFERENCES: 'settings/preferences', - SETTINGS_SUBSCRIPTION: 'settings/subscription', + SETTINGS_SUBSCRIPTION: {route: 'settings/subscription', getRoute: (backTo?: string) => getUrlWithBackToParam('settings/subscription', backTo)}, SETTINGS_SUBSCRIPTION_SIZE: { route: 'settings/subscription/subscription-size', getRoute: (canChangeSize: 0 | 1) => `settings/subscription/subscription-size?canChangeSize=${canChangeSize as number}` as const, @@ -193,22 +193,6 @@ const ROUTES = { route: 'settings/card/:cardID/report-virtual-fraud', getRoute: (cardID: string) => `settings/card/${cardID}/report-virtual-fraud` as const, }, - SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { - route: 'settings/wallet/card/:domain/get-physical/name', - getRoute: (domain: string, backTo?: string) => getUrlWithBackToParam(`settings/wallet/card/${domain}/get-physical/name`, backTo), - }, - SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { - route: 'settings/wallet/card/:domain/get-physical/phone', - getRoute: (domain: string, backTo?: string) => getUrlWithBackToParam(`settings/wallet/card/${domain}/get-physical/phone`, backTo), - }, - SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { - route: 'settings/wallet/card/:domain/get-physical/address', - getRoute: (domain: string, backTo?: string) => getUrlWithBackToParam(`settings/wallet/card/${domain}/get-physical/address`, backTo), - }, - SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { - route: 'settings/wallet/card/:domain/get-physical/confirm', - getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/confirm` as const, - }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', SETTINGS_ADD_US_BANK_ACCOUNT: 'settings/wallet/add-us-bank-account', @@ -1450,27 +1434,27 @@ const ROUTES = { }, WORKSPACE_EXPENSIFY_CARD_NAME: { route: 'settings/workspaces/:policyID/expensify-card/:cardID/edit/name', - getRoute: (policyID: string, cardID: string) => `settings/workspaces/${policyID}/expensify-card/${cardID}/edit/name` as const, + getRoute: (policyID: string, cardID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/expensify-card/${cardID}/edit/name`, backTo), }, EXPENSIFY_CARD_NAME: { route: 'settings/:policyID/expensify-card/:cardID/edit/name', - getRoute: (policyID: string, cardID: string) => `settings/${policyID}/expensify-card/${cardID}/edit/name` as const, + getRoute: (policyID: string, cardID: string, backTo?: string) => getUrlWithBackToParam(`settings/${policyID}/expensify-card/${cardID}/edit/name`, backTo), }, WORKSPACE_EXPENSIFY_CARD_LIMIT: { route: 'settings/workspaces/:policyID/expensify-card/:cardID/edit/limit', - getRoute: (policyID: string, cardID: string) => `settings/workspaces/${policyID}/expensify-card/${cardID}/edit/limit` as const, + getRoute: (policyID: string, cardID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/expensify-card/${cardID}/edit/limit`, backTo), }, EXPENSIFY_CARD_LIMIT: { route: 'settings/:policyID/expensify-card/:cardID/edit/limit', - getRoute: (policyID: string, cardID: string) => `settings/${policyID}/expensify-card/${cardID}/edit/limit` as const, + getRoute: (policyID: string, cardID: string, backTo?: string) => getUrlWithBackToParam(`settings/${policyID}/expensify-card/${cardID}/edit/limit`, backTo), }, WORKSPACE_EXPENSIFY_CARD_LIMIT_TYPE: { route: 'settings/workspaces/:policyID/expensify-card/:cardID/edit/limit-type', - getRoute: (policyID: string, cardID: string) => `settings/workspaces/${policyID}/expensify-card/${cardID}/edit/limit-type` as const, + getRoute: (policyID: string, cardID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/expensify-card/${cardID}/edit/limit-type`, backTo), }, EXPENSIFY_CARD_LIMIT_TYPE: { route: 'settings/:policyID/expensify-card/:cardID/edit/limit-type', - getRoute: (policyID: string, cardID: string) => `settings/${policyID}/expensify-card/${cardID}/edit/limit-type` as const, + getRoute: (policyID: string, cardID: string, backTo?: string) => getUrlWithBackToParam(`settings/${policyID}/expensify-card/${cardID}/edit/limit-type`, backTo), }, WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: { route: 'settings/workspaces/:policyID/expensify-card/issue-new', @@ -1743,7 +1727,12 @@ const ROUTES = { }, POLICY_ACCOUNTING_XERO_ORGANIZATION: { route: 'settings/workspaces/:policyID/accounting/xero/organization/:currentOrganizationID', - getRoute: (policyID: string, currentOrganizationID: string) => `settings/workspaces/${policyID}/accounting/xero/organization/${currentOrganizationID}` as const, + getRoute: (policyID: string | undefined, currentOrganizationID: string | undefined) => { + if (!policyID || !currentOrganizationID) { + Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_XERO_ORGANIZATION route'); + } + return `settings/workspaces/${policyID}/accounting/xero/organization/${currentOrganizationID}` as const; + }, }, POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES: { route: 'settings/workspaces/:policyID/accounting/xero/import/tracking-categories', @@ -1837,7 +1826,12 @@ const ROUTES = { MISSING_PERSONAL_DETAILS: 'missing-personal-details', POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: { route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const, + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR route'); + } + return `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const; + }, }, POLICY_ACCOUNTING_NETSUITE_EXISTING_CONNECTIONS: { route: 'settings/workspaces/:policyID/accounting/netsuite/existing-connections', @@ -2043,7 +2037,12 @@ const ROUTES = { }, POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/entity', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/entity` as const, + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY route'); + } + return `settings/workspaces/${policyID}/accounting/sage-intacct/entity` as const; + }, }, POLICY_ACCOUNTING_SAGE_INTACCT_IMPORT: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/import', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 19fcf8b4a367..05f5a1c17c3b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -112,12 +112,6 @@ const SCREENS = { WALLET: { ROOT: 'Settings_Wallet', DOMAIN_CARD: 'Settings_Wallet_DomainCard', - CARD_GET_PHYSICAL: { - NAME: 'Settings_Card_Get_Physical_Name', - PHONE: 'Settings_Card_Get_Physical_Phone', - ADDRESS: 'Settings_Card_Get_Physical_Address', - CONFIRM: 'Settings_Card_Get_Physical_Confirm', - }, TRANSFER_BALANCE: 'Settings_Wallet_Transfer_Balance', CHOOSE_TRANSFER_ACCOUNT: 'Settings_Wallet_Choose_Transfer_Account', ENABLE_PAYMENTS: 'Settings_Wallet_EnablePayments', diff --git a/src/components/Accordion/index.tsx b/src/components/Accordion/index.tsx index 9715f3902c03..d70b8db835dd 100644 --- a/src/components/Accordion/index.tsx +++ b/src/components/Accordion/index.tsx @@ -24,6 +24,7 @@ type AccordionProps = { function Accordion({isExpanded, children, duration = 300, isToggleTriggered, style}: AccordionProps) { const height = useSharedValue(0); + const isAnimating = useSharedValue(false); const derivedHeight = useDerivedValue(() => { if (!isToggleTriggered.get()) { @@ -41,10 +42,20 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty return isExpanded.get() ? 1 : 0; } - return withTiming(isExpanded.get() ? 1 : 0, { - duration, - easing: Easing.inOut(Easing.quad), - }); + isAnimating.set(true); + return withTiming( + isExpanded.get() ? 1 : 0, + { + duration, + easing: Easing.inOut(Easing.quad), + }, + (finished) => { + if (!finished || !isExpanded.get()) { + return; + } + isAnimating.set(false); + }, + ); }); const animatedStyle = useAnimatedStyle(() => { @@ -52,12 +63,16 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty return { height: 0, opacity: 0, + display: 'none', }; } return { height: !isToggleTriggered.get() ? undefined : derivedHeight.get(), + maxHeight: !isToggleTriggered.get() ? undefined : derivedHeight.get(), opacity: derivedOpacity.get(), + overflow: isAnimating.get() ? 'hidden' : 'visible', + display: isExpanded.get() ? 'inline' : 'none', }; }); diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 4470481d2be6..ffeea4ab3a02 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -2,8 +2,8 @@ import React, {useCallback} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as ValidationUtils from '@libs/ValidationUtils'; +import {addErrorMessage} from '@libs/ErrorUtils'; +import {isRequiredFulfilled} from '@libs/ValidationUtils'; import type {Country} from '@src/CONST'; import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; @@ -46,7 +46,7 @@ type AddressFormProps = { onAddressChanged?: (value: unknown, key: unknown) => void; /** Callback which is executed when the user submits his address changes */ - onSubmit: (values: FormOnyxValues) => void; + onSubmit: (values: FormOnyxValues) => void; /** Whether or not should the form data should be saved as draft */ shouldSaveDraft?: boolean; @@ -55,7 +55,7 @@ type AddressFormProps = { submitButtonText?: string; /** A unique Onyx key identifying the form */ - formID: typeof ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM | typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM; + formID: typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM; }; function AddressForm({ @@ -88,7 +88,7 @@ function AddressForm({ */ const validator = useCallback( - (values: FormOnyxValues): Errors => { + (values: FormOnyxValues): Errors => { const errors: Errors & { zipPostCode?: string | string[]; } = {}; @@ -102,7 +102,7 @@ function AddressForm({ // Add "Field required" errors if any required field is empty requiredFields.forEach((fieldKey) => { const fieldValue = values[fieldKey] ?? ''; - if (ValidationUtils.isRequiredFulfilled(fieldValue)) { + if (isRequiredFulfilled(fieldValue)) { return; } @@ -116,11 +116,11 @@ function AddressForm({ const countrySpecificZipRegex = countryRegexDetails?.regex; const countryZipFormat = countryRegexDetails?.samples ?? ''; - ErrorUtils.addErrorMessage(errors, 'firstName', translate('bankAccount.error.firstName')); + addErrorMessage(errors, 'firstName', translate('bankAccount.error.firstName')); if (countrySpecificZipRegex) { if (!countrySpecificZipRegex.test(values.zipPostCode?.trim().toUpperCase())) { - if (ValidationUtils.isRequiredFulfilled(values.zipPostCode?.trim())) { + if (isRequiredFulfilled(values.zipPostCode?.trim())) { errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}); } else { errors.zipPostCode = translate('common.error.fieldRequired'); diff --git a/src/components/BookTravelButton.tsx b/src/components/BookTravelButton.tsx index edffb8315808..846cf23e4c8a 100644 --- a/src/components/BookTravelButton.tsx +++ b/src/components/BookTravelButton.tsx @@ -7,6 +7,7 @@ import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {openTravelDotLink} from '@libs/actions/Link'; import {cleanupTravelProvisioningSession} from '@libs/actions/Travel'; +import DateUtils from '@libs/DateUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getAdminsPrivateEmailDomains, isPaidGroupPolicy} from '@libs/PolicyUtils'; @@ -15,6 +16,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Button from './Button'; +import ConfirmModal from './ConfirmModal'; import CustomStatusBarAndBackgroundContext from './CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext'; import DotIndicatorMessage from './DotIndicatorMessage'; @@ -28,6 +30,10 @@ const navigateToAcceptTerms = (domain: string) => { Navigation.navigate(ROUTES.TRAVEL_TCS.getRoute(domain)); }; +// Spotnana has scheduled maintenance from February 23 at 7 AM EST (12 PM UTC) to February 24 at 12 PM EST (5 PM UTC). +const SPOTNANA_BLACKOUT_PERIOD_START = '2025-02-23T11:59:00Z'; +const SPOTNANA_BLACKOUT_PERIOD_END = '2025-02-24T17:01:00Z'; + function BookTravelButton({text}: BookTravelButtonProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -38,14 +44,22 @@ function BookTravelButton({text}: BookTravelButtonProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const primaryLogin = account?.primaryLogin; const {setRootStatusBarEnabled} = useContext(CustomStatusBarAndBackgroundContext); + const [isMaintenanceModalVisible, setMaintenanceModalVisibility] = useState(false); // Flag indicating whether NewDot was launched exclusively for Travel, // e.g., when the user selects "Trips" from the Expensify Classic menu in HybridApp. const [wasNewDotLaunchedJustForTravel] = useOnyx(ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY); + const hideMaintenanceModal = () => setMaintenanceModalVisibility(false); + const bookATrip = useCallback(() => { setErrorMessage(''); + if (DateUtils.isCurrentTimeWithinRange(SPOTNANA_BLACKOUT_PERIOD_START, SPOTNANA_BLACKOUT_PERIOD_END)) { + setMaintenanceModalVisibility(true); + return; + } + // The primary login of the user is where Spotnana sends the emails with booking confirmations, itinerary etc. It can't be a phone number. if (!primaryLogin || Str.isSMSLogin(primaryLogin)) { setErrorMessage(translate('travel.phoneError')); @@ -116,6 +130,15 @@ function BookTravelButton({text}: BookTravelButtonProps) { success large /> + ); } diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index d943886982e4..b98baab062ba 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -145,6 +145,9 @@ type ButtonProps = Partial & { /** Whether the Enter keyboard listening is active whether or not the screen that contains the button is focused */ isPressOnEnterActive?: boolean; + + /** The text displays under the first line */ + secondLineText?: string; }; type KeyboardShortcutComponentProps = Pick; @@ -246,6 +249,7 @@ function Button( link = false, isContentCentered = false, isPressOnEnterActive, + secondLineText = '', ...rest }: ButtonProps, ref: ForwardedRef, @@ -260,7 +264,7 @@ function Button( return rest.children; } - const textComponent = ( + const primaryText = ( ); + const textComponent = secondLineText ? ( + + {primaryText} + {secondLineText} + + ) : ( + primaryText + ); + const defaultFill = success || danger ? theme.textLight : theme.icon; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index dce7373c54bb..e7fbf9baab1c 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -45,6 +45,7 @@ function ButtonWithDropdownMenu({ defaultSelectedIndex = 0, shouldShowSelectedItemCheck = false, testID, + secondLineText = '', }: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -144,6 +145,7 @@ function ButtonWithDropdownMenu({ shouldShowRightIcon={!isSplitButton} isSplitButton={isSplitButton} testID={testID} + secondLineText={secondLineText} /> {isSplitButton && ( @@ -193,6 +195,7 @@ function ButtonWithDropdownMenu({ small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL} innerStyles={[innerStyleDropButton]} enterKeyEventListenerPriority={enterKeyEventListenerPriority} + secondLineText={secondLineText} /> )} {(shouldAlwaysShowDropdownMenu || options.length > 1) && !!popoverAnchorPosition && ( diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index dbafbc497105..dd7782bb39ba 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -111,6 +111,9 @@ type ButtonWithDropdownMenuProps = { /** Used to locate the component in the tests */ testID?: string; + + /** The second line text displays under the first line */ + secondLineText?: string; }; export type { diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index b71c9e2402c5..712b03bf4590 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -49,6 +49,7 @@ import ChatBubbleUnread from '@assets/images/chatbubble-unread.svg'; import ChatBubble from '@assets/images/chatbubble.svg'; import ChatBubbles from '@assets/images/chatbubbles.svg'; import CheckCircle from '@assets/images/check-circle.svg'; +import Checkbox from '@assets/images/checkbox.svg'; import CheckmarkCircle from '@assets/images/checkmark-circle.svg'; import Checkmark from '@assets/images/checkmark.svg'; import CircularArrowBackwards from '@assets/images/circular-arrow-backwards.svg'; @@ -249,6 +250,7 @@ export { Cash, ChatBubble, ChatBubbles, + Checkbox, Checkmark, Chair, Close, diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index ac202c1dc6a0..3e1a66524274 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -228,18 +228,17 @@ const MapView = forwardRef( const initBounds = useMemo(() => (interactive ? undefined : waypointsBounds), [interactive, waypointsBounds]); const distanceSymbolCoorinate = useMemo(() => { - const length = directionCoordinates?.length; - // If the array is empty, return undefined - if (!length) { - return undefined; + if (!directionCoordinates?.length || !waypoints?.length) { + return; } + const {northEast, southWest} = utils.getBounds( + waypoints.map((waypoint) => waypoint.coordinate), + directionCoordinates, + ); + const boundsCenter = utils.getBoundsCenter({northEast, southWest}); - // Find the index of the middle element - const middleIndex = Math.floor(length / 2); - - // Return the middle element - return directionCoordinates.at(middleIndex); - }, [directionCoordinates]); + return utils.findClosestCoordinateOnLineFromCenter(boundsCenter, directionCoordinates); + }, [waypoints, directionCoordinates]); return !isOffline && isAccessTokenSet && !!defaultSettings ? ( diff --git a/src/components/MapView/MapViewImpl.website.tsx b/src/components/MapView/MapViewImpl.website.tsx index 4fdf71252895..621e1d0ab2e2 100644 --- a/src/components/MapView/MapViewImpl.website.tsx +++ b/src/components/MapView/MapViewImpl.website.tsx @@ -250,18 +250,17 @@ const MapViewImpl = forwardRef( }, [waypoints, directionCoordinates, interactive, currentPosition, initialState.zoom]); const distanceSymbolCoorinate = useMemo(() => { - const length = directionCoordinates?.length; - // If the array is empty, return undefined - if (!length) { - return undefined; + if (!directionCoordinates?.length || !waypoints?.length) { + return; } + const {northEast, southWest} = utils.getBounds( + waypoints.map((waypoint) => waypoint.coordinate), + directionCoordinates, + ); + const boundsCenter = utils.getBoundsCenter({northEast, southWest}); - // Find the index of the middle element - const middleIndex = Math.floor(length / 2); - - // Return the middle element - return directionCoordinates.at(middleIndex); - }, [directionCoordinates]); + return utils.findClosestCoordinateOnLineFromCenter(boundsCenter, directionCoordinates); + }, [waypoints, directionCoordinates]); return !isOffline && !!accessToken && !!initialViewState ? ( ( )} {!!distanceSymbolCoorinate && !!distanceInMeters && !!distanceUnit && ( @@ -298,7 +297,6 @@ const MapViewImpl = forwardRef( accessibilityLabel={CONST.ROLE.BUTTON} role={CONST.ROLE.BUTTON} onPress={toggleDistanceUnit} - style={{marginRight: 100}} > {DistanceRequestUtils.getDistanceForDisplayLabel(distanceInMeters, distanceUnit)} diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts index 11e90a78fffc..a9054c6555e9 100644 --- a/src/components/MapView/MapViewTypes.ts +++ b/src/components/MapView/MapViewTypes.ts @@ -2,6 +2,8 @@ import type {ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {Unit} from '@src/types/onyx/Policy'; +type Coordinate = [number, number]; + type MapViewProps = { // Public access token to be used to fetch map data from Mapbox. accessToken: string; @@ -18,7 +20,7 @@ type MapViewProps = { // Locations on which to put markers waypoints?: WayPoint[]; // List of coordinates which together forms a direction. - directionCoordinates?: Array<[number, number]>; + directionCoordinates?: Coordinate[]; // Callback to call when the map is idle / ready. onMapReady?: () => void; // Whether the map is interactable or not @@ -33,7 +35,7 @@ type MapViewProps = { type DirectionProps = { // Coordinates of points that constitute the direction - coordinates: Array<[number, number]>; + coordinates: Coordinate[]; }; type PendingMapViewProps = { @@ -53,14 +55,14 @@ type PendingMapViewProps = { // Initial state of the map type InitialState = { // Coordinate on which to center the map - location: [number, number]; + location: Coordinate; zoom: number; }; // Waypoint to be displayed on the map type WayPoint = { id: string; - coordinate: [number, number]; + coordinate: Coordinate; markerComponent: () => ReactNode; }; @@ -73,9 +75,9 @@ type DirectionStyle = { // Represents a handle to interact with a map view. type MapViewHandle = { // Fly to a location on the map - flyTo: (location: [number, number], zoomLevel: number, animationDuration?: number) => void; + flyTo: (location: Coordinate, zoomLevel: number, animationDuration?: number) => void; // Fit the map view to a bounding box - fitBounds: (ne: [number, number], sw: [number, number], paddingConfig?: number | number[], animationDuration?: number) => void; + fitBounds: (ne: Coordinate, sw: Coordinate, paddingConfig?: number | number[], animationDuration?: number) => void; }; -export type {DirectionStyle, WayPoint, MapViewProps, DirectionProps, PendingMapViewProps, MapViewHandle}; +export type {DirectionStyle, WayPoint, MapViewProps, DirectionProps, PendingMapViewProps, MapViewHandle, Coordinate}; diff --git a/src/components/MapView/utils.ts b/src/components/MapView/utils.ts index ae35bdfdeba9..bd663af6f978 100644 --- a/src/components/MapView/utils.ts +++ b/src/components/MapView/utils.ts @@ -1,4 +1,7 @@ -function getBounds(waypoints: Array<[number, number]>, directionCoordinates: undefined | Array<[number, number]>): {southWest: [number, number]; northEast: [number, number]} { +import type {LngLat} from 'react-map-gl'; +import type {Coordinate} from './MapViewTypes'; + +function getBounds(waypoints: Coordinate[], directionCoordinates: undefined | Coordinate[]): {southWest: Coordinate; northEast: Coordinate} { const lngs = waypoints.map((waypoint) => waypoint[0]); const lats = waypoints.map((waypoint) => waypoint[1]); if (directionCoordinates) { @@ -15,7 +18,7 @@ function getBounds(waypoints: Array<[number, number]>, directionCoordinates: und /** * Calculates the distance between two points on the Earth's surface given their latitude and longitude coordinates. */ -function haversineDistance(coordinate1: number[], coordinate2: number[]) { +function haversineDistance(coordinate1: Coordinate, coordinate2: Coordinate) { // Radius of the Earth in meters const R = 6371e3; const lat1 = ((coordinate1.at(0) ?? 0) * Math.PI) / 180; @@ -33,11 +36,90 @@ function haversineDistance(coordinate1: number[], coordinate2: number[]) { return R * angularDistance; } -function areSameCoordinate(coordinate1: number[], coordinate2: number[]) { +function areSameCoordinate(coordinate1: Coordinate, coordinate2: Coordinate) { return haversineDistance(coordinate1, coordinate2) < 20; } +function findClosestCoordinateOnLineFromCenter(center: LngLat, lineCoordinates: Coordinate[]): Coordinate | null { + if (!lineCoordinates || lineCoordinates.length < 2) { + return null; + } + + let closestPointOnLine: Coordinate | null = null; + let minDistance = Infinity; + + for (let i = 0; i < lineCoordinates.length - 1; i++) { + const startPoint = lineCoordinates.at(i); + const endPoint = lineCoordinates.at(i + 1); + + if (!startPoint || !endPoint) { + break; + } + + const closestPoint = closestPointOnSegment(center, startPoint, endPoint); + + const distance = haversineDistance([center.lng, center.lat], [closestPoint.lng, closestPoint.lat]); + + if (distance < minDistance) { + minDistance = distance; + closestPointOnLine = [closestPoint.lng, closestPoint.lat]; + } + } + + return closestPointOnLine; +} + +/** + * Find the closest point on the line segment created by connecting start and endPoint + */ +function closestPointOnSegment(point: LngLat, startPoint: Coordinate, endPoint: Coordinate): LngLat { + const x0 = point.lng; + const y0 = point.lat; + const x1 = startPoint[0]; + const y1 = startPoint[1]; + const x2 = endPoint[0]; + const y2 = endPoint[1]; + + const dx = x2 - x1; + const dy = y2 - y1; + + if (dx === 0 && dy === 0) { + return {lng: x1, lat: y1}; + } + + const t = ((x0 - x1) * dx + (y0 - y1) * dy) / (dx * dx + dy * dy); + + let closestX; + let closestY; + if (t < 0) { + closestX = x1; + closestY = y1; + } else if (t > 1) { + closestX = x2; + closestY = y2; + } else { + closestX = x1 + t * dx; + closestY = y1 + t * dy; + } + + return {lng: closestX, lat: closestY}; +} + +function getBoundsCenter(bounds: {southWest: Coordinate; northEast: Coordinate}) { + const { + southWest: [south, west], + northEast: [north, east], + } = bounds; + + const latitudeCenter = (north + south) / 2; + const longitudeCenter = (east + west) / 2; + + return {lng: latitudeCenter, lat: longitudeCenter}; +} + export default { getBounds, areSameCoordinate, + findClosestCoordinateOnLineFromCenter, + getBoundsCenter, }; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 1d6c3fe4cfba..d69798c7039e 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -41,6 +41,7 @@ import { isPending, isReceiptBeingScanned, shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, + shouldShowRTERViolationMessage, } from '@libs/TransactionUtils'; import variables from '@styles/variables'; import { @@ -150,8 +151,10 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea return !!transactions && transactions.length > 0 && transactions.every((t) => isExpensifyCardTransaction(t) && isPending(t)); }, [transactions]); const transactionIDs = transactions?.map((t) => t.transactionID) ?? []; + // Check if there is pending rter violation in all transactionViolations with given transactionIDs. const hasAllPendingRTERViolations = allHavePendingRTERViolation(transactionIDs); - const shouldShowBrokenConnectionViolation = shouldShowBrokenConnectionViolationTransactionUtils(transactionIDs, moneyRequestReport, policy); + // Check if user should see broken connection violation warning. + const shouldShowBrokenConnectionViolation = shouldShowBrokenConnectionViolationTransactionUtils(transactionIDs, moneyRequestReport, policy) && !!transactionThreadReportID; const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(moneyRequestReport?.reportID); const isPayAtEndExpense = isPayAtEndExpenseTransactionUtils(transaction); const isArchivedReport = isArchivedReportWithID(moneyRequestReport?.reportID); @@ -166,7 +169,8 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); const shouldShowMarkAsCashButton = - hasAllPendingRTERViolations || (shouldShowBrokenConnectionViolation && (!isPolicyAdmin(policy) || isCurrentUserSubmitter(moneyRequestReport?.reportID))); + !!transactionThreadReportID && + (hasAllPendingRTERViolations || (shouldShowBrokenConnectionViolation && (!isPolicyAdmin(policy) || isCurrentUserSubmitter(moneyRequestReport?.reportID)))); const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; @@ -184,7 +188,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const shouldShowSettlementButton = !shouldShowSubmitButton && (shouldShowPayButton || shouldShowApproveButton) && - !hasAllPendingRTERViolations && + !shouldShowRTERViolationMessage(transactions) && !shouldShowExportIntegrationButton && !shouldShowBrokenConnectionViolation; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 0cc01b2a6265..63579c5854fa 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -797,7 +797,8 @@ function MoneyRequestConfirmationList({ return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.CREATE, transactionID, transaction.reportID)); + const newIOUType = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.CREATE : iouType; + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(newIOUType, transactionID, transaction.reportID)); }; /** diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 4e7f271b2cf2..74d403099316 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -806,7 +806,7 @@ function MoneyRequestConfirmationListFooter({ if (!transactionID) { return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.getRoute(action, iouType, transactionID, reportID)); }} disabled={didConfirm} interactive={!isReadOnly} diff --git a/src/components/Navigation/DebugTabView.tsx b/src/components/Navigation/DebugTabView.tsx index db6fae0669c2..2d7f9e9ae9d8 100644 --- a/src/components/Navigation/DebugTabView.tsx +++ b/src/components/Navigation/DebugTabView.tsx @@ -82,9 +82,9 @@ function getSettingsRoute(status: IndicatorStatus | undefined, reimbursementAcco getReimbursementAccountRouteForCurrentStep(reimbursementAccount?.achData?.currentStep ?? CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT), ); case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS: - return ROUTES.SETTINGS_SUBSCRIPTION; + return ROUTES.SETTINGS_SUBSCRIPTION.route; case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO: - return ROUTES.SETTINGS_SUBSCRIPTION; + return ROUTES.SETTINGS_SUBSCRIPTION.route; case CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS: return ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyIDWithErrors); case CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS: diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx index 495c14ff76e1..fa91922a6498 100644 --- a/src/components/PDFThumbnail/index.tsx +++ b/src/components/PDFThumbnail/index.tsx @@ -1,17 +1,24 @@ import 'core-js/proposals/promise-with-resolvers'; // eslint-disable-next-line import/extensions -import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs'; +import pdfWorkerSource from 'pdfjs-dist/build/pdf.worker.min.mjs'; +// eslint-disable-next-line import/extensions +import pdfWorkerLegacySource from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs'; import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import {Document, pdfjs, Thumbnail} from 'react-pdf'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {isMobileSafari, isModernSafari} from '@libs/Browser'; import PDFThumbnailError from './PDFThumbnailError'; import type PDFThumbnailProps from './types'; +const shouldUseLegacyWorker = isMobileSafari() && !isModernSafari(); +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const pdfWorker = shouldUseLegacyWorker ? pdfWorkerLegacySource : pdfWorkerSource; + if (!pdfjs.GlobalWorkerOptions.workerSrc) { - pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'})); + pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(new Blob([pdfWorker], {type: 'text/javascript'})); } function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword, onLoadError, onLoadSuccess}: PDFThumbnailProps) { diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 8c1c1287d77b..984811281f49 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -8,8 +8,7 @@ import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import {ReceiptScan} from '@components/Icon/Expensicons'; +import {Checkmark, DotIndicator, Folder, Hourglass, Tag} from '@components/Icon/Expensicons'; import MoneyRequestSkeletonView from '@components/MoneyRequestSkeletonView'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -51,7 +50,7 @@ import { getTransactionViolations, hasMissingSmartscanFields, hasNoticeTypeViolation as hasNoticeTypeViolationTransactionUtils, - hasPendingUI, + hasPendingRTERViolation, hasReceipt as hasReceiptTransactionUtils, hasViolation as hasViolationTransactionUtils, hasWarningTypeViolation as hasWarningTypeViolationTransactionUtils, @@ -177,7 +176,8 @@ function MoneyRequestPreviewContent({ const shouldShowKeepButton = !!(allDuplicates.length && duplicates.length && allDuplicates.length === duplicates.length); const shouldShowTag = !!tag && isPolicyExpenseChat; - const shouldShowCategoryOrTag = shouldShowTag || !!category; + const shouldShowCategory = !!category && isPolicyExpenseChat; + const shouldShowCategoryOrTag = shouldShowTag || shouldShowCategory; const shouldShowRBR = hasNoticeTypeViolations || hasWarningTypeViolations || hasViolations || hasFieldErrors || (!isFullySettled && !isFullyApproved && isOnHold); const showCashOrCard = isCardTransaction ? translate('iou.card') : translate('iou.cash'); // We don't use isOnHold because it's true for duplicated transaction too and we only want to show hold message if the transaction is truly on hold @@ -234,6 +234,14 @@ function MoneyRequestPreviewContent({ message = translate('iou.split'); } + if (isPending(transaction)) { + message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.pending')}`; + } + + if (hasPendingRTERViolation(getTransactionViolations(transactionID, transactionViolations))) { + message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.pendingMatch')}`; + } + if (isSettled && !iouReport?.isCancelledIOU && !isPartialHold) { message += ` ${CONST.DOT_SEPARATOR} ${getSettledMessage()}`; return message; @@ -278,17 +286,8 @@ function MoneyRequestPreviewContent({ }; const getPendingMessageProps: () => PendingMessageProps = () => { - if (isScanning) { - return {shouldShow: true, messageIcon: ReceiptScan, messageDescription: translate('iou.receiptScanInProgress')}; - } - if (isPending(transaction)) { - return {shouldShow: true, messageIcon: Expensicons.CreditCardHourglass, messageDescription: translate('iou.transactionPending')}; - } if (shouldShowBrokenConnectionViolation(transaction ? [transaction.transactionID] : [], iouReport, policy)) { - return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('violations.brokenConnection530Error')}; - } - if (hasPendingUI(transaction, getTransactionViolations(transaction?.transactionID, transactionViolations))) { - return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('iou.pendingMatchWithCreditCard')}; + return {shouldShow: true, messageIcon: Hourglass, messageDescription: translate('violations.brokenConnection530Error')}; } return {shouldShow: false}; }; @@ -297,7 +296,7 @@ function MoneyRequestPreviewContent({ const getDisplayAmountText = (): string => { if (isScanning) { - return translate('iou.receiptScanning', {count: 1}); + return translate('iou.receiptStatusTitle'); } if (isFetchingWaypointsFromServer && !requestAmount) { @@ -393,8 +392,9 @@ function MoneyRequestPreviewContent({ {getPreviewHeaderText()} {!isSettled && shouldShowRBR && ( )} @@ -420,7 +420,7 @@ function MoneyRequestPreviewContent({ {isSettledReportUtils(iouReport?.reportID) && !isPartialHold && !isBillSplit && ( @@ -467,7 +467,7 @@ function MoneyRequestPreviewContent({ {shouldShowCategoryOrTag && } {shouldShowCategoryOrTag && ( - {!!category && ( + {shouldShowCategory && ( ({...getThumbnailAndImageURIs(transaction), transaction})); const transactionIDList = transactions?.map((reportTransaction) => reportTransaction.transactionID) ?? []; - const showRTERViolationMessage = numberOfRequests === 1 && hasPendingUI(lastTransaction, getTransactionViolations(lastTransaction?.transactionID, transactionViolations)); + const showRTERViolationMessage = shouldShowRTERViolationMessage(transactions); const shouldShowBrokenConnectionViolation = numberOfRequests === 1 && shouldShowBrokenConnectionViolationTransactionUtils(transactionIDList, iouReport, policy); let formattedMerchant = numberOfRequests === 1 ? getMerchant(lastTransaction) : null; const formattedDescription = numberOfRequests === 1 ? getDescription(lastTransaction) : null; @@ -331,7 +330,7 @@ function ReportPreview({ return convertToDisplayString(totalDisplaySpend, iouReport?.currency); } if (isScanning) { - return translate('iou.receiptScanning', {count: numberOfScanningReceipts}); + return translate('iou.receiptStatusTitle'); } if (hasOnlyTransactionsWithPendingRoutes) { return translate('iou.fieldPending'); @@ -367,7 +366,13 @@ function ReportPreview({ const previewMessage = useMemo(() => { if (isScanning) { - return translate('common.receipt'); + return totalDisplaySpend ? `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('common.scanning')}` : `${translate('common.receipt')}`; + } + if (numberOfPendingRequests === 1 && numberOfRequests === 1) { + return `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('iou.pending')}`; + } + if (showRTERViolationMessage) { + return `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('iou.pendingMatch')}`; } let payerOrApproverName; @@ -392,6 +397,9 @@ function ReportPreview({ return translate(paymentVerb, {payer: payerOrApproverName}); }, [ isScanning, + numberOfPendingRequests, + numberOfRequests, + showRTERViolationMessage, isPolicyExpenseChat, isTripRoom, isInvoiceRoom, @@ -400,6 +408,7 @@ function ReportPreview({ iouReport?.isWaitingOnBankAccount, hasNonReimbursableTransactions, translate, + totalDisplaySpend, chatReport, policy, invoiceReceiverPolicy, @@ -425,8 +434,6 @@ function ReportPreview({ const shouldShowSingleRequestMerchantOrDescription = numberOfRequests === 1 && (!!formattedMerchant || !!formattedDescription) && !(hasOnlyTransactionsWithPendingRoutes && !totalDisplaySpend); const shouldShowSubtitle = !isScanning && (shouldShowSingleRequestMerchantOrDescription || numberOfRequests > 1) && !isDisplayAmountZero(getDisplayAmount()); - const shouldShowScanningSubtitle = (numberOfScanningReceipts === 1 && numberOfRequests === 1) || (numberOfScanningReceipts >= 1 && Number(nonHeldAmount) === 0); - const shouldShowPendingSubtitle = numberOfPendingRequests === 1 && numberOfRequests === 1; const isPayAtEndExpense = isPayAtEndExpenseReport(iouReportID, transactions); const [archiveReason] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, {selector: getArchiveReason}); @@ -444,18 +451,9 @@ function ReportPreview({ }; } } - if (shouldShowScanningSubtitle) { - return {shouldShow: true, messageIcon: Expensicons.ReceiptScan, messageDescription: translate('iou.receiptScanInProgress')}; - } - if (shouldShowPendingSubtitle) { - return {shouldShow: true, messageIcon: Expensicons.CreditCardHourglass, messageDescription: translate('iou.transactionPending')}; - } if (shouldShowBrokenConnectionViolation) { return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('violations.brokenConnection530Error')}; } - if (showRTERViolationMessage) { - return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('iou.pendingMatchWithCreditCard')}; - } return {shouldShow: false}; }; diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 5e201a921e0d..fedeb388d3a9 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -36,6 +36,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type Report from '@src/types/onyx/Report'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import KeyboardUtils from '@src/utils/keyboard'; import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; @@ -268,11 +269,13 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) } else { onRouterClose(); - if (item?.reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID)); - } else if ('login' in item) { - navigateToAndOpenReport(item.login ? [item.login] : [], false); - } + KeyboardUtils.dismiss().then(() => { + if (item?.reportID) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID)); + } else if ('login' in item) { + navigateToAndOpenReport(item.login ? [item.login] : [], false); + } + }); } }, [autocompleteSubstitutions, onRouterClose, onSearchQueryChange, submitSearch, textInputValue], diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index f94e6e1146c5..53ee7b90408a 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -11,7 +11,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; +import {buildSearchQueryString} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; @@ -49,10 +49,16 @@ const expenseOptions: Array<{status: ExpenseSearchStatus; type: SearchDataTypes; icon: Expensicons.ThumbsUp, text: 'iou.approved', }, + { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.DONE, + icon: Expensicons.Checkbox, + text: 'iou.done', + }, { type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.PAID, - icon: Expensicons.MoneyBag, + icon: Expensicons.Checkmark, text: 'iou.settledExpensify', }, ]; @@ -73,7 +79,7 @@ const invoiceOptions: Array<{type: SearchDataTypes; status: InvoiceSearchStatus; { type: CONST.SEARCH.DATA_TYPES.INVOICE, status: CONST.SEARCH.STATUS.INVOICE.PAID, - icon: Expensicons.MoneyBag, + icon: Expensicons.Checkmark, text: 'iou.settledExpensify', }, ]; @@ -177,7 +183,7 @@ function SearchStatusBar({queryJSON, onStatusChange}: SearchStatusBarProps) { {options.map((item, index) => { const onPress = singleExecution(() => { onStatusChange?.(); - const query = SearchQueryUtils.buildSearchQueryString({...queryJSON, status: item.status}); + const query = buildSearchQueryString({...queryJSON, status: item.status}); Navigation.setParams({q: query}); }); const isActive = Array.isArray(queryJSON.status) ? queryJSON.status.includes(item.status) : queryJSON.status === item.status; diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index 635f933582c0..5b96771b7dd6 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -56,7 +56,7 @@ function ActionCell({ ({ const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, stepFields); + const errors = getFieldRequiredErrors(values, stepFields); const valuesToValidate = values[dobInputID as keyof FormOnyxValues] as string; if (valuesToValidate) { - if (!ValidationUtils.isValidPastDate(valuesToValidate) || !ValidationUtils.meetsMaximumAgeRequirement(valuesToValidate)) { + if (!isValidPastDate(valuesToValidate) || !meetsMaximumAgeRequirement(valuesToValidate)) { // @ts-expect-error type mismatch to be fixed errors[dobInputID] = translate('bankAccount.error.dob'); - } else if (!ValidationUtils.meetsMinimumAgeRequirement(valuesToValidate)) { + } else if (!meetsMinimumAgeRequirement(valuesToValidate)) { // @ts-expect-error type mismatch to be fixed errors[dobInputID] = translate('bankAccount.error.age'); } @@ -83,6 +83,7 @@ function DateOfBirthStep({ onSubmit={onSubmit} style={[styles.mh5, styles.flexGrow2, styles.justifyContentBetween]} submitButtonStyles={[styles.mb0]} + enabledWhenOffline > {formTitle} ({ const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, stepFields); + const errors = getFieldRequiredErrors(values, stepFields); const firstName = values[firstNameInputID as keyof FormOnyxValues] as string; - if (!ValidationUtils.isRequiredFulfilled(firstName)) { + if (!isRequiredFulfilled(firstName)) { // @ts-expect-error type mismatch to be fixed errors[firstNameInputID] = translate('common.error.fieldRequired'); - } else if (!ValidationUtils.isValidLegalName(firstName)) { + } else if (!isValidLegalName(firstName)) { // @ts-expect-error type mismatch to be fixed errors[firstNameInputID] = translate('privatePersonalDetails.error.hasInvalidCharacter'); } else if (firstName.length > CONST.LEGAL_NAME.MAX_LENGTH) { @@ -88,10 +88,10 @@ function FullNameStep({ } const lastName = values[lastNameInputID as keyof FormOnyxValues] as string; - if (!ValidationUtils.isRequiredFulfilled(lastName)) { + if (!isRequiredFulfilled(lastName)) { // @ts-expect-error type mismatch to be fixed errors[lastNameInputID] = translate('common.error.fieldRequired'); - } else if (!ValidationUtils.isValidLegalName(lastName)) { + } else if (!isValidLegalName(lastName)) { // @ts-expect-error type mismatch to be fixed errors[lastNameInputID] = translate('privatePersonalDetails.error.hasInvalidCharacter'); } else if (lastName.length > CONST.LEGAL_NAME.MAX_LENGTH) { @@ -113,6 +113,7 @@ function FullNameStep({ validate={customValidate ?? validate} onSubmit={onSubmit} style={[styles.mh5, styles.flexGrow1]} + enabledWhenOffline > {formTitle} diff --git a/src/components/SubStepForms/SingleFieldStep.tsx b/src/components/SubStepForms/SingleFieldStep.tsx index be9b3c033f96..c74bedeed3c6 100644 --- a/src/components/SubStepForms/SingleFieldStep.tsx +++ b/src/components/SubStepForms/SingleFieldStep.tsx @@ -46,6 +46,9 @@ type SingleFieldStepProps = SubStep /** Max length of the field */ maxLength?: number; + + /** Should the submit button be enabled when offline */ + enabledWhenOffline?: boolean; }; function SingleFieldStep({ @@ -61,6 +64,7 @@ function SingleFieldStep({ isEditing, shouldShowHelpLinks = true, maxLength, + enabledWhenOffline, }: SingleFieldStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -73,6 +77,7 @@ function SingleFieldStep({ onSubmit={onSubmit} style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} + enabledWhenOffline={enabledWhenOffline} > {formTitle} diff --git a/src/components/ThreeDotsMenu/types.ts b/src/components/ThreeDotsMenu/types.ts index 6b645d8e972c..dc7f411f3af1 100644 --- a/src/components/ThreeDotsMenu/types.ts +++ b/src/components/ThreeDotsMenu/types.ts @@ -1,4 +1,4 @@ -import type {StyleProp, ViewStyle} from 'react-native'; +import type {LayoutRectangle, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {TranslationPaths} from '@src/languages/types'; import type {AnchorPosition} from '@src/styles'; @@ -49,4 +49,7 @@ type ThreeDotsMenuProps = { shouldShowProductTrainingTooltip?: boolean; }; +type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle; target: HTMLElement}>; + +export type {LayoutChangeEventWithTarget}; export default ThreeDotsMenuProps; diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index bad9a1a169c9..1fd0cd657e85 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -24,6 +24,7 @@ import {canUseTouchScreen as canUseTouchScreenLib} from '@libs/DeviceCapabilitie import CONST from '@src/CONST'; import shouldReplayVideo from './shouldReplayVideo'; import type {VideoPlayerProps, VideoWithOnFullScreenUpdate} from './types'; +import useHandleNativeVideoControls from './useHandleNativeVideoControls'; import * as VideoUtils from './utils'; import VideoPlayerControls from './VideoPlayerControls'; @@ -91,6 +92,11 @@ function BaseVideoPlayer({ const isUploading = CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => url.startsWith(prefix)); const videoStateRef = useRef(null); const {updateVolume, lastNonZeroVolume} = useVolumeContext(); + useHandleNativeVideoControls({ + videoPlayerRef, + isOffline, + isLocalFile: isUploading, + }); const {videoPopoverMenuPlayerRef, currentPlaybackSpeed, setCurrentPlaybackSpeed, setSource: setPopoverMenuSource} = useVideoPopoverMenuContext(); const {source} = videoPopoverMenuPlayerRef.current?.props ?? {}; const shouldUseNewRate = typeof source === 'number' || !source || source.uri !== sourceURL; diff --git a/src/components/VideoPlayer/useHandleNativeVideoControls/index.native.ts b/src/components/VideoPlayer/useHandleNativeVideoControls/index.native.ts new file mode 100644 index 000000000000..478ef60c62a4 --- /dev/null +++ b/src/components/VideoPlayer/useHandleNativeVideoControls/index.native.ts @@ -0,0 +1,5 @@ +import type UseHandleVideoNativeControl from './types'; + +const useHandleNativeVideoControls: UseHandleVideoNativeControl = () => {}; + +export default useHandleNativeVideoControls; diff --git a/src/components/VideoPlayer/useHandleNativeVideoControls/index.ts b/src/components/VideoPlayer/useHandleNativeVideoControls/index.ts new file mode 100644 index 000000000000..1c682ef73f71 --- /dev/null +++ b/src/components/VideoPlayer/useHandleNativeVideoControls/index.ts @@ -0,0 +1,27 @@ +import {useEffect} from 'react'; +import type UseHandleNativeVideoControl from './types'; + +/** + * Web implementation for managing native video controls. + * This hook hides the download button on the native video player in full-screen mode + * when playing a local or offline video. + */ +const useHandleNativeVideoControls: UseHandleNativeVideoControl = ({videoPlayerRef, isLocalFile, isOffline}) => { + useEffect(() => { + // @ts-expect-error Property '_video' does not exist on type VideoWithOnFullScreenUpdate + // eslint-disable-next-line no-underscore-dangle + const videoElement = videoPlayerRef?.current?._nativeRef?.current?._video as HTMLVideoElement; + if (!videoElement) { + return; + } + + if (isOffline || isLocalFile) { + videoElement.setAttribute('controlsList', 'nodownload'); + } else { + videoElement.removeAttribute('controlsList'); + } + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isOffline, isLocalFile]); +}; + +export default useHandleNativeVideoControls; diff --git a/src/components/VideoPlayer/useHandleNativeVideoControls/types.ts b/src/components/VideoPlayer/useHandleNativeVideoControls/types.ts new file mode 100644 index 000000000000..ea6bfd4b9a19 --- /dev/null +++ b/src/components/VideoPlayer/useHandleNativeVideoControls/types.ts @@ -0,0 +1,11 @@ +import type {MutableRefObject} from 'react'; +import type {VideoWithOnFullScreenUpdate} from '@components/VideoPlayer/types'; + +type UseHandleNativeVideoControlParams = { + videoPlayerRef: MutableRefObject; + isLocalFile: boolean; + isOffline: boolean; +}; +type UseHandleNativeVideoControl = (params: UseHandleNativeVideoControlParams) => void; + +export default UseHandleNativeVideoControl; diff --git a/src/hooks/useAccordionAnimation.ts b/src/hooks/useAccordionAnimation.ts new file mode 100644 index 000000000000..d3995493ff8e --- /dev/null +++ b/src/hooks/useAccordionAnimation.ts @@ -0,0 +1,26 @@ +import {useEffect} from 'react'; +import {useSharedValue} from 'react-native-reanimated'; + +/** + * @returns two values: isExpanded, which manages the expansion of the accordion component, + * and shouldAnimateAccordionSection, which determines whether we should animate + * the expanding and collapsing of the accordion based on changes in isExpanded. + */ +function useAccordionAnimation(isExpanded: boolean) { + const isAccordionExpanded = useSharedValue(isExpanded); + const shouldAnimateAccordionSection = useSharedValue(false); + const hasMounted = useSharedValue(false); + + useEffect(() => { + isAccordionExpanded.set(isExpanded); + if (hasMounted.get()) { + shouldAnimateAccordionSection.set(true); + } else { + hasMounted.set(true); + } + }, [hasMounted, isAccordionExpanded, isExpanded, shouldAnimateAccordionSection]); + + return {isAccordionExpanded, shouldAnimateAccordionSection}; +} + +export default useAccordionAnimation; diff --git a/src/languages/en.ts b/src/languages/en.ts index c5d88693f61f..8ca5b04e4d40 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5,6 +5,8 @@ import type {Country} from '@src/CONST'; import type { AccountOwnerParams, ActionsAreCurrentlyRestricted, + AddedOrDeletedPolicyReportFieldParams, + AddedPolicyCustomUnitRateParams, AddEmployeeParams, AddressLineParams, AdminCanceledRequestParams, @@ -60,6 +62,7 @@ import type { DeleteActionParams, DeleteConfirmationParams, DeleteTransactionParams, + DemotedFromWorkspaceParams, DidSplitAmountMessageParams, EarlyDiscountSubtitleParams, EarlyDiscountTitleParams, @@ -105,6 +108,7 @@ import type { MarkReimbursedFromIntegrationParams, MissingPropertyParams, MovedFromPersonalSpaceParams, + NeedCategoryForExportToIntegrationParams, NoLongerHaveAccessParams, NotAllowedExtensionParams, NotYouParams, @@ -175,7 +179,18 @@ import type { UnapproveWithIntegrationWarningParams, UnshareParams, UntilTimeParams, - UpdateAutoReportingFrequencyParams, + UpdatedPolicyCategoryNameParams, + UpdatedPolicyCategoryParams, + UpdatedPolicyCurrencyParams, + UpdatedPolicyDescriptionParams, + UpdatedPolicyFieldWithNewAndOldValueParams, + UpdatedPolicyFieldWithValueParam, + UpdatedPolicyFrequencyParams, + UpdatedPolicyPreventSelfApprovalParams, + UpdatedPolicyReportFieldDefaultValueParams, + UpdatedPolicyTagFieldParams, + UpdatedPolicyTagNameParams, + UpdatedPolicyTagParams, UpdatedTheDistanceMerchantParams, UpdatedTheRequestParams, UpdateRoleParams, @@ -279,6 +294,7 @@ const translations = { continue: 'Continue', firstName: 'First name', lastName: 'Last name', + scanning: 'Scanning', addCardTermsOfService: 'Expensify Terms of Service', perPerson: 'per person', phone: 'Phone', @@ -888,6 +904,7 @@ const translations = { deleteReceipt: 'Delete receipt', deletedTransaction: ({amount, merchant}: DeleteTransactionParams) => `deleted an expense on this report, ${merchant} - ${amount}`, pendingMatchWithCreditCard: 'Receipt pending match with card transaction', + pendingMatch: 'Pending match', pendingMatchWithCreditCardDescription: 'Receipt pending match with card transaction. Mark as cash to cancel.', markAsCash: 'Mark as cash', routePending: 'Route pending...', @@ -939,6 +956,7 @@ const translations = { other: 'Are you sure that you want to delete these expenses?', }), settledExpensify: 'Paid', + done: 'Done', settledElsewhere: 'Paid elsewhere', individual: 'Individual', business: 'Business', @@ -1551,6 +1569,7 @@ const translations = { }, frequencyDescription: 'Choose how often you’d like expenses to submit automatically, or make it manual', frequencies: { + instant: 'Instant', weekly: 'Weekly', monthly: 'Monthly', twiceAMonth: 'Twice a month', @@ -2610,6 +2629,10 @@ const translations = { title: 'Get started with Expensify Travel', message: `You'll need to use your work email (e.g., name@company.com) with Expensify Travel, not your personal email (e.g., name@gmail.com).`, }, + maintenance: { + title: 'Expensify Travel is getting an upgrade! 🚀', + message: `It'll be unavailable February 23-24, but back and better than ever after that. If you need help with a current trip, please call +1 866-296-7768. Thanks!`, + }, }, workspace: { common: { @@ -2848,6 +2871,7 @@ const translations = { itemsDescription: 'Choose how to handle QuickBooks Desktop items in Expensify.', }, qbo: { + connectedTo: 'Connected to', importDescription: 'Choose which coding configurations to import from QuickBooks Online to Expensify.', classes: 'Classes', locations: 'Locations', @@ -3677,7 +3701,7 @@ const translations = { deleteFailureMessage: 'An error occurred while deleting the category, please try again.', categoryName: 'Category name', requiresCategory: 'Members must categorize all expenses', - needCategoryForExportToIntegration: 'Require a category on every expense in order to export to', + needCategoryForExportToIntegration: ({connectionName}: NeedCategoryForExportToIntegrationParams) => `All expenses must be categorized in order to export to ${connectionName}.`, subtitle: 'Get a better overview of where money is being spent. Use our default categories or add your own.', emptyCategories: { title: "You haven't created any categories", @@ -4795,8 +4819,48 @@ const translations = { public_announce: 'Public Announce', }, }, + workspaceApprovalModes: { + submitAndClose: 'Submit and Close', + submitAndApprove: 'Submit and Approve', + advanced: 'ADVANCED', + dynamictExternal: 'DYNAMIC_EXTERNAL', + smartReport: 'SMARTREPORT', + billcom: 'BILLCOM', + }, workspaceActions: { + addCategory: ({categoryName}: UpdatedPolicyCategoryParams) => `added the category "${categoryName}"`, + deleteCategory: ({categoryName}: UpdatedPolicyCategoryParams) => `removed the category "${categoryName}"`, + updateCategory: ({oldValue, categoryName}: UpdatedPolicyCategoryParams) => `${oldValue ? 'disabled' : 'enabled'} the category "${categoryName}"`, + setCategoryName: ({oldName, newName}: UpdatedPolicyCategoryNameParams) => `renamed the category "${oldName}" to "${newName}"`, + addTag: ({tagListName, tagName}: UpdatedPolicyTagParams) => `added the tag "${tagName}" to the list "${tagListName}"`, + updateTagName: ({tagListName, newName, oldName}: UpdatedPolicyTagNameParams) => `updated the tag list "${tagListName}" by changing the tag "${oldName}" to "${newName}`, + updateTagEnabled: ({tagListName, tagName, enabled}: UpdatedPolicyTagParams) => `${enabled ? 'enabled' : 'disabled'} the tag "${tagName}" on the list "${tagListName}"`, + deleteTag: ({tagListName, tagName}: UpdatedPolicyTagParams) => `removed the tag "${tagName}" from the list "${tagListName}"`, + updateTag: ({tagListName, newValue, tagName, updatedField, oldValue}: UpdatedPolicyTagFieldParams) => { + if (oldValue) { + return `updated the tag "${tagName}" on the list "${tagListName}" by changing the ${updatedField} to "${newValue}" (previously "${oldValue}")`; + } + return `updated the tag "${tagName}" on the list "${tagListName}" by adding a ${updatedField} of "${newValue}"`; + }, + addCustomUnitRate: ({customUnitName, rateName}: AddedPolicyCustomUnitRateParams) => `added a new "${customUnitName}" rate "${rateName}"`, + addedReportField: ({fieldType, fieldName}: AddedOrDeletedPolicyReportFieldParams) => `added ${fieldType} Report Field "${fieldName}"`, + updateReportFieldDefaultValue: ({defaultValue, fieldName}: UpdatedPolicyReportFieldDefaultValueParams) => `set the default value of report field "${fieldName}" to "${defaultValue}"`, + deleteReportField: ({fieldType, fieldName}: AddedOrDeletedPolicyReportFieldParams) => `removed ${fieldType} Report Field "${fieldName}"`, + preventSelfApproval: ({oldValue, newValue}: UpdatedPolicyPreventSelfApprovalParams) => + `updated "Prevent self-approval" to "${newValue === 'true' ? 'Enabled' : 'Disabled'}" (previously "${oldValue === 'true' ? 'Enabled' : 'Disabled'}")`, + updateMaxExpenseAmountNoReceipt: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `changed the maximum receipt required expense amount to ${newValue} (previously ${oldValue})`, + updateMaxExpenseAmount: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `changed the maximum expense amount for violations to ${newValue} (previously ${oldValue})`, + updateMaxExpenseAge: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `updated "Max expense age (days)" to "${newValue}" (previously "${oldValue === 'false' ? CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE : oldValue}")`, + updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => `updated "Re-bill expenses to clients" to "${newValue}" (previously "${oldValue}")`, + updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `turned "Enforce default report titles" ${value ? 'on' : 'off'}`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedRoomActionParams) => `updated the name of this workspace to "${newName}" (previously "${oldName}")`, + updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => + !oldDescription + ? `set the description of this workspace to "${newDescription}"` + : `updated the description of this workspace to "${newDescription}" (previously "${oldDescription}")`, removedFromApprovalWorkflow: ({submittersNames}: RemovedFromApprovalWorkflowParams) => { let joinedNames = ''; if (submittersNames.length === 1) { @@ -4811,6 +4875,12 @@ const translations = { other: `removed you from ${joinedNames}'s approval workflows and workspace chats. Previously submitted reports will remain available for approval in your Inbox.`, }; }, + demotedFromWorkspace: ({policyName, oldRole}: DemotedFromWorkspaceParams) => + `updated your role in ${policyName} from ${oldRole} to user. You have been removed from all submitter workspace chats except for you own.`, + updatedWorkspaceCurrencyAction: ({oldCurrency, newCurrency}: UpdatedPolicyCurrencyParams) => `updated the default currency to ${newCurrency} (previously ${oldCurrency})`, + updatedWorkspaceFrequencyAction: ({oldFrequency, newFrequency}: UpdatedPolicyFrequencyParams) => + `updated the auto-reporting frequency to "${newFrequency}" (previously "${oldFrequency}")`, + updateApprovalMode: ({newValue, oldValue}: ChangeFieldParams) => `updated the approval mode to "${newValue}" (previously "${oldValue}")`, upgradedWorkspace: 'upgraded this workspace to the Control plan', downgradedWorkspace: 'downgraded this workspace to the Collect plan', }, @@ -5066,7 +5136,8 @@ const translations = { nonReimbursableLink: 'View company card expenses.', pending: ({label}: ExportedToIntegrationParams) => `started exporting this report to ${label}...`, }, - integrationsMessage: ({errorMessage, label}: IntegrationSyncFailedParams) => `failed to export this report to ${label} ("${errorMessage}")`, + integrationsMessage: ({errorMessage, label, linkText, linkURL}: IntegrationSyncFailedParams) => + `failed to export this report to ${label} ("${errorMessage} ${linkText ? `${linkText}` : ''}")`, managerAttachReceipt: `added a receipt`, managerDetachReceipt: `removed a receipt`, markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `paid ${currency}${amount} elsewhere`, @@ -5083,16 +5154,11 @@ const translations = { stripePaid: ({amount, currency}: StripePaidParams) => `paid ${currency}${amount}`, takeControl: `took control`, integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `failed to sync with ${label}${errorMessage ? ` ("${errorMessage}")` : ''}`, - addEmployee: ({email, role}: AddEmployeeParams) => `added ${email} as ${role === 'member' || role === 'user' ? 'a member' : 'an admin'}`, - updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => - `updated the role of ${email} to ${newRole === 'member' || newRole === 'user' ? 'member' : newRole} (previously ${ - currentRole === 'member' || currentRole === 'user' ? 'member' : currentRole - })`, + addEmployee: ({email, role}: AddEmployeeParams) => `added ${email} as ${role === 'member' ? 'a' : 'an'} ${role}`, + updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => `updated the role of ${email} to ${newRole} (previously ${currentRole})`, leftWorkspace: ({nameOrEmail}: LeftWorkspaceParams) => `${nameOrEmail} left the workspace`, - removeMember: ({email, role}: AddEmployeeParams) => `removed ${role === 'member' || role === 'user' ? 'member' : 'admin'} ${email}`, + removeMember: ({email, role}: AddEmployeeParams) => `removed ${role} ${email}`, removedConnection: ({connectionName}: ConnectionNameParams) => `removed connection to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, - updateAutoReportingFrequency: ({oldFrequency, newFrequency}: UpdateAutoReportingFrequencyParams) => - `updated the submission frequency to "${newFrequency}" (previously "${oldFrequency}")`, }, }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 54292ec0f511..4afa5dd1bd6a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4,6 +4,8 @@ import type en from './en'; import type { AccountOwnerParams, ActionsAreCurrentlyRestricted, + AddedOrDeletedPolicyReportFieldParams, + AddedPolicyCustomUnitRateParams, AddEmployeeParams, AddressLineParams, AdminCanceledRequestParams, @@ -59,6 +61,7 @@ import type { DeleteActionParams, DeleteConfirmationParams, DeleteTransactionParams, + DemotedFromWorkspaceParams, DidSplitAmountMessageParams, EarlyDiscountSubtitleParams, EarlyDiscountTitleParams, @@ -104,6 +107,7 @@ import type { MarkReimbursedFromIntegrationParams, MissingPropertyParams, MovedFromPersonalSpaceParams, + NeedCategoryForExportToIntegrationParams, NoLongerHaveAccessParams, NotAllowedExtensionParams, NotYouParams, @@ -174,7 +178,18 @@ import type { UnapproveWithIntegrationWarningParams, UnshareParams, UntilTimeParams, - UpdateAutoReportingFrequencyParams, + UpdatedPolicyCategoryNameParams, + UpdatedPolicyCategoryParams, + UpdatedPolicyCurrencyParams, + UpdatedPolicyDescriptionParams, + UpdatedPolicyFieldWithNewAndOldValueParams, + UpdatedPolicyFieldWithValueParam, + UpdatedPolicyFrequencyParams, + UpdatedPolicyPreventSelfApprovalParams, + UpdatedPolicyReportFieldDefaultValueParams, + UpdatedPolicyTagFieldParams, + UpdatedPolicyTagNameParams, + UpdatedPolicyTagParams, UpdatedTheDistanceMerchantParams, UpdatedTheRequestParams, UpdateRoleParams, @@ -273,6 +288,7 @@ const translations = { continue: 'Continuar', firstName: 'Nombre', lastName: 'Apellidos', + scanning: 'Escaneando', phone: 'Teléfono', phoneNumber: 'Número de teléfono', phoneNumberPlaceholder: '(xxx) xxx-xxxx', @@ -881,6 +897,7 @@ const translations = { canceled: 'Canceló', posted: 'Contabilizado', deleteReceipt: 'Eliminar recibo', + pendingMatch: 'Pendiente de coincidencia', pendingMatchWithCreditCard: 'Recibo pendiente de adjuntar con la transacción de la tarjeta', pendingMatchWithCreditCardDescription: 'Recibo pendiente de adjuntar con la transacción de la tarjeta. Márcalo como efectivo para cancelar.', markAsCash: 'Marcar como efectivo', @@ -934,6 +951,7 @@ const translations = { other: '¿Estás seguro de que quieres eliminar estas solicitudes?', }), settledExpensify: 'Pagado', + done: 'Listo', settledElsewhere: 'Pagado de otra forma', individual: 'Individual', business: 'Empresa', @@ -1552,6 +1570,7 @@ const translations = { }, frequencyDescription: 'Elige la frecuencia de presentación automática de gastos, o preséntalos manualmente', frequencies: { + instant: 'Instante', weekly: 'Semanal', monthly: 'Mensual', twiceAMonth: 'Dos veces al mes', @@ -2635,6 +2654,10 @@ const translations = { title: 'Comienza con Expensify Travel', message: 'Tendrás que usar tu correo electrónico laboral (por ejemplo, nombre@empresa.com) con Expensify Travel, no tu correo personal (por ejemplo, nombre@gmail.com).', }, + maintenance: { + title: '¡Expensify Travel está recibiendo una actualización! 🚀', + message: `No estará disponible del 23 al 24 de febrero, pero volverá mejor que nunca después de eso. Si necesitas ayuda con un viaje actual, por favor llama al +1 866-296-7768. ¡Gracias!`, + }, }, workspace: { common: { @@ -2875,6 +2898,7 @@ const translations = { itemsDescription: 'Elige cómo gestionar los elementos de QuickBooks Desktop en Expensify.', }, qbo: { + connectedTo: 'Conectado a', importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Online a Expensify.', classes: 'Clases', locations: 'Lugares', @@ -3720,7 +3744,8 @@ const translations = { deleteFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.', categoryName: 'Nombre de la categoría', requiresCategory: 'Los miembros deben clasificar todos los gastos', - needCategoryForExportToIntegration: 'Se requiere una categoría en cada gasto para poder exportarlo a', + needCategoryForExportToIntegration: ({connectionName}: NeedCategoryForExportToIntegrationParams) => + `Todos los gastos deben estar categorizados para poder exportar a ${connectionName}.`, subtitle: 'Obtén una visión general de dónde te gastas el dinero. Utiliza las categorías predeterminadas o añade las tuyas propias.', emptyCategories: { title: 'No has creado ninguna categoría', @@ -4847,7 +4872,49 @@ const translations = { public_announce: 'Anuncio Público', }, }, + workspaceApprovalModes: { + submitAndClose: 'Enviar y Cerrar', + submitAndApprove: 'Enviar y Aprobar', + advanced: 'AVANZADO', + dynamictExternal: 'DINÁMICO_EXTERNO', + smartReport: 'INFORME_INTELIGENTE', + billcom: 'BILLCOM', + }, workspaceActions: { + addCategory: ({categoryName}: UpdatedPolicyCategoryParams) => `añadió la categoría "${categoryName}""`, + deleteCategory: ({categoryName}: UpdatedPolicyCategoryParams) => `eliminó la categoría "${categoryName}"`, + updateCategory: ({oldValue, categoryName}: UpdatedPolicyCategoryParams) => `${oldValue ? 'deshabilitó' : 'habilitó'} la categoría "${categoryName}"`, + setCategoryName: ({oldName, newName}: UpdatedPolicyCategoryNameParams) => `renombró la categoría "${oldName}" a "${newName}`, + addTag: ({tagListName, tagName}: UpdatedPolicyTagParams) => `añadió la etiqueta "${tagName}" a la lista "${tagListName}"`, + updateTagName: ({tagListName, newName, oldName}: UpdatedPolicyTagNameParams) => `actualizó la lista de etiquetas "${tagListName}" cambiando la etiqueta "${oldName}" a "${newName}"`, + updateTagEnabled: ({tagListName, tagName, enabled}: UpdatedPolicyTagParams) => `${enabled ? 'habilitó' : 'deshabilitó'} la etiqueta "${tagName}" en la lista "${tagListName}"`, + deleteTag: ({tagListName, tagName}: UpdatedPolicyTagParams) => `eliminó la etiqueta "${tagName}" de la lista "${tagListName}"`, + updateTag: ({tagListName, newValue, tagName, updatedField, oldValue}: UpdatedPolicyTagFieldParams) => { + if (oldValue) { + return `actualizó la etiqueta "${tagName}" en la lista "${tagListName}" cambiando el ${updatedField} a "${newValue}" (previamente "${oldValue}")`; + } + return `actualizó la etiqueta "${tagName}" en la lista "${tagListName}" añadiendo un ${updatedField} de "${newValue}"`; + }, + addCustomUnitRate: ({customUnitName, rateName}: AddedPolicyCustomUnitRateParams) => `añadió una nueva tasa de "${rateName}" para "${customUnitName}"`, + addedReportField: ({fieldType, fieldName}: AddedOrDeletedPolicyReportFieldParams) => `añadió el campo de informe ${fieldType} "${fieldName}"`, + updateReportFieldDefaultValue: ({defaultValue, fieldName}: UpdatedPolicyReportFieldDefaultValueParams) => + `estableció el valor predeterminado del campo de informe "${fieldName}" en "${defaultValue}"`, + deleteReportField: ({fieldType, fieldName}: AddedOrDeletedPolicyReportFieldParams) => `eliminó el campo de informe ${fieldType} "${fieldName}"`, + preventSelfApproval: ({oldValue, newValue}: UpdatedPolicyPreventSelfApprovalParams) => + `actualizó "Evitar la autoaprobación" a "${newValue === 'true' ? 'Habilitada' : 'Deshabilitada'}" (previamente "${oldValue === 'true' ? 'Habilitada' : 'Deshabilitada'}")`, + updateMaxExpenseAmountNoReceipt: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `cambió el monto máximo de gasto requerido sin recibo a ${newValue} (previamente ${oldValue})`, + updateMaxExpenseAmount: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `cambió el monto máximo de gasto para violaciones a ${newValue} (previamente ${oldValue})`, + updateMaxExpenseAge: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `actualizó "Antigüedad máxima de gastos (días)" a "${newValue}" (previamente "${oldValue === 'false' ? CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE : oldValue}")`, + updateDefaultBillable: ({oldValue, newValue}: UpdatedPolicyFieldWithNewAndOldValueParams) => + `actualizó "Volver a facturar gastos a clientes" a "${newValue}" (previamente "${oldValue}")`, + updateDefaultTitleEnforced: ({value}: UpdatedPolicyFieldWithValueParam) => `cambió "Requerir título predeterminado de informe" a ${value ? 'activado' : 'desactivado'}`, + updateWorkspaceDescription: ({newDescription, oldDescription}: UpdatedPolicyDescriptionParams) => + !oldDescription + ? `estableció la descripción de este espacio de trabajo como "${newDescription}"` + : `actualizó la descripción de este espacio de trabajo a "${newDescription}" (previamente "${oldDescription}")`, renamedWorkspaceNameAction: ({oldName, newName}: RenamedRoomActionParams) => `actualizó el nombre de este espacio de trabajo a "${newName}" (previamente "${oldName}")`, removedFromApprovalWorkflow: ({submittersNames}: RemovedFromApprovalWorkflowParams) => { let joinedNames = ''; @@ -4863,6 +4930,12 @@ const translations = { other: `te eliminó de los flujos de trabajo de aprobaciones y de los chats del espacio de trabajo de ${joinedNames}. Los informes enviados anteriormente seguirán estando disponibles para su aprobación en tu bandeja de entrada.`, }; }, + demotedFromWorkspace: ({policyName, oldRole}: DemotedFromWorkspaceParams) => + `cambió tu rol en ${policyName} de ${oldRole} a miembro. Te eliminamos de todos los chats del espacio de trabajo, excepto el suyo.`, + updatedWorkspaceCurrencyAction: ({oldCurrency, newCurrency}: UpdatedPolicyCurrencyParams) => `actualizó la moneda predeterminada a ${newCurrency} (previamente ${oldCurrency})`, + updatedWorkspaceFrequencyAction: ({oldFrequency, newFrequency}: UpdatedPolicyFrequencyParams) => + `actualizó la frecuencia de generación automática de informes a "${newFrequency}" (previamente "${oldFrequency}")`, + updateApprovalMode: ({newValue, oldValue}: ChangeFieldParams) => `actualizó el modo de aprobación a "${newValue}" (previamente "${oldValue}")`, upgradedWorkspace: 'mejoró este espacio de trabajo al plan Controlar', downgradedWorkspace: 'bajó de categoría este espacio de trabajo al plan Recopilar', }, @@ -5119,7 +5192,8 @@ const translations = { nonReimbursableLink: 'Ver los gastos de la tarjeta de empresa.', pending: ({label}: ExportedToIntegrationParams) => `comenzó a exportar este informe a ${label}...`, }, - integrationsMessage: ({label, errorMessage}: IntegrationSyncFailedParams) => `no se pudo exportar este informe a ${label} ("${errorMessage}")`, + integrationsMessage: ({label, errorMessage, linkText, linkURL}: IntegrationSyncFailedParams) => + `no se pudo exportar este informe a ${label} ("${errorMessage} ${linkText ? `${linkText}` : ''}")`, managerAttachReceipt: `agregó un recibo`, managerDetachReceipt: `quitó un recibo`, markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `pagó ${currency}${amount} en otro lugar`, @@ -5136,16 +5210,11 @@ const translations = { stripePaid: ({amount, currency}: StripePaidParams) => `pagado ${currency}${amount}`, takeControl: `tomó el control`, integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `no se pudo sincronizar con ${label}${errorMessage ? ` ("${errorMessage}")` : ''}`, - addEmployee: ({email, role}: AddEmployeeParams) => `agregó a ${email} como ${role === 'miembro' || role === 'user' ? 'miembro' : 'administrador'}`, - updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => - `actualizó el rol ${email} a ${newRole === 'miembro' || newRole === 'user' ? 'miembro' : 'administrador'} (previamente ${ - currentRole === 'miembro' || currentRole === 'user' ? 'miembro' : 'administrador' - })`, + addEmployee: ({email, role}: AddEmployeeParams) => `agregó a ${email} como ${role}`, + updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => `actualizó el rol ${email} a ${newRole} (previamente ${currentRole})`, leftWorkspace: ({nameOrEmail}: LeftWorkspaceParams) => `${nameOrEmail} salió del espacio de trabajo`, - removeMember: ({email, role}: AddEmployeeParams) => `eliminado ${role === 'miembro' || role === 'user' ? 'miembro' : 'administrador'} ${email}`, + removeMember: ({email, role}: AddEmployeeParams) => `eliminado ${role} ${email}`, removedConnection: ({connectionName}: ConnectionNameParams) => `eliminó la conexión a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, - updateAutoReportingFrequency: ({oldFrequency, newFrequency}: UpdateAutoReportingFrequencyParams) => - `actualizó la frecuencia de envíos a "${newFrequency}" (previamente "${oldFrequency}")`, }, }, }, diff --git a/src/languages/params.ts b/src/languages/params.ts index 2a04be84ae21..d9ab6a5296a8 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -293,6 +293,34 @@ type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string type ChangePolicyParams = {fromPolicy: string; toPolicy: string}; +type UpdatedPolicyDescriptionParams = {oldDescription: string; newDescription: string}; + +type UpdatedPolicyCurrencyParams = {oldCurrency: string; newCurrency: string}; + +type UpdatedPolicyCategoryParams = {categoryName: string; oldValue?: boolean}; + +type UpdatedPolicyTagParams = {tagListName: string; tagName: string; enabled?: boolean}; + +type UpdatedPolicyTagNameParams = {oldName: string; newName: string; tagListName: string}; + +type UpdatedPolicyTagFieldParams = {oldValue?: string; newValue: string; tagName: string; tagListName: string; updatedField: string}; + +type UpdatedPolicyCategoryNameParams = {oldName: string; newName?: string}; + +type AddedPolicyCustomUnitRateParams = {customUnitName: string; rateName: string}; + +type AddedOrDeletedPolicyReportFieldParams = {fieldType: string; fieldName?: string}; + +type UpdatedPolicyReportFieldDefaultValueParams = {fieldName?: string; defaultValue?: string}; + +type UpdatedPolicyPreventSelfApprovalParams = {oldValue: string; newValue: string}; + +type UpdatedPolicyFieldWithNewAndOldValueParams = {oldValue: string; newValue: string}; + +type UpdatedPolicyFieldWithValueParam = {value: boolean}; + +type UpdatedPolicyFrequencyParams = {oldFrequency: string; newFrequency: string}; + type ChangeTypeParams = {oldType: string; newType: string}; type DelegateSubmitParams = {delegateUser: string; originalManager: string}; @@ -349,11 +377,6 @@ type ConnectionNameParams = { connectionName: AllConnectionName; }; -type UpdateAutoReportingFrequencyParams = { - oldFrequency: string; - newFrequency: string; -}; - type LastSyncDateParams = { connectionName: string; formattedDate: string; @@ -369,7 +392,7 @@ type ExportAgainModalDescriptionParams = { connectionName: ConnectionName; }; -type IntegrationSyncFailedParams = {label: string; errorMessage: string}; +type IntegrationSyncFailedParams = {label: string; errorMessage: string; linkText?: string; linkURL?: string}; type AddEmployeeParams = {email: string; role: string}; @@ -395,6 +418,8 @@ type LowerUpperParams = {lower: string; upper: string}; type CategoryNameParams = {categoryName: string}; +type NeedCategoryForExportToIntegrationParams = {connectionName: string}; + type TaxAmountParams = {taxAmount: number}; type SecondaryLoginParams = {secondaryLogin: string}; @@ -515,6 +540,11 @@ type RemovedFromApprovalWorkflowParams = { submittersNames: string[]; }; +type DemotedFromWorkspaceParams = { + policyName: string; + oldRole: string; +}; + type IntegrationExportParams = { integration: string; type?: string; @@ -631,6 +661,7 @@ export type { ConnectionParams, IntegrationExportParams, RemovedFromApprovalWorkflowParams, + DemotedFromWorkspaceParams, DefaultAmountParams, AutoPayApprovedReportsLimitErrorParams, FeatureNameParams, @@ -815,7 +846,6 @@ export type { UpdateRoleParams, LeftWorkspaceParams, RemoveMemberParams, - UpdateAutoReportingFrequencyParams, DateParams, FiltersAmountBetweenParams, StatementPageTitleParams, @@ -834,8 +864,23 @@ export type { CompanyNameParams, CustomUnitRateParams, ChatWithAccountManagerParams, + UpdatedPolicyCurrencyParams, + UpdatedPolicyFrequencyParams, + UpdatedPolicyCategoryParams, + UpdatedPolicyCategoryNameParams, + UpdatedPolicyPreventSelfApprovalParams, + UpdatedPolicyFieldWithNewAndOldValueParams, + UpdatedPolicyFieldWithValueParam, + UpdatedPolicyDescriptionParams, EditDestinationSubtitleParams, FlightLayoverParams, + AddedOrDeletedPolicyReportFieldParams, + AddedPolicyCustomUnitRateParams, + UpdatedPolicyTagParams, + UpdatedPolicyTagNameParams, + UpdatedPolicyTagFieldParams, + UpdatedPolicyReportFieldDefaultValueParams, SubmitsToParams, SettlementDateParams, + NeedCategoryForExportToIntegrationParams, }; diff --git a/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts b/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts deleted file mode 100644 index 94e45a29b728..000000000000 --- a/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts +++ /dev/null @@ -1,14 +0,0 @@ -type RequestPhysicalExpensifyCardParams = { - authToken: string; - legalFirstName: string; - legalLastName: string; - phoneNumber: string; - addressCity: string; - addressCountry: string; - addressState: string; - addressStreet: string; - addressZip: string; - validateCode: string; -}; - -export default RequestPhysicalExpensifyCardParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index dacad199d00f..408c368d4f63 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -51,7 +51,6 @@ export type {default as ReferTeachersUniteVolunteerParams} from './ReferTeachers export type {default as ReportVirtualExpensifyCardFraudParams} from './ReportVirtualExpensifyCardFraudParams'; export type {default as RequestContactMethodValidateCodeParams} from './RequestContactMethodValidateCodeParams'; export type {default as RequestNewValidateCodeParams} from './RequestNewValidateCodeParams'; -export type {default as RequestPhysicalExpensifyCardParams} from './RequestPhysicalExpensifyCardParams'; export type {default as RequestReplacementExpensifyCardParams} from './RequestReplacementExpensifyCardParams'; export type {default as RequestUnlinkValidationLinkParams} from './RequestUnlinkValidationLinkParams'; export type {default as RequestAccountValidationLinkParams} from './RequestAccountValidationLinkParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f642e2711724..b8d150672e7c 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -87,7 +87,6 @@ const WRITE_COMMANDS = { VERIFY_IDENTITY: 'VerifyIdentity', ACCEPT_WALLET_TERMS: 'AcceptWalletTerms', ANSWER_QUESTIONS_FOR_WALLET: 'AnswerQuestionsForWallet', - REQUEST_PHYSICAL_EXPENSIFY_CARD: 'RequestPhysicalExpensifyCard', LOG_OUT: 'LogOut', REQUEST_ACCOUNT_VALIDATION_LINK: 'RequestAccountValidationLink', REQUEST_NEW_VALIDATE_CODE: 'RequestNewValidateCode', @@ -533,7 +532,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.VERIFY_IDENTITY]: Parameters.VerifyIdentityParams; [WRITE_COMMANDS.ACCEPT_WALLET_TERMS]: Parameters.AcceptWalletTermsParams; [WRITE_COMMANDS.ANSWER_QUESTIONS_FOR_WALLET]: Parameters.AnswerQuestionsForWalletParams; - [WRITE_COMMANDS.REQUEST_PHYSICAL_EXPENSIFY_CARD]: Parameters.RequestPhysicalExpensifyCardParams; [WRITE_COMMANDS.LOG_OUT]: Parameters.LogOutParams; [WRITE_COMMANDS.REQUEST_ACCOUNT_VALIDATION_LINK]: Parameters.RequestAccountValidationLinkParams; [WRITE_COMMANDS.REQUEST_NEW_VALIDATE_CODE]: Parameters.RequestNewValidateCodeParams; diff --git a/src/libs/Browser/index.ts b/src/libs/Browser/index.ts index aeec4f4def4a..74427ea41be0 100644 --- a/src/libs/Browser/index.ts +++ b/src/libs/Browser/index.ts @@ -1,4 +1,4 @@ -import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; +import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsModernSafari, IsSafari, OpenRouteInDesktopApp} from './types'; const getBrowser: GetBrowser = () => ''; @@ -14,6 +14,8 @@ const isChromeIOS: IsChromeIOS = () => false; const isSafari: IsSafari = () => false; +const isModernSafari: IsModernSafari = () => false; + const openRouteInDesktopApp: OpenRouteInDesktopApp = () => {}; -export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp}; +export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isModernSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp}; diff --git a/src/libs/Browser/index.website.ts b/src/libs/Browser/index.website.ts index 2f0ba7f0e289..5c2cf2e9a7c7 100644 --- a/src/libs/Browser/index.website.ts +++ b/src/libs/Browser/index.website.ts @@ -1,7 +1,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; +import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsModernSafari, IsSafari, OpenRouteInDesktopApp} from './types'; let isOpenRouteInDesktop = false; /** @@ -77,6 +77,16 @@ const isChromeIOS: IsChromeIOS = () => { const isSafari: IsSafari = () => getBrowser() === 'safari' || isMobileSafari(); +/** + * Checks if the requesting user agent is a modern version of Safari on iOS (version 18 or higher). + */ +const isModernSafari: IsModernSafari = (): boolean => { + const version = navigator.userAgent.match(/OS (\d+_\d+)/); + const iosVersion = version ? version[1].replace('_', '.') : ''; + + return parseFloat(iosVersion) >= 18; +}; + /** * The session information needs to be passed to the Desktop app, and the only way to do that is by using query params. There is no other way to transfer the data. */ @@ -127,4 +137,16 @@ const resetIsOpeningRouteInDesktop = () => { isOpenRouteInDesktop = false; }; -export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp, isOpeningRouteInDesktop, resetIsOpeningRouteInDesktop}; +export { + getBrowser, + isMobile, + isMobileSafari, + isMobileWebKit, + isSafari, + isModernSafari, + isMobileChrome, + isChromeIOS, + openRouteInDesktopApp, + isOpeningRouteInDesktop, + resetIsOpeningRouteInDesktop, +}; diff --git a/src/libs/Browser/types.ts b/src/libs/Browser/types.ts index ff0de91e7b78..01c0decbf330 100644 --- a/src/libs/Browser/types.ts +++ b/src/libs/Browser/types.ts @@ -12,6 +12,8 @@ type IsChromeIOS = () => boolean; type IsSafari = () => boolean; +type IsModernSafari = () => boolean; + type OpenRouteInDesktopApp = (shortLivedAuthToken?: string, email?: string, initialRoute?: string) => void; -export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, IsChromeIOS, OpenRouteInDesktopApp}; +export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, IsModernSafari, IsChromeIOS, OpenRouteInDesktopApp}; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 55c404459488..804e5f78e52f 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -943,6 +943,14 @@ function getFormattedDateRangeForPerDiem(date1: Date, date2: Date): string { return `${format(date1, 'MMM d, yyyy')} - ${format(date2, 'MMM d, yyyy')}`; } +/** + * Checks if the current time falls within the specified time range. + */ +const isCurrentTimeWithinRange = (startTime: string, endTime: string): boolean => { + const now = Date.now(); + return isAfter(now, new Date(startTime)) && isBefore(now, new Date(endTime)); +}; + const DateUtils = { isDate, formatToDayOfWeek, @@ -998,6 +1006,7 @@ const DateUtils = { getFormattedDuration, isFutureDay, getFormattedDateRangeForPerDiem, + isCurrentTimeWithinRange, }; export default DateUtils; diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts deleted file mode 100644 index 8dc46204db3c..000000000000 --- a/src/libs/GetPhysicalCardUtils.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; -import type {GetPhysicalCardForm} from '@src/types/form'; -import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx'; -import * as LoginUtils from './LoginUtils'; -import Navigation from './Navigation/Navigation'; -import * as PersonalDetailsUtils from './PersonalDetailsUtils'; -import * as UserUtils from './UserUtils'; - -function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { - const {legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; - const address = PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails); - - if (!legalFirstName && !legalLastName) { - return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); - } - if (!phoneNumber || !LoginUtils.validateNumber(phoneNumber)) { - return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); - } - if (!(address?.street && address?.city && address?.state && address?.country && address?.zip)) { - return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.getRoute(domain); - } - - return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain); -} - -function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxEntry) { - Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails)); -} - -/** - * - * @param currentRoute - * @param domain - * @param privatePersonalDetails - * @param loginList - * @returns - */ -function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry) { - const expectedRoute = getCurrentRoute(domain, privatePersonalDetails); - - // If the user is on the current route or the current route is confirmation, then he's allowed to stay on the current step - if ([currentRoute, ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain)].includes(expectedRoute)) { - return; - } - - // Redirect the user if he's not allowed to be on the current step - Navigation.goBack(expectedRoute); -} - -/** - * - * @param draftValues - * @param privatePersonalDetails - * @returns - */ -function getUpdatedDraftValues(draftValues: OnyxEntry, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry): GetPhysicalCardForm { - const {legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; - const address = PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails); - - return { - /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ - // we do not need to use nullish coalescing here because we want to allow empty strings - legalFirstName: draftValues?.legalFirstName || legalFirstName || '', - legalLastName: draftValues?.legalLastName || legalLastName || '', - addressLine1: draftValues?.addressLine1 || address?.street.split('\n')[0] || '', - addressLine2: draftValues?.addressLine2 || address?.street.split('\n')[1] || '', - city: draftValues?.city || address?.city || '', - country: draftValues?.country || address?.country || '', - phoneNumber: draftValues?.phoneNumber || phoneNumber || UserUtils.getSecondaryPhoneLogin(loginList) || '', - state: draftValues?.state || address?.state || '', - zipPostCode: draftValues?.zipPostCode || address?.zip || '', - /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ - }; -} - -/** - * - * @param draftValues - * @returns - */ -function getUpdatedPrivatePersonalDetails(draftValues: OnyxEntry, privatePersonalDetails: OnyxEntry): PrivatePersonalDetails { - const {addressLine1, addressLine2, city = '', country = '', legalFirstName, legalLastName, phoneNumber, state = '', zipPostCode = ''} = draftValues ?? {}; - return { - legalFirstName, - legalLastName, - phoneNumber, - addresses: [...(privatePersonalDetails?.addresses ?? []), {street: PersonalDetailsUtils.getFormattedStreet(addressLine1, addressLine2), city, country, state, zip: zipPostCode}], - }; -} - -export {getUpdatedDraftValues, getUpdatedPrivatePersonalDetails, goToNextPhysicalCardRoute, setCurrentRoute}; -export type {PrivatePersonalDetails}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 404e60609eaf..f2f2ae9631f5 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -254,10 +254,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default, [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD_CONFIRMATION]: () => require('../../../../pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage').default, [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: () => require('../../../../pages/settings/Wallet/ActivatePhysicalCardPage').default, - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME]: () => require('../../../../pages/settings/Wallet/Card/GetPhysicalCardName').default, - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE]: () => require('../../../../pages/settings/Wallet/Card/GetPhysicalCardPhone').default, - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS]: () => require('../../../../pages/settings/Wallet/Card/GetPhysicalCardAddress').default, - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM]: () => require('../../../../pages/settings/Wallet/Card/GetPhysicalCardConfirm').default, [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: () => require('../../../../pages/settings/Wallet/TransferBalancePage').default, [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ChooseTransferAccountPage').default, [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: () => require('../../../../pages/EnablePayments/EnablePayments').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 206953f074e4..71d06b9048e1 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -28,10 +28,6 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial['config'] = { path: ROUTES.SETTINGS_REPORT_FRAUD_CONFIRMATION.route, exact: true, }, - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME]: { - path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.route, - exact: true, - }, - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE]: { - path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.route, - exact: true, - }, - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS]: { - path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.route, - exact: true, - }, - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM]: { - path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.route, - exact: true, - }, [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: { path: ROUTES.SETTINGS_ENABLE_PAYMENTS, exact: true, @@ -1566,7 +1550,7 @@ const config: LinkingOptions['config'] = { exact: true, }, [SCREENS.SETTINGS.SAVE_THE_WORLD]: ROUTES.SETTINGS_SAVE_THE_WORLD, - [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: ROUTES.SETTINGS_SUBSCRIPTION, + [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: {path: ROUTES.SETTINGS_SUBSCRIPTION.route}, [SCREENS.SETTINGS.PREFERENCES.ROOT]: { path: ROUTES.SETTINGS_PREFERENCES, // exact: true, diff --git a/src/libs/Navigation/navigateAfterInteraction/index.ios.ts b/src/libs/Navigation/navigateAfterInteraction/index.ios.ts new file mode 100644 index 000000000000..6faedf3c7616 --- /dev/null +++ b/src/libs/Navigation/navigateAfterInteraction/index.ios.ts @@ -0,0 +1,14 @@ +import {InteractionManager} from 'react-native'; +import Navigation from '@libs/Navigation/Navigation'; + +/** + * On iOS, the navigation transition can sometimes break other animations, such as the closing modal. + * In this case we need to wait for the animation to be complete before executing the navigation + */ +function navigateAfterInteraction(callback: () => void) { + InteractionManager.runAfterInteractions(() => { + Navigation.setNavigationActionToMicrotaskQueue(callback); + }); +} + +export default navigateAfterInteraction; diff --git a/src/libs/Navigation/navigateAfterInteraction/index.ts b/src/libs/Navigation/navigateAfterInteraction/index.ts new file mode 100644 index 000000000000..34ef4e1fce33 --- /dev/null +++ b/src/libs/Navigation/navigateAfterInteraction/index.ts @@ -0,0 +1,5 @@ +function navigateAfterInteraction(callback: () => void) { + callback(); +} + +export default navigateAfterInteraction; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index fa274d1ecf7e..d1aa0b331d60 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -129,29 +129,6 @@ type SettingsNavigatorParamList = { /** cardID of selected card */ cardID: string; }; - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME]: { - /** domain of selected card */ - domain: string; - backTo?: Routes; - }; - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE]: { - /** domain of selected card */ - domain: string; - backTo?: Routes; - }; - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS]: { - /** Currently selected country */ - country: string; - /** domain of selected card */ - domain: string; - backTo?: Routes; - }; - [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM]: { - /** Currently selected country */ - country: string; - /** domain of selected card */ - domain: string; - }; [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: { policyID: string; }; @@ -322,7 +299,7 @@ type SettingsNavigatorParamList = { tagName: string; backTo?: Routes; }; - [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: {backTo?: Routes}; [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: { canChangeSize: 0 | 1; }; @@ -903,14 +880,17 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.EXPENSIFY_CARD_NAME]: { policyID: string; cardID: string; + backTo?: Routes; }; [SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT]: { policyID: string; cardID: string; + backTo?: Routes; }; [SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT_TYPE]: { policyID: string; cardID: string; + backTo?: Routes; }; [SCREENS.EXPENSIFY_CARD.EXPENSIFY_CARD_DETAILS]: { policyID: string; @@ -920,14 +900,17 @@ type SettingsNavigatorParamList = { [SCREENS.EXPENSIFY_CARD.EXPENSIFY_CARD_NAME]: { policyID: string; cardID: string; + backTo?: Routes; }; [SCREENS.EXPENSIFY_CARD.EXPENSIFY_CARD_LIMIT]: { policyID: string; cardID: string; + backTo?: Routes; }; [SCREENS.EXPENSIFY_CARD.EXPENSIFY_CARD_LIMIT_TYPE]: { policyID: string; cardID: string; + backTo?: Routes; }; [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: { policyID: string; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 76986c7cad98..66b70d90c171 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -792,7 +792,6 @@ function createOption( if (report) { result.isChatRoom = reportUtilsIsChatRoom(report); result.isDefaultRoom = isDefaultRoom(report); - // eslint-disable-next-line @typescript-eslint/naming-convention result.private_isArchived = getReportNameValuePairs(report.reportID)?.private_isArchived; result.isExpenseReport = isExpenseReport(report); result.isInvoiceRoom = isInvoiceRoom(report); diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 9ed5881513f2..eda98f2cc70b 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -14,12 +14,13 @@ function canUseSpotnanaTravel(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.SPOTNANA_TRAVEL) || canUseAllBetas(betas); } -function canUseNetSuiteUSATax(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas); +function isBlockedFromSpotnanaTravel(betas: OnyxEntry): boolean { + // Don't check for all betas or nobody can use test travel on dev + return !!betas?.includes(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); } -function canUseCategoryAndTagApprovers(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.CATEGORY_AND_TAG_APPROVERS) || canUseAllBetas(betas); +function canUseNetSuiteUSATax(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas); } function canUsePerDiem(betas: OnyxEntry): boolean { @@ -57,8 +58,8 @@ export default { canUseDefaultRooms, canUseLinkPreviews, canUseSpotnanaTravel, + isBlockedFromSpotnanaTravel, canUseNetSuiteUSATax, - canUseCategoryAndTagApprovers, canUsePerDiem, canUseMergeAccounts, canUseManagerMcTest, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 607de0fee0e7..ff09cd63bcee 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -11,11 +11,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Locale, OnyxInputOrEntry, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; import type {JoinWorkspaceResolution, OriginalMessageChangeLog, OriginalMessageExportIntegration} from '@src/types/onyx/OriginalMessage'; +import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {Message, OldDotReportAction, OriginalMessage, ReportActions} from '@src/types/onyx/ReportAction'; import type ReportActionName from '@src/types/onyx/ReportActionName'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {convertToDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {getEnvironmentURL} from './Environment/Environment'; import getBase62ReportID from './getBase62ReportID'; @@ -30,6 +32,7 @@ import {getPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils} from './PolicyUtil import type {getReportName, OptimisticIOUReportAction, PartialReportAction} from './ReportUtils'; import StringUtils from './StringUtils'; import {isOnHoldByTransactionID} from './TransactionUtils'; +import {getReportFieldAlternativeTextTranslationKey} from './WorkspaceReportFieldUtils'; type LastVisibleMessage = { lastMessageText: string; @@ -1368,7 +1371,9 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD case CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE: { const {result, label} = originalMessage; const errorMessage = result?.messages?.join(', ') ?? ''; - return translateLocal('report.actions.type.integrationsMessage', {errorMessage, label}); + const linkText = result?.link?.text ?? ''; + const linkURL = result?.link?.url ?? ''; + return translateLocal('report.actions.type.integrationsMessage', {errorMessage, label, linkText, linkURL}); } case CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT: return translateLocal('report.actions.type.managerAttachReceipt'); @@ -1743,7 +1748,7 @@ function getPolicyChangeLogAddEmployeeMessage(reportAction: OnyxInputOrEntry): string { - if (!isPolicyChangeLogDeleteMemberMessage(reportAction)) { - return ''; +function getWorkspaceNameUpdatedMessage(action: ReportAction) { + const {oldName, newName} = getOriginalMessage(action as ReportAction) ?? {}; + const message = oldName && newName ? translateLocal('workspaceActions.renamedWorkspaceNameAction', {oldName, newName}) : getReportActionText(action); + return message; +} + +function getWorkspaceDescriptionUpdatedMessage(action: ReportAction) { + const {oldDescription, newDescription} = getOriginalMessage(action as ReportAction) ?? {}; + const message = + typeof oldDescription === 'string' && newDescription ? translateLocal('workspaceActions.updateWorkspaceDescription', {newDescription, oldDescription}) : getReportActionText(action); + return message; +} + +function getWorkspaceCurrencyUpdateMessage(action: ReportAction) { + const {oldCurrency, newCurrency} = getOriginalMessage(action as ReportAction) ?? {}; + const message = oldCurrency && newCurrency ? translateLocal('workspaceActions.updatedWorkspaceCurrencyAction', {oldCurrency, newCurrency}) : getReportActionText(action); + return message; +} + +type AutoReportingFrequencyKey = ValueOf; +type AutoReportingFrequencyDisplayNames = Record; + +const getAutoReportingFrequencyDisplayNames = (): AutoReportingFrequencyDisplayNames => ({ + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY]: translateLocal('workflowsPage.frequencies.monthly'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE]: translateLocal('workflowsPage.frequencies.daily'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY]: translateLocal('workflowsPage.frequencies.weekly'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY]: translateLocal('workflowsPage.frequencies.twiceAMonth'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP]: translateLocal('workflowsPage.frequencies.byTrip'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL]: translateLocal('workflowsPage.frequencies.manually'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT]: translateLocal('workflowsPage.frequencies.instant'), +}); + +function getWorkspaceFrequencyUpdateMessage(action: ReportAction): string { + const {oldFrequency, newFrequency} = getOriginalMessage(action as ReportAction) ?? {}; + + if (!oldFrequency || !newFrequency) { + return getReportActionText(action); } - const originalMessage = getOriginalMessage(reportAction); - const email = originalMessage?.email ?? ''; - const role = originalMessage?.role ?? ''; - return translateLocal('report.actions.type.removeMember', {email, role}); + + const frequencyDisplayNames = getAutoReportingFrequencyDisplayNames(); + const oldFrequencyTranslation = frequencyDisplayNames[oldFrequency]?.toLowerCase(); + const newFrequencyTranslation = frequencyDisplayNames[newFrequency]?.toLowerCase(); + + if (!oldFrequencyTranslation || !newFrequencyTranslation) { + return getReportActionText(action); + } + + return translateLocal('workspaceActions.updatedWorkspaceFrequencyAction', { + oldFrequency: oldFrequencyTranslation, + newFrequency: newFrequencyTranslation, + }); } -function isPolicyChangeLogUpdateAutoReportingFrequencyMessage( - reportAction: OnyxInputOrEntry, -): reportAction is ReportAction { - return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY); +function getWorkspaceCategoryUpdateMessage(action: ReportAction): string { + const {categoryName, oldValue, newName, oldName} = getOriginalMessage(action as ReportAction) ?? {}; + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY && categoryName) { + return translateLocal('workspaceActions.addCategory', { + categoryName, + }); + } + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY && categoryName) { + return translateLocal('workspaceActions.deleteCategory', { + categoryName, + }); + } + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY && categoryName) { + return translateLocal('workspaceActions.updateCategory', { + oldValue: !!oldValue, + categoryName, + }); + } + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME && oldName && newName) { + return translateLocal('workspaceActions.setCategoryName', { + oldName, + newName, + }); + } + + return getReportActionText(action); } -function getPolicyChangeLogUpdateAutoReportingFrequencyMessage(reportAction: OnyxInputOrEntry): string { - if (!isPolicyChangeLogUpdateAutoReportingFrequencyMessage(reportAction)) { +function getWorkspaceTagUpdateMessage(action: ReportAction): string { + const {tagListName, tagName, enabled, newName, newValue, oldName, oldValue, updatedField} = + getOriginalMessage(action as ReportAction) ?? {}; + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG && tagListName && tagName) { + return translateLocal('workspaceActions.addTag', { + tagListName, + tagName, + }); + } + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAG && tagListName && tagName) { + return translateLocal('workspaceActions.deleteTag', { + tagListName, + tagName, + }); + } + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_ENABLED && tagListName && tagName) { + return translateLocal('workspaceActions.updateTagEnabled', { + tagListName, + tagName, + enabled, + }); + } + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_NAME && tagListName && newName && oldName) { + return translateLocal('workspaceActions.updateTagName', { + tagListName, + newName, + oldName, + }); + } + + if ( + action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG && + tagListName && + typeof oldValue === 'string' && + typeof newValue === 'string' && + tagName && + updatedField + ) { + return translateLocal('workspaceActions.updateTag', { + tagListName, + oldValue, + newValue, + tagName, + updatedField, + }); + } + + return getReportActionText(action); +} + +function getWorkspaceCustomUnitRateAddedMessage(action: ReportAction): string { + const {customUnitName, rateName} = getOriginalMessage(action as ReportAction) ?? {}; + + if (customUnitName && rateName) { + return translateLocal('workspaceActions.addCustomUnitRate', { + customUnitName, + rateName, + }); + } + + return getReportActionText(action); +} + +function getWorkspaceReportFieldAddMessage(action: ReportAction): string { + const {fieldName, fieldType} = getOriginalMessage(action as ReportAction) ?? {}; + + if (fieldName && fieldType) { + return translateLocal('workspaceActions.addedReportField', { + fieldName, + fieldType: translateLocal(getReportFieldAlternativeTextTranslationKey(fieldType as PolicyReportFieldType)), + }); + } + + return getReportActionText(action); +} + +function getWorkspaceReportFieldUpdateMessage(action: ReportAction): string { + const {updateType, fieldName, defaultValue} = getOriginalMessage(action as ReportAction) ?? {}; + + if (updateType === 'updatedDefaultValue' && fieldName && defaultValue) { + return translateLocal('workspaceActions.updateReportFieldDefaultValue', { + fieldName, + defaultValue, + }); + } + + return getReportActionText(action); +} + +function getWorkspaceReportFieldDeleteMessage(action: ReportAction): string { + const {fieldType, fieldName} = getOriginalMessage(action as ReportAction) ?? {}; + + if (fieldType && fieldName) { + return translateLocal('workspaceActions.deleteReportField', { + fieldName, + fieldType: translateLocal(getReportFieldAlternativeTextTranslationKey(fieldType as PolicyReportFieldType)), + }); + } + + return getReportActionText(action); +} + +function getWorkspaceUpdateFieldMessage(action: ReportAction): string { + const {newValue, oldValue, updatedField} = getOriginalMessage(action as ReportAction) ?? {}; + + const newValueTranslationKey = CONST.POLICY.APPROVAL_MODE_TRANSLATION_KEYS[newValue as keyof typeof CONST.POLICY.APPROVAL_MODE_TRANSLATION_KEYS]; + const oldValueTranslationKey = CONST.POLICY.APPROVAL_MODE_TRANSLATION_KEYS[oldValue as keyof typeof CONST.POLICY.APPROVAL_MODE_TRANSLATION_KEYS]; + + if (updatedField && updatedField === CONST.POLICY.COLLECTION_KEYS.APPROVAL_MODE && oldValueTranslationKey && newValueTranslationKey) { + return translateLocal('workspaceActions.updateApprovalMode', { + newValue: translateLocal(`workspaceApprovalModes.${newValueTranslationKey}` as TranslationPaths), + oldValue: translateLocal(`workspaceApprovalModes.${oldValueTranslationKey}` as TranslationPaths), + fieldName: updatedField, + }); + } + + if (updatedField && updatedField === CONST.POLICY.EXPENSE_REPORT_RULES.PREVENT_SELF_APPROVAL && typeof oldValue === 'string' && typeof newValue === 'string') { + return translateLocal('workspaceActions.preventSelfApproval', { + oldValue, + newValue, + }); + } + + if (updatedField && updatedField === CONST.POLICY.EXPENSE_REPORT_RULES.MAX_EXPENSE_AGE && typeof oldValue === 'string' && typeof newValue === 'string') { + return translateLocal('workspaceActions.updateMaxExpenseAge', { + oldValue, + newValue, + }); + } + + return getReportActionText(action); +} + +function getPolicyChangeLogMaxExpesnseAmountNoReceiptMessage(action: ReportAction): string { + const {oldMaxExpenseAmountNoReceipt, newMaxExpenseAmountNoReceipt, currency} = + getOriginalMessage(action as ReportAction) ?? {}; + + if (typeof oldMaxExpenseAmountNoReceipt === 'number' && typeof newMaxExpenseAmountNoReceipt === 'number') { + return translateLocal('workspaceActions.updateMaxExpenseAmountNoReceipt', { + oldValue: convertToDisplayString(oldMaxExpenseAmountNoReceipt, currency), + newValue: convertToDisplayString(newMaxExpenseAmountNoReceipt, currency), + }); + } + + return getReportActionText(action); +} + +function getPolicyChangeLogMaxExpenseAmountMessage(action: ReportAction): string { + const {oldMaxExpenseAmount, newMaxExpenseAmount, currency} = + getOriginalMessage(action as ReportAction) ?? {}; + + if (typeof oldMaxExpenseAmount === 'number' && typeof newMaxExpenseAmount === 'number') { + return translateLocal('workspaceActions.updateMaxExpenseAmount', { + oldValue: convertToDisplayString(oldMaxExpenseAmount, currency), + newValue: convertToDisplayString(newMaxExpenseAmount, currency), + }); + } + + return getReportActionText(action); +} + +function getPolicyChangeLogDefaultBillableMessage(action: ReportAction): string { + const {oldDefaultBillable, newDefaultBillable} = getOriginalMessage(action as ReportAction) ?? {}; + + if (typeof oldDefaultBillable === 'string' && typeof newDefaultBillable === 'string') { + return translateLocal('workspaceActions.updateDefaultBillable', { + oldValue: oldDefaultBillable, + newValue: newDefaultBillable, + }); + } + + return getReportActionText(action); +} + +function getPolicyChangeLogDefaultTitleEnforcedMessage(action: ReportAction): string { + const {value} = getOriginalMessage(action as ReportAction) ?? {}; + + if (typeof value === 'boolean') { + return translateLocal('workspaceActions.updateDefaultTitleEnforced', { + value, + }); + } + + return getReportActionText(action); +} + +function getPolicyChangeLogDeleteMemberMessage(reportAction: OnyxInputOrEntry): string { + if (!isPolicyChangeLogDeleteMemberMessage(reportAction)) { return ''; } const originalMessage = getOriginalMessage(reportAction); - const oldFrequency = translateLocal(`workspace.common.frequency.${originalMessage?.oldFrequency}` as TranslationPaths); - const newFrequency = translateLocal(`workspace.common.frequency.${originalMessage?.newFrequency}` as TranslationPaths); - return translateLocal('report.actions.type.updateAutoReportingFrequency', {oldFrequency, newFrequency}); + const email = originalMessage?.email ?? ''; + const role = translateLocal('workspace.common.roleName', {role: originalMessage?.role ?? ''}).toLowerCase(); + return translateLocal('report.actions.type.removeMember', {email, role}); } function getRemovedConnectionMessage(reportAction: OnyxEntry): string { @@ -1835,6 +2099,13 @@ function getRemovedFromApprovalChainMessage(reportAction: OnyxEntry>) { + const originalMessage = getOriginalMessage(reportAction); + const policyName = originalMessage?.policyName ?? translateLocal('workspace.common.workspace'); + const oldRole = translateLocal('workspace.common.roleName', {role: originalMessage?.oldRole}).toLowerCase(); + return translateLocal('workspaceActions.demotedFromWorkspace', {policyName, oldRole}); +} + function isCardIssuedAction(reportAction: OnyxEntry) { return isActionOfType( reportAction, @@ -1964,6 +2235,7 @@ export { getOneTransactionThreadReportID, getOriginalMessage, getRemovedFromApprovalChainMessage, + getDemotedFromWorkspaceMessage, getReportAction, getReportActionHtml, getReportActionMessage, @@ -2042,7 +2314,6 @@ export { getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogChangeRoleMessage, getPolicyChangeLogDeleteMemberMessage, - getPolicyChangeLogUpdateAutoReportingFrequencyMessage, getPolicyChangeLogEmployeeLeftMessage, getRenamedAction, isCardIssuedAction, @@ -2051,6 +2322,21 @@ export { getActionableJoinRequestPendingReportAction, getReportActionsLength, wasActionCreatedWhileOffline, + getWorkspaceCategoryUpdateMessage, + getWorkspaceUpdateFieldMessage, + getWorkspaceNameUpdatedMessage, + getWorkspaceCurrencyUpdateMessage, + getWorkspaceFrequencyUpdateMessage, + getPolicyChangeLogMaxExpesnseAmountNoReceiptMessage, + getPolicyChangeLogMaxExpenseAmountMessage, + getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultTitleEnforcedMessage, + getWorkspaceDescriptionUpdatedMessage, + getWorkspaceReportFieldAddMessage, + getWorkspaceCustomUnitRateAddedMessage, + getWorkspaceTagUpdateMessage, + getWorkspaceReportFieldUpdateMessage, + getWorkspaceReportFieldDeleteMessage, }; export type {LastVisibleMessage}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a7a67f6de26f..a67eba0aec50 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -64,7 +64,7 @@ import {autoSwitchToFocusMode} from './actions/PriorityMode'; import {hasCreditBankAccount} from './actions/ReimbursementAccount/store'; import {handleReportChanged, prepareOnboardingOnyxData} from './actions/Report'; import {isAnonymousUser as isAnonymousUserSession} from './actions/Session'; -import {convertToDisplayString, getCurrencySymbol} from './CurrencyUtils'; +import {convertToDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {hasValidDraftComment} from './DraftCommentUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from './ErrorUtils'; @@ -154,6 +154,7 @@ import { isThreadParentMessage, isTrackExpenseAction, isTransactionThread, + isTripPreview, isUnapprovedAction, isWhisperAction, wasActionTakenByCurrentUser, @@ -718,6 +719,8 @@ type GetReportNameParams = { policies?: SearchPolicy[]; }; +type ReportByPolicyMap = Record; + let currentUserEmail: string | undefined; let currentUserPrivateDomain: string | undefined; let currentUserAccountID: number | undefined; @@ -779,6 +782,7 @@ Onyx.connect({ }); let allReports: OnyxCollection; +let reportsByPolicyID: ReportByPolicyMap; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, @@ -794,12 +798,27 @@ Onyx.connect({ if (!value) { return; } - Object.values(value).forEach((report) => { + + reportsByPolicyID = Object.values(value).reduce((acc, report) => { if (!report) { - return; + return acc; } + handleReportChanged(report); - }); + + // Get all reports, which are the ones that are: + // - Owned by the same user + // - Are either open or submitted + // - Belong to the same workspace + if (report.policyID && report.ownerAccountID === currentUserAccountID && (report.stateNum ?? 0) <= 1) { + if (!acc[report.policyID]) { + acc[report.policyID] = []; + } + acc[report.policyID].push(report); + } + + return acc; + }, {}); }, }); @@ -3286,6 +3305,9 @@ function isHoldCreator(transaction: OnyxEntry, reportID: string | u * 2. Report is settled or it is closed */ function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry, policy: OnyxEntry): boolean { + if (isInvoiceReport(report)) { + return true; + } const isReportSettled = isSettled(report?.reportID); const isReportClosed = isClosedReport(report); const isTitleField = isReportFieldOfTypeTitle(reportField); @@ -3599,14 +3621,14 @@ function canEditFieldOfMoneyRequest(reportAction: OnyxInputOrEntry if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { const isRequestor = currentUserAccountID === reportAction?.actorAccountID; - return !isInvoiceReport(moneyRequestReport) && + return ( + !isInvoiceReport(moneyRequestReport) && !isReceiptBeingScanned(transaction) && !isDistanceRequest(transaction) && !isPerDiemRequest(transaction) && (isAdmin || isManager || isRequestor) && - isDeleteAction - ? isRequestor - : true; + (isDeleteAction ? isRequestor : true) + ); } if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE) { @@ -4465,10 +4487,9 @@ function getReportNameInternal({ const modifiedMessage = ModifiedExpenseMessage.getForReportAction({reportOrID: report?.reportID, reportAction: parentReportAction, searchReports: reports}); return formatReportLastMessageText(modifiedMessage); } - if (isTripRoom(report)) { + if (isTripRoom(report) && report?.reportName !== CONST.REPORT.DEFAULT_REPORT_NAME) { return report?.reportName ?? ''; } - if (isCardIssuedAction(parentReportAction)) { return getCardIssuedMessage({reportAction: parentReportAction, personalDetails}); } @@ -5341,10 +5362,11 @@ function getWorkspaceNameUpdatedMessage(action: ReportAction) { function getDeletedTransactionMessage(action: ReportAction) { const deletedTransactionOriginalMessage = getOriginalMessage(action as ReportAction) ?? {}; - const amount = Math.abs(deletedTransactionOriginalMessage.amount ?? 0) / 100; - const currency = getCurrencySymbol(deletedTransactionOriginalMessage.currency ?? ''); + const amount = Math.abs(deletedTransactionOriginalMessage.amount ?? 0); + const currency = deletedTransactionOriginalMessage.currency ?? ''; + const formattedAmount = convertToDisplayString(amount, currency) ?? ''; const message = translateLocal('iou.deletedTransaction', { - amount: `${currency}${amount}`, + amount: formattedAmount, merchant: deletedTransactionOriginalMessage.merchant ?? '', }); return message; @@ -6017,12 +6039,11 @@ function buildOptimisticChatReport( return reportParticipants; }, {} as Participants); const currentTime = DateUtils.getDBTime(); - const isNewlyCreatedWorkspaceChat = chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && isOwnPolicyExpenseChat; const optimisticChatReport: OptimisticChatReport = { type: CONST.REPORT.TYPE.CHAT, chatType, isOwnPolicyExpenseChat, - isPinned: isNewlyCreatedWorkspaceChat, + isPinned: false, lastActorAccountID: 0, lastMessageHtml: '', lastMessageText: undefined, @@ -6926,17 +6947,15 @@ function shouldDisplayViolationsRBRInLHN(report: OnyxEntry, transactionV if (!isCurrentUserSubmitter(report.reportID)) { return false; } + if (!report.policyID || !reportsByPolicyID) { + return false; + } - // Get all potential reports, which are the ones that are: - // - Owned by the same user - // - Are either open or submitted - // - Belong to the same workspace - // And if any have a violation, then it should have a RBR - const reports = Object.values(allReports ?? {}) as Report[]; - const potentialReports = reports.filter((r) => r?.ownerAccountID === currentUserAccountID && (r?.stateNum ?? 0) <= 1 && r?.policyID === report.policyID); - return potentialReports.some( - (potentialReport) => hasViolations(potentialReport.reportID, transactionViolations) || hasWarningTypeViolations(potentialReport.reportID, transactionViolations), - ); + // If any report has a violation, then it should have a RBR + const potentialReports = reportsByPolicyID[report.policyID] ?? []; + return potentialReports.some((potentialReport) => { + return hasViolations(potentialReport.reportID, transactionViolations) || hasWarningTypeViolations(potentialReport.reportID, transactionViolations); + }); } /** @@ -8424,6 +8443,11 @@ function getAllAncestorReportActions(report: Report | null | undefined, currentU break; } + // For threads, we don't want to display trip summary + if (isTripPreview(parentReportAction) && allAncestors.length > 0) { + break; + } + const isParentReportActionUnread = isCurrentActionUnread(parentReport, parentReportAction); allAncestors.push({ report: parentReport, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index fc9ba60be715..6e9a1711f57d 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -28,16 +28,28 @@ import { getOriginalMessage, getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogChangeRoleMessage, + getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultTitleEnforcedMessage, getPolicyChangeLogDeleteMemberMessage, getPolicyChangeLogEmployeeLeftMessage, - getPolicyChangeLogUpdateAutoReportingFrequencyMessage, + getPolicyChangeLogMaxExpenseAmountMessage, + getPolicyChangeLogMaxExpesnseAmountNoReceiptMessage, getRemovedConnectionMessage, getRenamedAction, getReportAction, - getReportActionMessage, getReportActionMessageText, getSortedReportActions, getUpdateRoomDescriptionMessage, + getWorkspaceCategoryUpdateMessage, + getWorkspaceCurrencyUpdateMessage, + getWorkspaceCustomUnitRateAddedMessage, + getWorkspaceDescriptionUpdatedMessage, + getWorkspaceFrequencyUpdateMessage, + getWorkspaceReportFieldAddMessage, + getWorkspaceReportFieldDeleteMessage, + getWorkspaceReportFieldUpdateMessage, + getWorkspaceTagUpdateMessage, + getWorkspaceUpdateFieldMessage, isActionOfType, isCardIssuedAction, isInviteOrRemovedAction, @@ -541,14 +553,49 @@ function getOptionData({ result.alternateText = `${lastActorDisplayName} ${getUpdateRoomDescriptionMessage(lastAction)}`; } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME)) { result.alternateText = getWorkspaceNameUpdatedMessage(lastAction); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DESCRIPTION)) { + result.alternateText = getWorkspaceDescriptionUpdatedMessage(lastAction); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY)) { + result.alternateText = getWorkspaceCurrencyUpdateMessage(lastAction); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY)) { + result.alternateText = getWorkspaceFrequencyUpdateMessage(lastAction); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_UPGRADE)) { + result.alternateText = translateLocal('workspaceActions.upgradedWorkspace'); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.TEAM_DOWNGRADE)) { + result.alternateText = translateLocal('workspaceActions.downgradedWorkspace'); + } else if ( + isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY) || + isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY) || + isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY) || + isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME) + ) { + result.alternateText = getWorkspaceCategoryUpdateMessage(lastAction); + } else if (isTagModificationAction(lastAction?.actionName)) { + result.alternateText = getCleanedTagName(getWorkspaceTagUpdateMessage(lastAction) ?? ''); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE)) { + result.alternateText = getWorkspaceCustomUnitRateAddedMessage(lastAction); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD)) { + result.alternateText = getWorkspaceReportFieldAddMessage(lastAction); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REPORT_FIELD)) { + result.alternateText = getWorkspaceReportFieldUpdateMessage(lastAction); + } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_REPORT_FIELD)) { + result.alternateText = getWorkspaceReportFieldDeleteMessage(lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD) { + result.alternateText = getWorkspaceUpdateFieldMessage(lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT) { + result.alternateText = getPolicyChangeLogMaxExpesnseAmountNoReceiptMessage(lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT) { + result.alternateText = getPolicyChangeLogMaxExpenseAmountMessage(lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE) { + result.alternateText = getPolicyChangeLogDefaultBillableMessage(lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED) { + result.alternateText = getPolicyChangeLogDefaultTitleEnforcedMessage(lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_POLICY) { result.alternateText = getPolicyChangeLogEmployeeLeftMessage(lastAction, true); } else if (isCardIssuedAction(lastAction)) { result.alternateText = getCardIssuedMessage({reportAction: lastAction}); } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) { result.alternateText = formatReportLastMessageText(Parser.htmlToText(`${lastActorDisplayName}: ${lastMessageText}`)); - } else if (isTagModificationAction(lastAction?.actionName)) { - result.alternateText = getCleanedTagName(getReportActionMessage(lastAction)?.text ?? ''); } else if (lastAction && isOldDotReportAction(lastAction)) { result.alternateText = getMessageOfOldDotReportAction(lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE) { @@ -561,8 +608,6 @@ function getOptionData({ result.alternateText = getReportActionMessageText(lastAction) ?? ''; } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION) { result.alternateText = getRemovedConnectionMessage(lastAction); - } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { - result.alternateText = getPolicyChangeLogUpdateAutoReportingFrequencyMessage(lastAction); } else { result.alternateText = lastMessageTextFromReport.length > 0 @@ -654,19 +699,16 @@ function getWelcomeMessage(report: OnyxEntry, policy: OnyxEntry) const isMultipleParticipant = participantAccountIDs.length > 1; const displayNamesWithTooltips = getDisplayNamesWithTooltips(getPersonalDetailsForAccountIDs(participantAccountIDs, allPersonalDetails), isMultipleParticipant); const displayNamesWithTooltipsText = displayNamesWithTooltips - .map(({displayName, pronouns}, index) => { - const formattedText = !pronouns ? displayName : `${displayName} (${pronouns})`; - + .map(({displayName}, index) => { if (index === displayNamesWithTooltips.length - 1) { - return `${formattedText}.`; + return `${displayName}.`; } if (index === displayNamesWithTooltips.length - 2) { - return `${formattedText} ${translateLocal('common.and')}`; + return `${displayName} ${translateLocal('common.and')}`; } if (index < displayNamesWithTooltips.length - 2) { - return `${formattedText},`; + return `${displayName},`; } - return ''; }) .join(' '); diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 2a7f935b07e6..bf188de6e189 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -206,7 +206,7 @@ function hasCardExpiredError() { * @returns Whether there is an insufficient funds error. */ function hasInsufficientFundsError() { - return billingStatus?.declineReason === 'insufficient_funds' && amountOwed !== 0; + return billingStatus?.declineReason === 'insufficient_funds' && getAmountOwed() !== 0; } function shouldShowPreTrialBillingBanner(): boolean { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 649a0a3cac58..5ab070a21db7 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1369,6 +1369,10 @@ function getAllSortedTransactions(iouReportID?: string): Array; }; +type UpdateWorkflowDataOnApproverRemovalParams = { + /** + * An array of approval workflows that need to be updated. + */ + approvalWorkflows: ApprovalWorkflow[]; + /** + * The email of the approver being removed + */ + removedApprover: PersonalDetails; + /** + * The email of the workspace owner + */ + ownerDetails: PersonalDetails; +}; + +type UpdateWorkflowDataOnApproverRemovalResult = Array< + ApprovalWorkflow & { + /** + * @property {boolean} [removeApprovalWorkflow] - A flag that determines if the approval workflow should be removed. + * - `true`: Indicates the approval workflow needs to be removed. + * - `false` or `undefined`: No removal is required; the workflow will be updated instead. + */ + removeApprovalWorkflow?: boolean; + } +>; + /** * This function converts an approval workflow into a list of policy employees. * An optimized list is created that contains only the updated employees to maintain minimal data changes. @@ -281,5 +308,114 @@ function convertApprovalWorkflowToPolicyEmployees({ return updatedEmployeeList; } +function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover, ownerDetails}: UpdateWorkflowDataOnApproverRemovalParams): UpdateWorkflowDataOnApproverRemovalResult { + const defaultWorkflow = approvalWorkflows.find((workflow) => workflow.isDefault); + const removedApproverEmail = removedApprover.login; + const ownerEmail = ownerDetails.login; + const ownerAvatar = ownerDetails.avatar ?? ''; + const ownerDisplayName = ownerDetails.displayName ?? ''; + + return approvalWorkflows.flatMap((workflow) => { + const [currentApprover] = workflow.approvers; + const isSingleApprover = workflow.approvers.length === 1; + const isMultipleApprovers = workflow.approvers.length > 1; + const isApproverToRemove = currentApprover?.email === removedApproverEmail; + const defaultHasOwner = defaultWorkflow?.approvers.some((approver) => approver.email === ownerEmail); + + if (workflow.isDefault) { + // Handle default workflow + if (isSingleApprover && isApproverToRemove && currentApprover?.email !== ownerEmail) { + return { + ...workflow, + approvers: [ + { + ...currentApprover, + avatar: ownerAvatar, + displayName: ownerDisplayName, + email: ownerEmail ?? '', + }, + ], + }; + } + return workflow; + } + + if (isSingleApprover) { + // Remove workflows with a single approver when owner is the approver + if (currentApprover?.email === ownerEmail) { + return { + ...workflow, + removeApprovalWorkflow: true, + }; + } + + // Handle case where the approver is to be removed + if (isApproverToRemove) { + // Remove workflow if the default workflow includes the owner or approver is to be replaced + if (defaultHasOwner) { + return { + ...workflow, + removeApprovalWorkflow: true, + }; + } + + // Replace the approver with owner details + return { + ...workflow, + approvers: [ + { + ...currentApprover, + avatar: ownerAvatar, + displayName: ownerDisplayName, + email: ownerEmail ?? '', + }, + ], + }; + } + } + + if (isMultipleApprovers && workflow.approvers.some((item) => item.email === removedApproverEmail)) { + const removedApproverIndex = workflow.approvers.findIndex((item) => item.email === removedApproverEmail); + + // If the removed approver is the first in the list, return an empty array + if (removedApproverIndex === 0) { + return { + ...workflow, + removeApprovalWorkflow: true, + }; + } + + const updateApprovers = workflow.approvers.slice(0, removedApproverIndex); + const updateApproversHasOwner = updateApprovers.some((approver) => approver.email === ownerEmail); + + // If the owner is already in the approvers list, return the workflow with the updated approvers + if (updateApproversHasOwner) { + return { + ...workflow, + approvers: updateApprovers, + }; + } + + // Update forwardsTo if necessary and prepare the new approver object + const updatedApprovers = updateApprovers.flatMap((item) => (item.forwardsTo === removedApproverEmail ? {...item, forwardsTo: ownerEmail} : item)); + + const newApprover = { + email: ownerEmail ?? '', + forwardsTo: undefined, + avatar: ownerDetails?.avatar ?? '', + displayName: ownerDetails?.displayName ?? '', + isCircularReference: workflow.approvers.at(removedApproverIndex)?.isCircularReference, + }; + + return { + ...workflow, + approvers: [...updatedApprovers, newApprover], + }; + } + + // Return the unchanged workflow in other cases + return workflow; + }); +} -export {calculateApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, INITIAL_APPROVAL_WORKFLOW}; +export {calculateApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, INITIAL_APPROVAL_WORKFLOW, updateWorkflowDataOnApproverRemoval}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index a65b3493171e..21ea41bab8a8 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -425,7 +425,6 @@ function deleteWorkspace(policyID: string, policyName: string) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, value: { - // eslint-disable-next-line @typescript-eslint/naming-convention private_isArchived: null, }, }); diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index ffcbc813477b..1b151f47fde1 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -892,6 +892,7 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { }); } +let pongHasBeenMissed = false; let lastPingSentTimestamp = Date.now(); let lastPongReceivedTimestamp = Date.now(); function subscribeToPusherPong() { @@ -910,6 +911,9 @@ function subscribeToPusherPong() { Log.info(`[Pusher PINGPONG] The event took ${latency} ms`); Timing.end(CONST.TIMING.PUSHER_PING_PONG); + + // When any PONG event comes in, reset this flag so that checkforLatePongReplies will resume looking for missed PONGs + pongHasBeenMissed = false; }); } @@ -947,6 +951,16 @@ function pingPusher() { } function checkforLatePongReplies() { + if (isOffline()) { + Log.info('[Pusher PINGPONG] Skipping checkforLatePongReplies because the client is offline'); + return; + } + + if (pongHasBeenMissed) { + Log.info(`[Pusher PINGPONG] Skipped checking for late PONG events because a PONG has already been missed`); + return; + } + Log.info(`[Pusher PINGPONG] Checking for late PONG events`); const timeSinceLastPongReceived = Date.now() - lastPongReceivedTimestamp; @@ -956,12 +970,14 @@ function checkforLatePongReplies() { // When going offline, reset the pingpong state so that when the network reconnects, the client will start fresh lastPingSentTimestamp = Date.now(); + pongHasBeenMissed = true; } else { Log.info(`[Pusher PINGPONG] Last PONG event was ${timeSinceLastPongReceived} ms ago so not going offline`); } } -let pingPongStarted = false; +let pingPusherIntervalID: ReturnType; +let checkforLatePongRepliesIntervalID: ReturnType; function initializePusherPingPong() { // Only run the ping pong from the leader client if (!ActiveClientManager.isClientTheLeader()) { @@ -969,26 +985,29 @@ function initializePusherPingPong() { return; } - // Ignore any additional calls to initialize the ping pong if it's already been started - if (pingPongStarted) { - return; - } - pingPongStarted = true; - Log.info(`[Pusher PINGPONG] Starting Pusher PING PONG and pinging every ${PING_INTERVAL_LENGTH_IN_SECONDS} seconds`); // Subscribe to the pong event from Pusher. Unfortunately, there is no way of knowing when the client is actually subscribed // so there could be a little delay before the client is actually listening to this event. subscribeToPusherPong(); + // If things are initializing again (which is fine because it will reinitialize each time Pusher authenticates), clear the old intervals + if (pingPusherIntervalID) { + clearInterval(pingPusherIntervalID); + } + // Send a ping to pusher on a regular interval - setInterval(pingPusher, PING_INTERVAL_LENGTH_IN_SECONDS * 1000); + pingPusherIntervalID = setInterval(pingPusher, PING_INTERVAL_LENGTH_IN_SECONDS * 1000); // Delay the start of this by double the length of PING_INTERVAL_LENGTH_IN_SECONDS to give a chance for the first // events to be sent and received setTimeout(() => { + // If things are initializing again (which is fine because it will reinitialize each time Pusher authenticates), clear the old intervals + if (checkforLatePongRepliesIntervalID) { + clearInterval(checkforLatePongRepliesIntervalID); + } // Check for any missing pong events on a regular interval - setInterval(checkforLatePongReplies, CHECK_LATE_PONG_INTERVAL_LENGTH_IN_SECONDS * 1000); + checkforLatePongRepliesIntervalID = setInterval(checkforLatePongReplies, CHECK_LATE_PONG_INTERVAL_LENGTH_IN_SECONDS * 1000); }, PING_INTERVAL_LENGTH_IN_SECONDS * 2); } diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index ea7e86ef49b7..f9e8ef61f057 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -2,22 +2,13 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; -import type { - AcceptWalletTermsParams, - AnswerQuestionsForWalletParams, - RequestPhysicalExpensifyCardParams, - UpdatePersonalDetailsForWalletParams, - VerifyIdentityParams, -} from '@libs/API/parameters'; +import type {AcceptWalletTermsParams, AnswerQuestionsForWalletParams, UpdatePersonalDetailsForWalletParams, VerifyIdentityParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import type {PrivatePersonalDetails} from '@libs/GetPhysicalCardUtils'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {WalletAdditionalQuestionDetails} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; -import * as FormActions from './FormActions'; +import {clearErrors} from './FormActions'; type WalletQuestionAnswer = { question: string; @@ -259,107 +250,6 @@ function answerQuestionsForWallet(answers: WalletQuestionAnswer[], idNumber: str }); } -function requestPhysicalExpensifyCard(cardID: number, authToken: string, privatePersonalDetails: PrivatePersonalDetails, validateCode: string) { - const {legalFirstName = '', legalLastName = '', phoneNumber = ''} = privatePersonalDetails; - const {city = '', country = '', state = '', street = '', zip = ''} = PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails) ?? {}; - - const requestParams: RequestPhysicalExpensifyCardParams = { - authToken, - legalFirstName, - legalLastName, - phoneNumber, - addressCity: city, - addressCountry: country, - addressState: state, - addressStreet: street, - addressZip: zip, - validateCode, - }; - - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - errors: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - value: privatePersonalDetails, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, - value: { - isLoading: true, - errors: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.VALIDATE_ACTION_CODE, - value: { - validateCodeSent: false, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - state: 4, // NOT_ACTIVATED - errors: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, - value: { - isLoading: false, - errors: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.VALIDATE_ACTION_CODE, - value: { - validateCodeSent: false, - }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - state: 2, - isLoading: false, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, - value: { - isLoading: false, - }, - }, - ]; - - API.write(WRITE_COMMANDS.REQUEST_PHYSICAL_EXPENSIFY_CARD, requestParams, {optimisticData, failureData, successData}); -} - function resetWalletAdditionalDetailsDraft() { Onyx.set(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT, null); } @@ -373,7 +263,7 @@ function clearPhysicalCardError(cardID?: string) { return; } - FormActions.clearErrors(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM); + clearErrors(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM); Onyx.merge(ONYXKEYS.CARD_LIST, { [cardID]: { errors: null, @@ -393,7 +283,6 @@ export { verifyIdentity, acceptWalletTerms, setKYCWallSource, - requestPhysicalExpensifyCard, resetWalletAdditionalDetailsDraft, clearPhysicalCardError, }; diff --git a/src/libs/actions/connections/QuickbooksOnline.ts b/src/libs/actions/connections/QuickbooksOnline.ts index 15233682116f..05b6bb730069 100644 --- a/src/libs/actions/connections/QuickbooksOnline.ts +++ b/src/libs/actions/connections/QuickbooksOnline.ts @@ -289,10 +289,13 @@ function updateQuickbooksOnlineNonReimbursableBillDefaultVendor( - policyID: string, + policyID: string | undefined, settingValue: TSettingValue, oldSettingValue?: TSettingValue, ) { + if (!policyID) { + return; + } const {optimisticData, failureData, successData} = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT, settingValue, oldSettingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { @@ -383,10 +386,13 @@ function updateQuickbooksOnlineReimbursementAccountID( - policyID: string, + policyID: string | undefined, settingValue: TSettingValue, oldSettingValue?: TSettingValue, ) { + if (!policyID) { + return; + } const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.EXPORT, settingValue, oldSettingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { diff --git a/src/libs/migrations/NVPMigration.ts b/src/libs/migrations/NVPMigration.ts index 09274468a1e3..6a4f61f30407 100644 --- a/src/libs/migrations/NVPMigration.ts +++ b/src/libs/migrations/NVPMigration.ts @@ -12,9 +12,7 @@ const migrations = { preferredLocale: ONYXKEYS.NVP_PREFERRED_LOCALE, preferredEmojiSkinTone: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, frequentlyUsedEmojis: ONYXKEYS.FREQUENTLY_USED_EMOJIS, - // eslint-disable-next-line @typescript-eslint/naming-convention private_blockedFromConcierge: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, - // eslint-disable-next-line @typescript-eslint/naming-convention private_pushNotificationID: ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID, tryFocusMode: ONYXKEYS.NVP_TRY_FOCUS_MODE, introSelected: ONYXKEYS.NVP_INTRO_SELECTED, diff --git a/src/pages/EnablePayments/PersonalInfo/substeps/PhoneNumberStep.tsx b/src/pages/EnablePayments/PersonalInfo/substeps/PhoneNumberStep.tsx index 54843c2aaf4a..675869a682af 100644 --- a/src/pages/EnablePayments/PersonalInfo/substeps/PhoneNumberStep.tsx +++ b/src/pages/EnablePayments/PersonalInfo/substeps/PhoneNumberStep.tsx @@ -5,7 +5,7 @@ import SingleFieldStep from '@components/SubStepForms/SingleFieldStep'; import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; import useWalletAdditionalDetailsStepFormSubmit from '@hooks/useWalletAdditionalDetailsStepFormSubmit'; -import * as ValidationUtils from '@libs/ValidationUtils'; +import {getFieldRequiredErrors, isValidUSPhone} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; @@ -21,9 +21,9 @@ function PhoneNumberStep({onNext, onMove, isEditing}: SubStepProps) { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); + const errors = getFieldRequiredErrors(values, STEP_FIELDS); - if (values.phoneNumber && !ValidationUtils.isValidUSPhone(values.phoneNumber, true)) { + if (values.phoneNumber && !isValidUSPhone(values.phoneNumber, true)) { errors.phoneNumber = translate('bankAccount.error.phoneNumber'); } return errors; @@ -51,6 +51,7 @@ function PhoneNumberStep({onNext, onMove, isEditing}: SubStepProps) { inputLabel={translate('common.phoneNumber')} inputMode={CONST.INPUT_MODE.TEL} defaultValue={defaultPhoneNumber} + enabledWhenOffline /> ); } diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx index ad050e23b3b1..785f4ca9f539 100644 --- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx +++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx @@ -93,6 +93,11 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount accountingIcon = Expensicons.QBOCircle; break; } + case CONST.POLICY.CONNECTIONS.NAME.QBD: { + text = translate('workspace.accounting.qbd'); + accountingIcon = Expensicons.QBDSquare; + break; + } case CONST.POLICY.CONNECTIONS.NAME.XERO: { text = translate('workspace.accounting.xero'); accountingIcon = Expensicons.XeroCircle; diff --git a/src/pages/Search/SavedSearchItemThreeDotMenu.tsx b/src/pages/Search/SavedSearchItemThreeDotMenu.tsx index 4d92ef295f2d..2a0733e40c7e 100644 --- a/src/pages/Search/SavedSearchItemThreeDotMenu.tsx +++ b/src/pages/Search/SavedSearchItemThreeDotMenu.tsx @@ -1,4 +1,5 @@ import React, {useRef, useState} from 'react'; +import type {LayoutChangeEvent, LayoutRectangle, NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; @@ -12,6 +13,9 @@ type SavedSearchItemThreeDotMenuProps = { renderTooltipContent: () => React.JSX.Element; shouldRenderTooltip: boolean; }; + +type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle; target: HTMLElement}>; + function SavedSearchItemThreeDotMenu({menuItems, isDisabledItem, hideProductTrainingTooltip, renderTooltipContent, shouldRenderTooltip}: SavedSearchItemThreeDotMenuProps) { const threeDotsMenuContainerRef = useRef(null); const [threeDotsMenuPosition, setThreeDotsMenuPosition] = useState({horizontal: 0, vertical: 0}); @@ -20,17 +24,18 @@ function SavedSearchItemThreeDotMenu({menuItems, isDisabledItem, hideProductTrai { + const target = e.target || (e as LayoutChangeEventWithTarget).nativeEvent.target; + target?.measureInWindow((x, y, width) => { + setThreeDotsMenuPosition({ + horizontal: x + width, + vertical: y, + }); + }); + }} > { - threeDotsMenuContainerRef.current?.measureInWindow((x, y, width) => { - setThreeDotsMenuPosition({ - horizontal: x + width, - vertical: y, - }); - }); - }} anchorPosition={threeDotsMenuPosition} renderProductTrainingTooltipContent={renderTooltipContent} shouldShowProductTrainingTooltip={shouldRenderTooltip} diff --git a/src/pages/Travel/MyTripsPage.tsx b/src/pages/Travel/MyTripsPage.tsx index 58db57685ab7..363a59621e28 100644 --- a/src/pages/Travel/MyTripsPage.tsx +++ b/src/pages/Travel/MyTripsPage.tsx @@ -9,7 +9,7 @@ import ManageTrips from './ManageTrips'; function MyTripsPage() { const {translate} = useLocalize(); - const {canUseSpotnanaTravel} = usePermissions(); + const {canUseSpotnanaTravel, isBlockedFromSpotnanaTravel} = usePermissions(); return ( - + Navigation.goBack()} diff --git a/src/pages/Travel/TripDetailsPage.tsx b/src/pages/Travel/TripDetailsPage.tsx index 2e9df5a6a5e6..67e7463db977 100644 --- a/src/pages/Travel/TripDetailsPage.tsx +++ b/src/pages/Travel/TripDetailsPage.tsx @@ -16,9 +16,9 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import type {TravelNavigatorParamList} from '@libs/Navigation/types'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as TripReservationUtils from '@libs/TripReservationUtils'; -import * as Link from '@userActions/Link'; +import {getTripIDFromTransactionParentReportID} from '@libs/ReportUtils'; +import {getTripReservationIcon} from '@libs/TripReservationUtils'; +import {openTravelDotLink} from '@userActions/Link'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -40,7 +40,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const {canUseSpotnanaTravel} = usePermissions(); + const {canUseSpotnanaTravel, isBlockedFromSpotnanaTravel} = usePermissions(); const {isOffline} = useNetwork(); const [isModifyTripLoading, setIsModifyTripLoading] = useState(false); @@ -51,10 +51,10 @@ function TripDetailsPage({route}: TripDetailsPageProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID ?? CONST.DEFAULT_NUMBER_ID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? CONST.DEFAULT_NUMBER_ID}`); - const tripID = ReportUtils.getTripIDFromTransactionParentReportID(parentReport?.reportID); + const tripID = getTripIDFromTransactionParentReportID(parentReport?.reportID); const reservationType = transaction?.receipt?.reservationList?.at(route.params.reservationIndex ?? 0)?.type; const reservation = transaction?.receipt?.reservationList?.at(route.params.reservationIndex ?? 0); - const reservationIcon = TripReservationUtils.getTripReservationIcon(reservation?.type); + const reservationIcon = getTripReservationIcon(reservation?.type); const [travelerPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (personalDetails) => pickTravelerPersonalDetails(personalDetails, reservation)}); return ( @@ -67,7 +67,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { > { setIsModifyTripLoading(true); - Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID))?.finally(() => { + openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID))?.finally(() => { setIsModifyTripLoading(false); }); }} @@ -128,7 +128,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { shouldShowRightIcon onPress={() => { setIsTripSupportLoading(true); - Link.openTravelDotLink(activePolicyID, CONST.TRIP_SUPPORT)?.finally(() => { + openTravelDotLink(activePolicyID, CONST.TRIP_SUPPORT)?.finally(() => { setIsTripSupportLoading(false); }); }} diff --git a/src/pages/Travel/TripSummaryPage.tsx b/src/pages/Travel/TripSummaryPage.tsx index 69926f0e443e..f7b6cb2c67a3 100644 --- a/src/pages/Travel/TripSummaryPage.tsx +++ b/src/pages/Travel/TripSummaryPage.tsx @@ -19,7 +19,7 @@ type TripSummaryPageProps = StackScreenProps ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { children = ; - } else if (isTagModificationAction(action.actionName)) { - children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME) { children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DESCRIPTION) { + children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY) { + children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { + children = ; + } else if ( + action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY || + action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY || + action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY || + action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME + ) { + children = ; + } else if (isTagModificationAction(action.actionName)) { + children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE) { + children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD) { + children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REPORT_FIELD) { + children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_REPORT_FIELD) { + children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD)) { + children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT)) { + children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { + children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE)) { + children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED)) { + children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE) { children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE) { children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE) { children = ; - } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { - children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN)) { children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE)) { + children = ; } else if ( isActionOfType( action, @@ -1075,6 +1121,11 @@ function PureReportActionItem({ /> ); } + + if (isTripPreview(action) && isThreadReportParentAction) { + return ; + } + if (isChronosOOOListAction(action)) { return ( { const ancestorReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${ancestor.report.reportID}`]; const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(ancestorReport); + const shouldDisplayThreadDivider = !isTripPreview(ancestor.reportAction); + return ( navigateToConciergeChatAndDeleteReport(ancestor.report.reportID)} > - + {shouldDisplayThreadDivider && ( + + )} { + if (!isSafari()) { + return; + } + const prevSorted = lastAction?.reportActionID ? prevSortedVisibleReportActionsObjects[lastAction?.reportActionID] : null; + if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER && !prevSorted) { + InteractionManager.runAfterInteractions(() => { + reportScrollManager.scrollToBottom(); + }); + } + }, [lastAction, prevSortedVisibleReportActionsObjects, reportScrollManager]); + const scrollToBottomForCurrentUserAction = useCallback( (isFromCurrentUser: boolean) => { InteractionManager.runAfterInteractions(() => { @@ -771,6 +786,7 @@ function ReportActionsList({ ; - } if (shouldDisplayParentAction && isChatThread(report)) { return ( diff --git a/src/pages/home/report/TripSummary.tsx b/src/pages/home/report/TripSummary.tsx index d1144ef95b34..89abed699cc7 100644 --- a/src/pages/home/report/TripSummary.tsx +++ b/src/pages/home/report/TripSummary.tsx @@ -1,39 +1,32 @@ import React from 'react'; -import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import TripDetailsView from '@components/ReportActionItem/TripDetailsView'; -import useThemeStyles from '@hooks/useThemeStyles'; import useTripTransactions from '@hooks/useTripTransactions'; -import type * as OnyxTypes from '@src/types/onyx'; -import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; -import RepliesDivider from './RepliesDivider'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; type TripSummaryProps = { - /** The current report is displayed */ - report: OnyxEntry; + /** The report ID */ + reportID: string | undefined; }; -function TripSummary({report}: TripSummaryProps) { - const styles = useThemeStyles(); - const tripTransactions = useTripTransactions(report?.reportID); +function TripSummary({reportID}: TripSummaryProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID ?? CONST.DEFAULT_NUMBER_ID}`); + const tripTransactions = useTripTransactions(reportID); - if (!report?.reportID) { + if (!reportID || tripTransactions.length === 0) { return null; } return ( - - - - - - - + + + ); } diff --git a/src/pages/home/report/comment/TextWithEmojiFragment/index.native.tsx b/src/pages/home/report/comment/TextWithEmojiFragment/index.ios.tsx similarity index 100% rename from src/pages/home/report/comment/TextWithEmojiFragment/index.native.tsx rename to src/pages/home/report/comment/TextWithEmojiFragment/index.ios.tsx diff --git a/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx index 2632c08c478f..bf5fe15153b4 100644 --- a/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx +++ b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx @@ -1,11 +1,16 @@ import React, {useEffect, useMemo} from 'react'; import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; +import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import {getReportPrivateNote} from '@libs/actions/Report'; import getComponentDisplayName from '@libs/getComponentDisplayName'; +import Navigation from '@libs/Navigation/Navigation'; import {isArchivedReport, isSelfDM} from '@libs/ReportUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import LoadingPage from '@pages/LoadingPage'; @@ -70,6 +75,25 @@ export default function (pageTitle: TranslationPaths) { }, [report, isOtherUserNote, shouldShowFullScreenLoadingIndicator, isPrivateNotesUndefined, isReconnecting, isOffline, reportNameValuePairs]); if (shouldShowFullScreenLoadingIndicator) { + if (isOffline) { + return ( + + Navigation.goBack()} + shouldShowBackButton + onCloseButtonPress={() => Navigation.dismissModal()} + /> + + + + + ); + } return ; } diff --git a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx index 38c084ce830b..ab13a4951826 100644 --- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx @@ -31,6 +31,7 @@ import {canActionTask as canActionTaskUtils, canModifyTask as canModifyTaskUtils import {setSelfTourViewed} from '@libs/actions/Welcome'; import getIconForAction from '@libs/getIconForAction'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation from '@libs/Navigation/Navigation'; import {hasSeenTourSelector} from '@libs/onboardingSelectors'; import {areAllGroupPoliciesExpenseChatDisabled, canSendInvoice as canSendInvoicePolicyUtils, shouldShowPolicy} from '@libs/PolicyUtils'; @@ -189,7 +190,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT const prevIsFocused = usePrevious(isFocused); const {isOffline} = useNetwork(); - const {canUseSpotnanaTravel} = usePermissions(); + const {canUseSpotnanaTravel, isBlockedFromSpotnanaTravel} = usePermissions(); const canSendInvoice = useMemo(() => canSendInvoicePolicyUtils(allPolicies as OnyxCollection, session?.email), [allPolicies, session?.email]); const isValidReport = !(isEmptyObject(quickActionReport) || isArchivedReport(reportNameValuePairs)); const {environment} = useEnvironment(); @@ -454,6 +455,80 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT const canModifyTask = canModifyTaskUtils(viewTourTaskReport, currentUserPersonalDetails.accountID); const canActionTask = canActionTaskUtils(viewTourTaskReport, currentUserPersonalDetails.accountID); + const menuItems = [ + ...expenseMenuItems, + { + icon: Expensicons.ChatBubble, + text: translate('sidebarScreen.fabNewChat'), + onSelected: () => interceptAnonymousUser(startNewChat), + }, + ...(canSendInvoice + ? [ + { + icon: Expensicons.InvoiceGeneric, + text: translate('workspace.invoices.sendInvoice'), + shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, + onSelected: () => + interceptAnonymousUser(() => { + if (shouldRedirectToExpensifyClassic) { + setModalVisible(true); + return; + } + + startMoneyRequest( + CONST.IOU.TYPE.INVOICE, + // When starting to create an invoice from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used + // for all of the routes in the creation flow. + generateReportID(), + ); + }), + }, + ] + : []), + ...(canUseSpotnanaTravel && !isBlockedFromSpotnanaTravel + ? [ + { + icon: Expensicons.Suitcase, + text: translate('travel.bookTravel'), + onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.TRAVEL_MY_TRIPS)), + }, + ] + : []), + ...(!hasSeenTour + ? [ + { + icon: Expensicons.Binoculars, + iconStyles: styles.popoverIconCircle, + iconFill: theme.icon, + text: translate('tour.takeATwoMinuteTour'), + description: translate('tour.exploreExpensify'), + onSelected: () => { + openExternalLink(navatticURL); + setSelfTourViewed(isAnonymousUser()); + if (viewTourTaskReport && canModifyTask && canActionTask) { + completeTask(viewTourTaskReport); + } + }, + }, + ] + : []), + ...(!isLoading && shouldShowNewWorkspaceButton + ? [ + { + displayInDefaultIconColor: true, + contentFit: 'contain' as ImageContentFit, + icon: Expensicons.NewWorkspace, + iconWidth: variables.w46, + iconHeight: variables.h40, + text: translate('workspace.new.newWorkspace'), + description: translate('workspace.new.getTheExpensifyCardAndMore'), + onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.WORKSPACE_CONFIRMATION.getRoute(Navigation.getActiveRoute()))), + }, + ] + : []), + ...quickActionMenuItems, + ]; + return ( interceptAnonymousUser(startNewChat), - }, - ...(canSendInvoice - ? [ - { - icon: Expensicons.InvoiceGeneric, - text: translate('workspace.invoices.sendInvoice'), - shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, - onSelected: () => - interceptAnonymousUser(() => { - if (shouldRedirectToExpensifyClassic) { - setModalVisible(true); - return; - } - - startMoneyRequest( - CONST.IOU.TYPE.INVOICE, - // When starting to create an invoice from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used - // for all of the routes in the creation flow. - generateReportID(), - ); - }), - }, - ] - : []), - ...(canUseSpotnanaTravel - ? [ - { - icon: Expensicons.Suitcase, - text: translate('travel.bookTravel'), - onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.TRAVEL_MY_TRIPS)), - }, - ] - : []), - ...(!hasSeenTour - ? [ - { - icon: Expensicons.Binoculars, - iconStyles: styles.popoverIconCircle, - iconFill: theme.icon, - text: translate('tour.takeATwoMinuteTour'), - description: translate('tour.exploreExpensify'), - onSelected: () => { - openExternalLink(navatticURL); - setSelfTourViewed(isAnonymousUser()); - if (viewTourTaskReport && canModifyTask && canActionTask) { - completeTask(viewTourTaskReport); - } - }, - }, - ] - : []), - ...(!isLoading && shouldShowNewWorkspaceButton - ? [ - { - displayInDefaultIconColor: true, - contentFit: 'contain' as ImageContentFit, - icon: Expensicons.NewWorkspace, - iconWidth: variables.w46, - iconHeight: variables.h40, - text: translate('workspace.new.newWorkspace'), - description: translate('workspace.new.getTheExpensifyCardAndMore'), - onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.WORKSPACE_CONFIRMATION.getRoute(Navigation.getActiveRoute()))), - }, - ] - : []), - ...quickActionMenuItems, - ]} + menuItems={menuItems.map((item) => { + return { + ...item, + onSelected: () => { + if (!item.onSelected) { + return; + } + navigateAfterInteraction(item.onSelected); + }, + }; + })} withoutOverlay anchorRef={fabRef} /> diff --git a/src/pages/iou/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx index 59454cea3ce8..fb56c3e422b6 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx @@ -130,7 +130,7 @@ function IOURequestStepCategory({ setMoneyRequestCategory(transactionID, updatedCategory, policy?.id); - if (action === CONST.IOU.ACTION.CATEGORIZE) { + if (action === CONST.IOU.ACTION.CATEGORIZE && !backTo) { if (report?.reportID) { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, report.reportID)); } diff --git a/src/pages/iou/request/step/IOURequestStepTime.tsx b/src/pages/iou/request/step/IOURequestStepTime.tsx index 9b78de8951bd..76dca80e8cd9 100644 --- a/src/pages/iou/request/step/IOURequestStepTime.tsx +++ b/src/pages/iou/request/step/IOURequestStepTime.tsx @@ -17,7 +17,7 @@ import {getIOURequestPolicyID, setMoneyRequestDateAttribute} from '@userActions/ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; +import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/MoneyRequestTimeForm'; import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -26,7 +26,7 @@ import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; -type IOURequestStepTimeProps = WithWritableReportOrNotFoundProps & { +type IOURequestStepTimeProps = WithWritableReportOrNotFoundProps & { /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ transaction: OnyxEntry; @@ -37,6 +37,7 @@ type IOURequestStepTimeProps = WithWritableReportOrNotFoundProps { + if (isEditPage) { + Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID)); + return; + } + if (backTo) { Navigation.goBack(backTo); return; @@ -78,7 +85,7 @@ function IOURequestStepTime({ setMoneyRequestDateAttribute(transactionID, newStart, newEnd); - if (backTo) { + if (isEditPage) { navigateBack(); } else { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SUBRATE.getRoute(action, iouType, transactionID, reportID)); diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index 5aa581426b50..3f0512e94c46 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -43,6 +43,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO | typeof SCREENS.MONEY_REQUEST.STEP_DESTINATION | typeof SCREENS.MONEY_REQUEST.STEP_TIME + | typeof SCREENS.MONEY_REQUEST.STEP_TIME_EDIT | typeof SCREENS.MONEY_REQUEST.STEP_SUBRATE; type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index 9ee3740be2a1..e3ffa11a2e24 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -45,6 +45,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_UPGRADE | typeof SCREENS.MONEY_REQUEST.STEP_DESTINATION | typeof SCREENS.MONEY_REQUEST.STEP_TIME + | typeof SCREENS.MONEY_REQUEST.STEP_TIME_EDIT | typeof SCREENS.MONEY_REQUEST.STEP_SUBRATE; type WithWritableReportOrNotFoundProps = WithWritableReportOrNotFoundOnyxProps & PlatformStackScreenProps; diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 827490264ff3..821b5b5eb2c2 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -212,7 +212,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr brickRoadIndicator: !!privateSubscription?.errors || hasSubscriptionRedDotError() ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, badgeText: freeTrialText, badgeStyle: freeTrialText ? styles.badgeSuccess : undefined, - action: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION), + action: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.route), }); } diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx index 71b6c2641263..2b7cee0d2e9d 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx @@ -50,7 +50,7 @@ function EarlyDiscountBanner({isSubscriptionPage}: EarlyDiscountBannerProps) { success style={shouldUseNarrowLayout && styles.flex1} text={translate('subscription.billingBanner.earlyDiscount.claimOffer')} - onPress={() => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION)} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.getRoute(Navigation.getActiveRoute()))} /> {discountInfo?.discountType === 25 && (