diff --git a/android/app/build.gradle b/android/app/build.gradle index 192537f08e3d..5b21487d92cd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -107,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009000306 - versionName "9.0.3-6" + versionCode 1009000400 + versionName "9.0.4-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/expensifyCard/cardIllustration.svg b/assets/images/expensifyCard/cardIllustration.svg new file mode 100644 index 000000000000..f8162bbd913f --- /dev/null +++ b/assets/images/expensifyCard/cardIllustration.svg @@ -0,0 +1,487 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md index 2ff74760b376..0fd47f1341fa 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/Global-Reimbursements.md @@ -7,29 +7,9 @@ description: International Reimbursements If your company’s business bank account is in the US, Canada, the UK, Europe, or Australia, you now have the option to send direct reimbursements to nearly any country across the globe! The process to enable global reimbursements is dependent on the currency of your reimbursement bank account, so be sure to review the corresponding instructions below. -# How to request international reimbursements - -## The reimbursement account is in USD - -If your reimbursement bank account is in USD, the first step is connecting the bank account to Expensify. -The individual who plans on sending reimbursements internationally should head to **Settings > Account > Payments > Add Verified Bank Account**. From there, you will provide company details, input personal information, and upload a copy of your ID. - -Once the USD bank account is verified (or if you already had a USD business bank account connected), click the support icon in your Expensify account to inform your Setup Specialist, Account Manager, or Concierge that you’d like to enable international reimbursements. From there, Expensify will ask you to confirm the currencies of the reimbursement and employee bank accounts. - -Our team will assess your account, and if you meet the criteria, international reimbursements will be enabled. - -## The reimbursement account is in AUD, CAD, GBP, EUR - -To request international reimbursements, contact Expensify Support to make that request. - -You can do this by clicking on the support icon and informing your Setup Specialist, Account Manager, or Concierge that you’d like to set up global reimbursements on your account. -From there, Expensify will ask you to confirm both the currencies of the reimbursement and employee bank accounts. - -Our team will assess your account, and if you meet the criteria, international reimbursements will be enabled. - # How to verify the bank account for sending international payments -Once international payments are enabled on your Expensify account, the next step is verifying the bank account to send the reimbursements. +The steps for USD accounts and non-USD accounts differ slightly. ## The reimbursement account is in USD @@ -38,9 +18,9 @@ First, confirm the workspace settings are set up correctly by doing the followin 2. Under **Settings > Workspaces > Group > _[Workspace Name]_ > Reimbursements**, set the reimbursement method to direct 3. Under **Settings > Workspaces > Group > _[Workspace Name]_ > Reimbursements**, set the USD bank account to the default account -Once that’s all set, head to **Settings > Account > Payments**, and click **Enable Global Reimbursement** on the bank account (this button may not show for up to 60 minutes after the Expensify team confirms international reimbursements are available on your account). +Once that’s all set, head to **Settings > Account > Payments**, and click **Enable Global Reimbursement** on the bank account. -From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required. +From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. If additional information is required, our Support Team will contact you with more details. ## The reimbursement account is in AUD, CAD, GBP, EUR @@ -53,7 +33,7 @@ Next, add the bank account to Expensify: 4. Enter the bank account details 5. Click **Save & Continue** -From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. Our Support Team will contact you with more details if additional information is required. +From there, you’ll fill out a form via DocuSign. Once the form is complete, it is automatically sent to our Compliance Team for review. If additional information is required, our Support Team will contact you with more details. # How to start reimbursing internationally diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 17eaae3cc3fc..c1aae6e1265d 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.3.6 + 9.0.4.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 618d394349ed..579c99455525 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleSignature ???? CFBundleVersion - 9.0.3.6 + 9.0.4.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d5e50828e3c7..7981169f076b 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.3 + 9.0.4 CFBundleVersion - 9.0.3.6 + 9.0.4.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 9f63be958d1a..c92ca2ec3813 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c61316e22030..e4bd1d99db16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.3-6", + "version": "9.0.4-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.ts b/src/CONST.ts index 3a5883256f94..b90dbd18f51e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -74,6 +74,9 @@ const onboardingChoices = { type OnboardingPurposeType = ValueOf; const CONST = { + DEFAULT_DB_NAME: 'OnyxDB', + DEFAULT_TABLE_NAME: 'keyvaluepairs', + DEFAULT_ONYX_DUMP_FILE_NAME: 'onyx-state.txt', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], // Note: Group and Self-DM excluded as these are not tied to a Workspace @@ -1375,6 +1378,14 @@ const CONST = { AUTO_CREATE_ENTITIES: 'autoCreateEntities', APPROVAL_ACCOUNT: 'approvalAccount', CUSTOM_FORM_ID_OPTIONS: 'customFormIDOptions', + TOKEN_INPUT_STEP_NAMES: ['1', '2,', '3', '4', '5'], + TOKEN_INPUT_STEP_KEYS: { + 0: 'installBundle', + 1: 'enableTokenAuthentication', + 2: 'enableSoapServices', + 3: 'createAccessToken', + 4: 'enterCredentials', + }, IMPORT_FIELDS: ['departments', 'classes', 'locations', 'customers', 'jobs'], IMPORT_CUSTOM_FIELDS: ['customSegments', 'customLists'], SYNC_OPTIONS: { @@ -1888,6 +1899,11 @@ const CONST = { MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', }, + BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, MORE_FEATURES: { ARE_CATEGORIES_ENABLED: 'areCategoriesEnabled', ARE_TAGS_ENABLED: 'areTagsEnabled', @@ -1898,21 +1914,6 @@ const CONST = { ARE_EXPENSIFY_CARDS_ENABLED: 'areExpensifyCardsEnabled', ARE_TAXES_ENABLED: 'tax', }, - CATEGORIES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, - TAGS_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, - DISTANCE_RATES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, DEFAULT_CATEGORIES: [ 'Advertising', 'Benefits', @@ -1943,11 +1944,6 @@ const CONST = { DUPLICATE_SUBSCRIPTION: 'duplicateSubscription', FAILED_TO_CLEAR_BALANCE: 'failedToClearBalance', }, - TAX_RATES_BULK_ACTION_TYPES: { - DELETE: 'delete', - DISABLE: 'disable', - ENABLE: 'enable', - }, COLLECTION_KEYS: { DESCRIPTION: 'description', REIMBURSER: 'reimburser', @@ -2295,6 +2291,7 @@ const CONST = { LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, TAG_NAME_LIMIT: 256, + WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH: 256, REPORT_NAME_LIMIT: 100, TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 500, @@ -4117,13 +4114,13 @@ const CONST = { type: 'setupCategories', autoCompleted: false, title: 'Set up categories', - description: + description: ({workspaceLink}: {workspaceLink: string}) => '*Set up categories* so your team can code expenses for easy reporting.\n' + '\n' + 'Here’s how to set up categories:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to *Workspaces* > [your workspace].\n' + + `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + '3. Click *Categories*.\n' + '4. Enable and disable default categories.\n' + '5. Click *Add categories* to make your own.\n' + @@ -4134,13 +4131,13 @@ const CONST = { type: 'addExpenseApprovals', autoCompleted: false, title: 'Add expense approvals', - description: + description: ({workspaceLink}: {workspaceLink: string}) => '*Add expense approvals* to review your team’s spend and keep it under control.\n' + '\n' + 'Here’s how to add expense approvals:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to *Workspaces* > [your workspace].\n' + + `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + '3. Click *More features*.\n' + '4. Enable *Workflows*.\n' + '5. In *Workflows*, enable *Add approvals*.\n' + @@ -4151,13 +4148,13 @@ const CONST = { type: 'inviteTeam', autoCompleted: false, title: 'Invite your team', - description: + description: ({workspaceLink}: {workspaceLink: string}) => '*Invite your team* to Expensify so they can start tracking expenses today.\n' + '\n' + 'Here’s how to invite your team:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to *Workspaces* > [your workspace].\n' + + `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + '3. Click *Members* > *Invite member*.\n' + '4. Enter emails or phone numbers. \n' + '5. Add an invite message if you want.\n' + @@ -5118,6 +5115,12 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], + + REPORT_FIELD_TYPES: { + TEXT: 'text', + DATE: 'date', + LIST: 'dropdown', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5088c1d3158f..d31a47ccbb57 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -359,12 +359,17 @@ const ONYXKEYS = { /** Holds the checks used while transferring the ownership of the workspace */ POLICY_OWNERSHIP_CHANGE_CHECKS: 'policyOwnershipChangeChecks', + // These statuses below are in separate keys on purpose - it allows us to have different behaviours of the banner based on the status + /** Indicates whether ClearOutstandingBalance failed */ SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED: 'subscriptionRetryBillingStatusFailed', /** Indicates whether ClearOutstandingBalance was successful */ SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL: 'subscriptionRetryBillingStatusSuccessful', + /** Indicates whether ClearOutstandingBalance is pending */ + SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING: 'subscriptionRetryBillingStatusPending', + /** Stores info during review duplicates flow */ REVIEW_DUPLICATES: 'reviewDuplicates', @@ -454,6 +459,8 @@ const ONYXKEYS = { WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft', WORKSPACE_TAX_CUSTOM_NAME: 'workspaceTaxCustomName', WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft', + WORKSPACE_REPORT_FIELDS_FORM: 'workspaceReportFieldsForm', + WORKSPACE_REPORT_FIELDS_FORM_DRAFT: 'workspaceReportFieldsFormDraft', POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm', POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft', POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm', @@ -552,6 +559,8 @@ const ONYXKEYS = { ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardFormDraft', SAGE_INTACCT_CREDENTIALS_FORM: 'sageIntacctCredentialsForm', SAGE_INTACCT_CREDENTIALS_FORM_DRAFT: 'sageIntacctCredentialsFormDraft', + NETSUITE_TOKEN_INPUT_FORM: 'netsuiteTokenInputForm', + NETSUITE_TOKEN_INPUT_FORM_DRAFT: 'netsuiteTokenInputFormDraft', }, } as const; @@ -564,6 +573,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAG_FORM]: FormTypes.WorkspaceTagForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; + [ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM]: FormTypes.WorkspaceReportFieldsForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; @@ -614,6 +624,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SUBSCRIPTION_SIZE_FORM]: FormTypes.SubscriptionSizeForm; [ONYXKEYS.FORMS.ISSUE_NEW_EXPENSIFY_CARD_FORM]: FormTypes.IssueNewExpensifyCardForm; [ONYXKEYS.FORMS.SAGE_INTACCT_CREDENTIALS_FORM]: FormTypes.SageIntactCredentialsForm; + [ONYXKEYS.FORMS.NETSUITE_TOKEN_INPUT_FORM]: FormTypes.NetSuiteTokenInputForm; }; type OnyxFormDraftValuesMapping = { @@ -781,6 +792,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: boolean; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: boolean; + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING]: boolean; [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2ce956052b39..81d55c63e801 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -783,6 +783,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields` as const, }, + WORKSPACE_CREATE_REPORT_FIELD: { + route: 'settings/workspaces/:policyID/reportFields/new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new` as const, + }, + WORKSPACE_REPORT_FIELD_LIST_VALUES: { + route: 'settings/workspaces/:policyID/reportFields/new/listValues', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new/listValues` as const, + }, + WORKSPACE_REPORT_FIELD_ADD_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/new/addValue', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new/addValue` as const, + }, + WORKSPACE_REPORT_FIELD_VALUE_SETTINGS: { + route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}` as const, + }, + WORKSPACE_REPORT_FIELD_EDIT_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex/edit', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}/edit` as const, + }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, @@ -940,6 +960,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const, }, + POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT: { + route: 'settings/workspaces/:policyID/accounting/netsuite/token-input', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/token-input` as const, + }, POLICY_ACCOUNTING_NETSUITE_IMPORT: { route: 'settings/workspaces/:policyID/accounting/netsuite/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c7f247a65078..82c93909e2cb 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -272,6 +272,7 @@ const SCREENS = { XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Policy_Accounting_Xero_Export_Bank_Account_Select', + NETSUITE_TOKEN_INPUT: 'Policy_Accounting_NetSuite_Token_Input', NETSUITE_SUBSIDIARY_SELECTOR: 'Policy_Accounting_NetSuite_Subsidiary_Selector', NETSUITE_IMPORT: 'Policy_Accounting_NetSuite_Import', NETSUITE_EXPORT: 'Policy_Accounting_NetSuite_Export', @@ -314,6 +315,11 @@ const SCREENS = { TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', REPORT_FIELDS: 'Workspace_ReportFields', + REPORT_FIELDS_CREATE: 'Workspace_ReportFields_Create', + REPORT_FIELDS_LIST_VALUES: 'Workspace_ReportFields_ListValues', + REPORT_FIELDS_ADD_VALUE: 'Workspace_ReportFields_AddValue', + REPORT_FIELDS_VALUE_SETTINGS: 'Workspace_ReportFields_ValueSettings', + REPORT_FIELDS_EDIT_VALUE: 'Workspace_ReportFields_EditValue', TAX_EDIT: 'Workspace_Tax_Edit', TAX_NAME: 'Workspace_Tax_Name', TAX_VALUE: 'Workspace_Tax_Value', diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts index 87a9108d5f2e..5893bcd9936e 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts @@ -1,5 +1,6 @@ import type {ForwardedRef} from 'react'; import {createContext} from 'react'; +import type {GestureType} from 'react-native-gesture-handler'; import type PagerView from 'react-native-pager-view'; import type {SharedValue} from 'react-native-reanimated'; import type {AttachmentSource} from '@components/Attachments/types'; @@ -17,16 +18,28 @@ type AttachmentCarouselPagerItems = { }; type AttachmentCarouselPagerContextValue = { - /** The list of items that are shown in the pager */ + /** List of attachments displayed in the pager */ pagerItems: AttachmentCarouselPagerItems[]; - /** The index of the active page */ + /** Index of the currently active page */ activePage: number; - pagerRef?: ForwardedRef; + + /** Ref to the active attachment */ + pagerRef?: ForwardedRef; + + /** Indicates if the pager is currently scrolling */ isPagerScrolling: SharedValue; + + /** Indicates if scrolling is enabled for the attachment */ isScrollEnabled: SharedValue; + + /** Function to call after a tap event */ onTap: () => void; + + /** Function to call when the scale changes */ onScaleChanged: (scale: number) => void; + + /** Function to call after a swipe down event */ onSwipeDown: () => void; }; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index b7ef9309eb10..f16ba2c53ae8 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -1,4 +1,4 @@ -import type {ForwardedRef} from 'react'; +import type {ForwardedRef, SetStateAction} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; @@ -8,6 +8,7 @@ import type {PagerViewProps} from 'react-native-pager-view'; import PagerView from 'react-native-pager-view'; import Animated, {useAnimatedProps, useSharedValue} from 'react-native-reanimated'; import CarouselItem from '@components/Attachments/AttachmentCarousel/CarouselItem'; +import useCarouselContextEvents from '@components/Attachments/AttachmentCarousel/useCarouselContextEvents'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; @@ -41,24 +42,21 @@ type AttachmentCarouselPagerProps = { >, ) => void; - /** - * A callback that can be used to toggle the attachment carousel arrows, when the scale of the image changes. - * @param showArrows If set, it will show/hide the arrows. If not set, it will toggle the arrows. - */ - onRequestToggleArrows: (showArrows?: boolean) => void; - /** A callback that is called when swipe-down-to-close gesture happens */ onClose: () => void; + + /** Sets the visibility of the arrows. */ + setShouldShowArrows: (show?: SetStateAction) => void; }; function AttachmentCarouselPager( - {items, activeSource, initialPage, onPageSelected, onRequestToggleArrows, onClose}: AttachmentCarouselPagerProps, + {items, activeSource, initialPage, setShouldShowArrows, onPageSelected, onClose}: AttachmentCarouselPagerProps, ref: ForwardedRef, ) { + const {handleTap, handleScaleChange} = useCarouselContextEvents(setShouldShowArrows); const styles = useThemeStyles(); const pagerRef = useRef(null); - const scale = useRef(1); const isPagerScrolling = useSharedValue(false); const isScrollEnabled = useSharedValue(true); @@ -80,42 +78,6 @@ function AttachmentCarouselPager( /** The `pagerItems` object that passed down to the context. Later used to detect current page, whether it's a single image gallery etc. */ const pagerItems = useMemo(() => items.map((item, index) => ({source: item.source, index, isActive: index === activePageIndex})), [activePageIndex, items]); - /** - * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. - * It is used to react to zooming/pinching and (mostly) enabling/disabling scrolling on the pager, - * as well as enabling/disabling the carousel buttons. - */ - const handleScaleChange = useCallback( - (newScale: number) => { - if (newScale === scale.current) { - return; - } - - scale.current = newScale; - - const newIsScrollEnabled = newScale === 1; - if (isScrollEnabled.value === newIsScrollEnabled) { - return; - } - - isScrollEnabled.value = newIsScrollEnabled; - onRequestToggleArrows(newIsScrollEnabled); - }, - [isScrollEnabled, onRequestToggleArrows], - ); - - /** - * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. - * It is used to trigger touch events on the pager when the user taps on the MultiGestureCanvas/Lightbox. - */ - const handleTap = useCallback(() => { - if (!isScrollEnabled.value) { - return; - } - - onRequestToggleArrows(); - }, [isScrollEnabled.value, onRequestToggleArrows]); - const extractItemKey = useCallback( (item: Attachment, index: number) => typeof item.source === 'string' || typeof item.source === 'number' ? `source-${item.source}` : `reportActionID-${item.reportActionID}` ?? `index-${index}`, diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index 15740725c42e..243fc52f1f5d 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -96,22 +96,6 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, [autoHideArrows, page, updatePage], ); - /** - * Toggles the arrows visibility - * @param {Boolean} showArrows if showArrows is passed, it will set the visibility to the passed value - */ - const toggleArrows = useCallback( - (showArrows?: boolean) => { - if (showArrows === undefined) { - setShouldShowArrows((prevShouldShowArrows) => !prevShouldShowArrows); - return; - } - - setShouldShowArrows(showArrows); - }, - [setShouldShowArrows], - ); - const containerStyles = [styles.flex1, styles.attachmentCarouselContainer]; if (page == null) { @@ -147,7 +131,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, items={attachments} initialPage={page} activeSource={activeSource} - onRequestToggleArrows={toggleArrows} + setShouldShowArrows={setShouldShowArrows} onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} onClose={onClose} ref={pagerRef} diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index eeac97bc5fa5..8f4a4446df99 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -1,10 +1,12 @@ import isEqual from 'lodash/isEqual'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {MutableRefObject} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {Keyboard, PixelRatio, View} from 'react-native'; +import type {GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; -import Animated, {scrollTo, useAnimatedRef} from 'react-native-reanimated'; +import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import BlockingView from '@components/BlockingViews/BlockingView'; import * as Illustrations from '@components/Icon/Illustrations'; @@ -22,8 +24,10 @@ import CarouselActions from './CarouselActions'; import CarouselButtons from './CarouselButtons'; import CarouselItem from './CarouselItem'; import extractAttachments from './extractAttachments'; +import AttachmentCarouselPagerContext from './Pager/AttachmentCarouselPagerContext'; import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps, UpdatePageProps} from './types'; import useCarouselArrows from './useCarouselArrows'; +import useCarouselContextEvents from './useCarouselContextEvents'; const viewabilityConfig = { // To facilitate paging through the attachments, we want to consider an item "viewable" when it is @@ -33,13 +37,15 @@ const viewabilityConfig = { const MIN_FLING_VELOCITY = 500; -function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, type, accountID}: AttachmentCarouselProps) { +function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose}: AttachmentCarouselProps) { const theme = useTheme(); const {translate} = useLocalize(); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); const styles = useThemeStyles(); const {isFullScreenRef} = useFullScreenContext(); const scrollRef = useAnimatedRef>>(); + const nope = useSharedValue(false); + const pagerRef = useRef(null); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -52,6 +58,14 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, const [attachments, setAttachments] = useState([]); const [activeSource, setActiveSource] = useState(source); const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows(); + const {handleTap, handleScaleChange, scale} = useCarouselContextEvents(setShouldShowArrows); + + useEffect(() => { + if (!canUseTouchScreen) { + return; + } + setShouldShowArrows(true); + }, [canUseTouchScreen, page, setShouldShowArrows]); const compareImage = useCallback((attachment: Attachment) => attachment.source === source, [source]); @@ -169,6 +183,20 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, [cellWidth], ); + const context = useMemo( + () => ({ + pagerItems: [{source, index: 0, isActive: true}], + activePage: 0, + pagerRef, + isPagerScrolling: nope, + isScrollEnabled: nope, + onTap: handleTap, + onScaleChanged: handleScaleChange, + onSwipeDown: onClose, + }), + [source, nope, handleTap, handleScaleChange, onClose], + ); + /** Defines how a single attachment should be rendered */ const renderItem = useCallback( ({item}: ListRenderItemInfo) => ( @@ -176,20 +204,30 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, setShouldShowArrows((oldState) => !oldState) : undefined} + onPress={canUseTouchScreen ? handleTap : undefined} isModalHovered={shouldShowArrows} /> ), - [activeSource, canUseTouchScreen, cellWidth, setShouldShowArrows, shouldShowArrows, styles.h100], + [activeSource, canUseTouchScreen, cellWidth, handleTap, shouldShowArrows, styles.h100], ); /** Pan gesture handing swiping through attachments on touch screen devices */ const pan = useMemo( () => Gesture.Pan() .enabled(canUseTouchScreen) - .onUpdate(({translationX}) => scrollTo(scrollRef, page * cellWidth - translationX, 0, false)) + .onUpdate(({translationX}) => { + if (scale.current !== 1) { + return; + } + + scrollTo(scrollRef, page * cellWidth - translationX, 0, false); + }) .onEnd(({translationX, velocityX}) => { + if (scale.current !== 1) { + return; + } + let newIndex; if (velocityX > MIN_FLING_VELOCITY) { // User flung to the right @@ -204,8 +242,9 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, } scrollTo(scrollRef, newIndex * cellWidth, 0, true); - }), - [attachments.length, canUseTouchScreen, cellWidth, page, scrollRef], + }) + .withRef(pagerRef as MutableRefObject), + [attachments.length, canUseTouchScreen, cellWidth, page, scale, scrollRef], ); return ( @@ -233,27 +272,28 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, autoHideArrow={autoHideArrows} cancelAutoHideArrow={cancelAutoHideArrows} /> - - - - + + + + + diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts index 12ca3db4e2ff..a7ce0f93114b 100644 --- a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts +++ b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts @@ -32,6 +32,9 @@ function useCarouselArrows() { }, CONST.ARROW_HIDE_DELAY); }, [canUseTouchScreen, cancelAutoHideArrows]); + /** + * Sets the visibility of the arrows. + */ const setShouldShowArrows = useCallback( (show: SetStateAction = true) => { setShouldShowArrowsInternal(show); diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts new file mode 100644 index 000000000000..d516879322ea --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts @@ -0,0 +1,63 @@ +import {useCallback, useRef} from 'react'; +import type {SetStateAction} from 'react'; +import {useSharedValue} from 'react-native-reanimated'; + +function useCarouselContextEvents(setShouldShowArrows: (show?: SetStateAction) => void) { + const scale = useRef(1); + const isScrollEnabled = useSharedValue(true); + + /** + * Toggles the arrows visibility + */ + const onRequestToggleArrows = useCallback( + (showArrows?: boolean) => { + if (showArrows === undefined) { + setShouldShowArrows((prevShouldShowArrows) => !prevShouldShowArrows); + return; + } + + setShouldShowArrows(showArrows); + }, + [setShouldShowArrows], + ); + + /** + * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. + * It is used to react to zooming/pinching and (mostly) enabling/disabling scrolling on the pager, + * as well as enabling/disabling the carousel buttons. + */ + const handleScaleChange = useCallback( + (newScale: number) => { + if (newScale === scale.current) { + return; + } + + scale.current = newScale; + + const newIsScrollEnabled = newScale === 1; + if (isScrollEnabled.value === newIsScrollEnabled) { + return; + } + + isScrollEnabled.value = newIsScrollEnabled; + onRequestToggleArrows(newIsScrollEnabled); + }, + [isScrollEnabled, onRequestToggleArrows], + ); + + /** + * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. + * It is used to trigger touch events on the pager when the user taps on the MultiGestureCanvas/Lightbox. + */ + const handleTap = useCallback(() => { + if (!isScrollEnabled.value) { + return; + } + + onRequestToggleArrows(); + }, [isScrollEnabled.value, onRequestToggleArrows]); + + return {handleTap, handleScaleChange, scale}; +} + +export default useCarouselContextEvents; diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 702f0380ceef..d1eedd560694 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -10,9 +10,9 @@ type PaymentType = DeepValueOf; -type WorkspaceDistanceRatesBulkActionType = DeepValueOf; +type WorkspaceDistanceRatesBulkActionType = DeepValueOf; -type WorkspaceTaxRatesBulkActionType = DeepValueOf; +type WorkspaceTaxRatesBulkActionType = DeepValueOf; type DropdownOption = { value: TValueType; diff --git a/src/components/ConnectToNetSuiteButton/index.tsx b/src/components/ConnectToNetSuiteButton/index.tsx index fc948503a127..a0cd36671117 100644 --- a/src/components/ConnectToNetSuiteButton/index.tsx +++ b/src/components/ConnectToNetSuiteButton/index.tsx @@ -26,8 +26,7 @@ function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeCon return; } - // TODO: Will be updated to new token input page - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID)); }} text={translate('workspace.accounting.setup')} style={styles.justifyContentCenter} @@ -39,8 +38,7 @@ function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeCon onConfirm={() => { removePolicyConnection(policyID, integrationToDisconnect); - // TODO: Will be updated to new token input page - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID)); setIsDisconnectModalOpen(false); }} integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} diff --git a/src/components/ConnectToQuickbooksOnlineButton/index.tsx b/src/components/ConnectToQuickbooksOnlineButton/index.tsx index 71f1fba91187..50ee9165b8a3 100644 --- a/src/components/ConnectToQuickbooksOnlineButton/index.tsx +++ b/src/components/ConnectToQuickbooksOnlineButton/index.tsx @@ -40,6 +40,8 @@ function ConnectToQuickbooksOnlineButton({policyID, shouldDisconnectIntegrationB {shouldDisconnectIntegrationBeforeConnecting && integrationToDisconnect && isDisconnectModalOpen && ( { + // Since QBO doesn't support Taxes, we should disable them from the LHN when connecting to QBO + PolicyAction.enablePolicyTaxes(policyID, false); removePolicyConnection(policyID, integrationToDisconnect); Link.openLink(getQuickBooksOnlineSetupLink(policyID), environmentURL); setIsDisconnectModalOpen(false); diff --git a/src/components/ConnectionLayout.tsx b/src/components/ConnectionLayout.tsx index adb607c8e98b..dc8638f018d4 100644 --- a/src/components/ConnectionLayout.tsx +++ b/src/components/ConnectionLayout.tsx @@ -9,7 +9,6 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import type {AccessVariant} from '@pages/workspace/AccessOrNotFoundWrapper'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {TranslationPaths} from '@src/languages/types'; -import type {Route} from '@src/ROUTES'; import type {ConnectionName, PolicyFeatureName} from '@src/types/onyx/Policy'; import HeaderWithBackButton from './HeaderWithBackButton'; import ScreenWrapper from './ScreenWrapper'; @@ -20,9 +19,6 @@ type ConnectionLayoutProps = { /** Used to set the testID for tests */ displayName: string; - /* The route on back button press */ - onBackButtonPressRoute?: Route; - /** Header title to be translated for the connection component */ headerTitle?: TranslationPaths; @@ -64,6 +60,12 @@ type ConnectionLayoutProps = { /** Name of the current connection */ connectionName: ConnectionName; + + /** Block the screen when the connection is not empty */ + reverseConnectionEmptyCheck?: boolean; + + /** Handler for back button press */ + onBackButtonPress?: () => void; }; type ConnectionLayoutContentProps = Pick; @@ -81,7 +83,6 @@ function ConnectionLayoutContent({title, titleStyle, children, titleAlreadyTrans function ConnectionLayout({ displayName, - onBackButtonPressRoute, headerTitle, children, title, @@ -96,6 +97,8 @@ function ConnectionLayout({ shouldUseScrollView = true, headerTitleAlreadyTranslated, titleAlreadyTranslated, + reverseConnectionEmptyCheck = false, + onBackButtonPress = () => Navigation.goBack(), }: ConnectionLayoutProps) { const {translate} = useLocalize(); @@ -120,7 +123,7 @@ function ConnectionLayout({ policyID={policyID} accessVariants={accessVariants} featureName={featureName} - shouldBeBlocked={isConnectionEmpty} + shouldBeBlocked={reverseConnectionEmptyCheck ? !isConnectionEmpty : isConnectionEmpty} > Navigation.goBack(onBackButtonPressRoute)} + onBackButtonPress={onBackButtonPress} /> {shouldUseScrollView ? ( {renderSelectionContent} diff --git a/src/components/EReceiptThumbnail.tsx b/src/components/EReceiptThumbnail.tsx index f4216dcc9f8a..a8d636db460b 100644 --- a/src/components/EReceiptThumbnail.tsx +++ b/src/components/EReceiptThumbnail.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TripReservationUtils from '@libs/TripReservationUtils'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -56,7 +57,8 @@ const backgroundImages = { function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptThumbnail = false, centerIconV = true, iconSize = 'large'}: EReceiptThumbnailProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const colorCode = isReceiptThumbnail ? StyleUtils.getFileExtensionColorCode(fileExtension) : StyleUtils.getEReceiptColorCode(transaction); + const {tripIcon, tripBGColor} = TripReservationUtils.getTripEReceiptData(transaction); + const colorCode = tripBGColor ?? (isReceiptThumbnail ? StyleUtils.getFileExtensionColorCode(fileExtension) : StyleUtils.getEReceiptColorCode(transaction)); const backgroundImage = useMemo(() => backgroundImages[colorCode], [colorCode]); @@ -141,6 +143,14 @@ function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptT fill={primaryColor} /> ) : null} + {tripIcon ? ( + + ) : null} diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index 6a1409ab4a93..d81293729b94 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -1,12 +1,14 @@ import {useFocusEffect, useIsFocused, useRoute} from '@react-navigation/native'; import FocusTrap from 'focus-trap-react'; import React, {useCallback, useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import BOTTOM_TAB_SCREENS from '@components/FocusTrap/BOTTOM_TAB_SCREENS'; -import SCREENS_WITH_AUTOFOCUS from '@components/FocusTrap/SCREENS_WITH_AUTOFOCUS'; +import getScreenWithAutofocus from '@components/FocusTrap/SCREENS_WITH_AUTOFOCUS'; import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import TOP_TAB_SCREENS from '@components/FocusTrap/TOP_TAB_SCREENS'; import WIDE_LAYOUT_INACTIVE_SCREENS from '@components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import ONYXKEYS from '@src/ONYXKEYS'; import type FocusTrapProps from './FocusTrapProps'; let activeRouteName = ''; @@ -14,6 +16,8 @@ function FocusTrapForScreen({children}: FocusTrapProps) { const isFocused = useIsFocused(); const route = useRoute(); const {isSmallScreenWidth} = useWindowDimensions(); + const [isAuthenticated] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => !!session?.authToken}); + const screensWithAutofocus = useMemo(() => getScreenWithAutofocus(!!isAuthenticated), [isAuthenticated]); const isActive = useMemo(() => { // Focus trap can't be active on bottom tab screens because it would block access to the tab bar. @@ -49,13 +53,13 @@ function FocusTrapForScreen({children}: FocusTrapProps) { fallbackFocus: document.body, // We don't want to ovverride autofocus on these screens. initialFocus: () => { - if (SCREENS_WITH_AUTOFOCUS.includes(activeRouteName)) { + if (screensWithAutofocus.includes(activeRouteName)) { return false; } return undefined; }, setReturnFocus: (element) => { - if (SCREENS_WITH_AUTOFOCUS.includes(activeRouteName)) { + if (screensWithAutofocus.includes(activeRouteName)) { return false; } return element; diff --git a/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts b/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts index 2a77b52e3116..7af327d35ac4 100644 --- a/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts +++ b/src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts @@ -1,4 +1,5 @@ import {CENTRAL_PANE_WORKSPACE_SCREENS} from '@libs/Navigation/AppNavigator/Navigators/FullScreenNavigator'; +import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; const SCREENS_WITH_AUTOFOCUS: string[] = [ @@ -10,6 +11,14 @@ const SCREENS_WITH_AUTOFOCUS: string[] = [ SCREENS.SETTINGS.PROFILE.PRONOUNS, SCREENS.NEW_TASK.DETAILS, SCREENS.MONEY_REQUEST.CREATE, + SCREENS.SIGN_IN_ROOT, ]; -export default SCREENS_WITH_AUTOFOCUS; +function getScreenWithAutofocus(isAuthenticated: boolean) { + if (!isAuthenticated) { + return [...SCREENS_WITH_AUTOFOCUS, NAVIGATORS.BOTTOM_TAB_NAVIGATOR]; + } + return SCREENS_WITH_AUTOFOCUS; +} + +export default getScreenWithAutofocus; diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 6245fdcf7b49..afbe2bb124b5 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -49,12 +49,14 @@ type ValidInputs = | typeof AddPlaidBankAccount | typeof EmojiPickerButtonDropdown; -type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country'; +type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'reportFields' | 'disabledListValues'; type ValueTypeMap = { string: string; boolean: boolean; date: Date; country: Country | ''; + reportFields: string[]; + disabledListValues: boolean[]; }; type FormValue = ValueOf; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index e699badc43ec..bd0824372799 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -1,3 +1,4 @@ +import ExpensifyCardIllustration from '@assets/images/expensifyCard/cardIllustration.svg'; import Abracadabra from '@assets/images/product-illustrations/abracadabra.svg'; import BankArrowPink from '@assets/images/product-illustrations/bank-arrow--pink.svg'; import BankMouseGreen from '@assets/images/product-illustrations/bank-mouse--green.svg'; @@ -176,6 +177,7 @@ export { Binoculars, CompanyCard, ReceiptUpload, + ExpensifyCardIllustration, SplitBill, PiggyBank, Accounting, diff --git a/src/components/ImageView/index.tsx b/src/components/ImageView/index.tsx index c74d9bd5aa52..e12be53d01ae 100644 --- a/src/components/ImageView/index.tsx +++ b/src/components/ImageView/index.tsx @@ -7,6 +7,7 @@ import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Image from '@components/Image'; import RESIZE_MODES from '@components/Image/resizeModes'; import type {ImageOnLoadEvent} from '@components/Image/types'; +import Lightbox from '@components/Lightbox'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -200,25 +201,11 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV if (canUseTouchScreen) { return ( - - 1 ? RESIZE_MODES.center : RESIZE_MODES.contain} - onLoadStart={imageLoadingStart} - onLoad={imageLoad} - onError={onError} - /> - {((isLoading && (!isOffline || isLocalFile)) || (!isLoading && zoomScale === 0)) && } - {isLoading && !isLocalFile && } - + ); } return ( diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx index 20b3f6bc79a4..d8899a317df5 100644 --- a/src/components/InteractiveStepSubHeader.tsx +++ b/src/components/InteractiveStepSubHeader.tsx @@ -25,6 +25,9 @@ type InteractiveStepSubHeaderProps = { type InteractiveStepSubHeaderHandle = { /** Move to the next step */ moveNext: () => void; + + /** Move to the previous step */ + movePrevious: () => void; }; const MIN_AMOUNT_FOR_EXPANDING = 3; @@ -45,6 +48,9 @@ function InteractiveStepSubHeader({stepNames, startStepIndex = 0, onStepSelected moveNext: () => { setCurrentStep((actualStep) => actualStep + 1); }, + movePrevious: () => { + setCurrentStep((actualStep) => actualStep - 1); + }, }), [], ); diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 76be659b35b4..780c8c7d2ea4 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -90,10 +90,11 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(moneyRequestReport); const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); const isApproved = ReportUtils.isReportApproved(moneyRequestReport); + const isClosed = ReportUtils.isClosedReport(moneyRequestReport); const isOnHold = TransactionUtils.isOnHold(transaction); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isDeletedParentAction = !!requestParentReportAction && ReportActionsUtils.isDeletedAction(requestParentReportAction); - const canHoldOrUnholdRequest = !isEmptyObject(transaction) && !isSettled && !isApproved && !isDeletedParentAction; + const canHoldOrUnholdRequest = !isEmptyObject(transaction) && !isSettled && !isApproved && !isDeletedParentAction && !isClosed; // Only the requestor can delete the request, admins can only edit it. const isActionOwner = @@ -146,7 +147,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const displayedAmount = ReportUtils.hasHeldExpenses(moneyRequestReport.reportID) && canAllowSettlement ? nonHeldAmount : formattedAmount; const isMoreContentShown = shouldShowNextStep || shouldShowStatusBar || (shouldShowAnyButton && shouldUseNarrowLayout); - const confirmPayment = (type?: PaymentMethodType | undefined) => { + const confirmPayment = (type?: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type || !chatReport) { return; } @@ -155,7 +156,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { - IOU.payInvoice(type, chatReport, moneyRequestReport); + IOU.payInvoice(type, chatReport, moneyRequestReport, payAsBusiness); } else { IOU.payMoneyRequest(type, chatReport, moneyRequestReport, true); } @@ -436,6 +437,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea paymentType={paymentType} chatReport={chatReport} moneyRequestReport={moneyRequestReport} + transactionCount={transactionIDs.length} /> )} ; + /** The policy tag lists */ + policyTags: OnyxEntry; + /** The policy tag lists */ policyTagLists: Array>; @@ -193,6 +196,7 @@ function MoneyRequestConfirmationListFooter({ isTypeInvoice, onToggleBillable, policy, + policyTags, policyTagLists, rate, receiptFilename, @@ -226,6 +230,7 @@ function MoneyRequestConfirmationListFooter({ // A flag for showing the tags field // TODO: remove the !isTypeInvoice from this condition after BE supports tags for invoices: https://github.com/Expensify/App/issues/41281 const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists) && !isTypeInvoice, [isPolicyExpenseChat, isTypeInvoice, policyTagLists]); + const isMultilevelTags = useMemo(() => PolicyUtils.isMultiLevelTags(policyTags), [policyTags]); const senderWorkspace = useMemo(() => { const senderWorkspaceParticipant = selectedParticipants.find((participant) => participant.isSender); @@ -437,8 +442,9 @@ function MoneyRequestConfirmationListFooter({ shouldShow: shouldShowCategories, isSupplementary: action === CONST.IOU.ACTION.CATEGORIZE ? false : !isCategoryRequired, }, - ...policyTagLists.map(({name, required}, index) => { + ...policyTagLists.map(({name, required, tags}, index) => { const isTagRequired = required ?? false; + const shouldShow = shouldShowTags && (!isMultilevelTags || OptionsListUtils.hasEnabledOptions(tags)); return { item: ( ), - shouldShow: shouldShowTags, + shouldShow, isSupplementary: !isTagRequired, }; }), diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 31a1f7a2c3d8..6b93e2e125e0 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -1,6 +1,7 @@ import type {ForwardedRef} from 'react'; import React, {useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; +import type {GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import type {GestureRef} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture'; import type PagerView from 'react-native-pager-view'; @@ -40,7 +41,7 @@ type MultiGestureCanvasProps = ChildrenProps & { shouldDisableTransformationGestures?: SharedValue; /** If there is a pager wrapping the canvas, we need to disable the pan gesture in case the pager is swiping */ - pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude + pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude /** Handles scale changed event */ onScaleChanged?: OnScaleChangedCallback; @@ -48,6 +49,7 @@ type MultiGestureCanvasProps = ChildrenProps & { /** Handles scale changed event */ onTap?: OnTapCallback; + /** Handles swipe down event */ onSwipeDown?: OnSwipeDownCallback; }; @@ -242,11 +244,12 @@ function MultiGestureCanvas({ e.preventDefault()} style={StyleUtils.getFullscreenCenteredContentStyles()} > {children} diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts index 903f384dd525..636913fdf05d 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -3,6 +3,7 @@ import {Dimensions} from 'react-native'; import type {PanGesture} from 'react-native-gesture-handler'; import {Gesture} from 'react-native-gesture-handler'; import {runOnJS, useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated'; +import * as Browser from '@libs/Browser'; import {SPRING_CONFIG} from './constants'; import type {MultiGestureCanvasVariables} from './types'; import * as MultiGestureCanvasUtils from './utils'; @@ -57,6 +58,8 @@ const usePanGesture = ({ const panVelocityX = useSharedValue(0); const panVelocityY = useSharedValue(0); + const isMobileBrowser = Browser.isMobile(); + // Disable "swipe down to close" gesture when content is bigger than the canvas const enableSwipeDownToClose = useDerivedValue(() => canvasSize.height < zoomedContentHeight.value, [canvasSize.height]); @@ -207,7 +210,9 @@ const usePanGesture = ({ panVelocityY.value = evt.velocityY; if (!isSwipingDownToClose.value) { - panTranslateX.value += evt.changeX; + if (!isMobileBrowser || (isMobileBrowser && zoomScale.value !== 1)) { + panTranslateX.value += evt.changeX; + } } if (enableSwipeDownToClose.value || isSwipingDownToClose.value) { diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 46ec51994d90..872464d8a5b0 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; @@ -40,6 +40,9 @@ type ProcessMoneyReportHoldMenuProps = { /** Type of action handled */ requestType?: ActionHandledType; + + /** Number of transaction of a money request */ + transactionCount: number; }; function ProcessMoneyReportHoldMenu({ @@ -52,6 +55,7 @@ function ProcessMoneyReportHoldMenu({ paymentType, chatReport, moneyRequestReport, + transactionCount, }: ProcessMoneyReportHoldMenuProps) { const {translate} = useLocalize(); const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE; @@ -68,12 +72,19 @@ function ProcessMoneyReportHoldMenu({ onClose(); }; + const promptText = useMemo(() => { + if (nonHeldAmount) { + return translate(isApprove ? 'iou.confirmApprovalAmount' : 'iou.confirmPayAmount'); + } + return translate(isApprove ? 'iou.confirmApprovalAllHoldAmount' : 'iou.confirmPayAllHoldAmount', {transactionCount}); + }, [nonHeldAmount, transactionCount, translate, isApprove]); + return ( onSubmit(false)} diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index c796a267fd01..9693b982ec4a 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -138,6 +138,7 @@ function ReportPreview({ const moneyRequestComment = action?.childLastMoneyRequestComment ?? ''; const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); + const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport); const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); const isApproved = ReportUtils.isReportApproved(iouReport, action); @@ -177,7 +178,7 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); - const confirmPayment = (type: PaymentMethodType | undefined) => { + const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { return; } @@ -187,7 +188,7 @@ function ReportPreview({ setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { if (ReportUtils.isInvoiceReport(iouReport)) { - IOU.payInvoice(type, chatReport, iouReport); + IOU.payInvoice(type, chatReport, iouReport, payAsBusiness); } else { IOU.payMoneyRequest(type, chatReport, iouReport); } @@ -246,7 +247,16 @@ function ReportPreview({ if (isScanning) { return translate('common.receipt'); } - let payerOrApproverName = isPolicyExpenseChat ? ReportUtils.getPolicyName(chatReport) : ReportUtils.getDisplayNameForParticipant(managerID, true); + + let payerOrApproverName; + if (isPolicyExpenseChat) { + payerOrApproverName = ReportUtils.getPolicyName(chatReport); + } else if (isInvoiceRoom) { + payerOrApproverName = ReportUtils.getInvoicePayerName(chatReport); + } else { + payerOrApproverName = ReportUtils.getDisplayNameForParticipant(managerID, true); + } + if (isApproved) { return translate('iou.managerApproved', {manager: payerOrApproverName}); } @@ -454,6 +464,7 @@ function ReportPreview({ paymentType={paymentType} chatReport={chatReport} moneyRequestReport={iouReport} + transactionCount={numberOfRequests} /> )} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 878d25da4af4..503f7d11d2da 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -166,7 +166,7 @@ function BaseSelectionList( itemLayouts.push({length: fullItemHeight, offset}); offset += fullItemHeight; - if (item.isSelected) { + if (item.isSelected && !selectedOptions.find((option) => option.text === item.text)) { selectedOptions.push(item); } }); diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 553839ae8457..7119cee06cd9 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -88,8 +88,8 @@ function ReportListItem({ return null; } - const participantFrom = reportItem.transactions[0].from; - const participantTo = reportItem.transactions[0].to; + const participantFrom = reportItem.from; + const participantTo = reportItem.to; // These values should come as part of the item via SearchUtils.getSections() but ReportListItem is not yet 100% handled // This will be simplified in future once sorting of ReportListItem is done diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 9fc138254f8b..83bc8df36571 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -43,7 +43,7 @@ function TableListItem({ return ( ({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={isDisabled || item.isDisabledCheckbox} onPress={handleCheckboxPress} - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3]} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3, item.cursorStyle]} > - + {item.isSelected && ( void; + onPress: (paymentType?: PaymentMethodType, payAsBusiness?: boolean) => void; /** The route to redirect if user does not have a payment method setup */ enablePaymentsRoute: EnablePaymentsRoute; @@ -143,6 +145,9 @@ function SettlementButton({ }: SettlementButtonProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + + const primaryPolicy = useMemo(() => PolicyActions.getPrimaryPolicy(activePolicyID), [activePolicyID]); const session = useSession(); // The app would crash due to subscribing to the entire report collection if chatReportID is an empty string. So we should have a fallback ID here. @@ -199,20 +204,39 @@ function SettlementButton({ } if (isInvoiceReport) { - buttonOptions.push({ - text: translate('iou.settlePersonal', {formattedAmount}), - icon: Expensicons.User, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - backButtonText: translate('iou.individual'), - subMenuItems: [ - { - text: translate('iou.payElsewhere', {formattedAmount: ''}), - icon: Expensicons.Cash, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE), - }, - ], - }); + if (ReportUtils.isIndividualInvoiceRoom(chatReport)) { + buttonOptions.push({ + text: translate('iou.settlePersonal', {formattedAmount}), + icon: Expensicons.User, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + backButtonText: translate('iou.individual'), + subMenuItems: [ + { + text: translate('iou.payElsewhere', {formattedAmount: ''}), + icon: Expensicons.Cash, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE), + }, + ], + }); + } + + if (PolicyUtils.isPolicyAdmin(primaryPolicy) && PolicyUtils.isPaidGroupPolicy(primaryPolicy)) { + buttonOptions.push({ + text: translate('iou.settleBusiness', {formattedAmount}), + icon: Expensicons.Building, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + backButtonText: translate('iou.business'), + subMenuItems: [ + { + text: translate('iou.payElsewhere', {formattedAmount: ''}), + icon: Expensicons.Cash, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, true), + }, + ], + }); + } } if (shouldShowApproveButton) { @@ -226,7 +250,7 @@ function SettlementButton({ return buttonOptions; // We don't want to reorder the options when the preferred payment method changes while the button is still visible // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currency, formattedAmount, iouReport, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton, shouldDisableApproveButton]); + }, [currency, formattedAmount, iouReport, chatReport, policyID, translate, shouldHidePaymentOptions, primaryPolicy, shouldShowApproveButton, shouldDisableApproveButton]); const selectPaymentType = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { if (policy && SubscriptionUtils.shouldRestrictUserBillableActions(policy.id)) { @@ -259,7 +283,7 @@ function SettlementButton({ return ( onPress(paymentType)} enablePaymentsRoute={enablePaymentsRoute} addBankAccountRoute={addBankAccountRoute} addDebitCardRoute={addDebitCardRoute} diff --git a/src/components/SingleChoiceQuestion.tsx b/src/components/SingleChoiceQuestion.tsx index c2dc72438e43..e52007850475 100644 --- a/src/components/SingleChoiceQuestion.tsx +++ b/src/components/SingleChoiceQuestion.tsx @@ -22,7 +22,7 @@ function SingleChoiceQuestion({prompt, errorText, possibleAnswers, currentQuesti <> {prompt} diff --git a/src/components/TestToolsModal.tsx b/src/components/TestToolsModal.tsx index ad1c65e76a4b..5c330bd700e0 100644 --- a/src/components/TestToolsModal.tsx +++ b/src/components/TestToolsModal.tsx @@ -31,7 +31,7 @@ type TestToolsModalOnyxProps = { type TestToolsModalProps = TestToolsModalOnyxProps; function TestToolsModal({isTestToolsModalOpen = false, shouldStoreLogs = false}: TestToolsModalProps) { - const {isDevelopment} = useEnvironment(); + const {isProduction} = useEnvironment(); const {windowWidth} = useWindowDimensions(); const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); @@ -44,7 +44,6 @@ function TestToolsModal({isTestToolsModalOpen = false, shouldStoreLogs = false}: onClose={toggleTestToolsModal} > - {isDevelopment && } )} + {!isProduction && } ); diff --git a/src/components/TextPicker/TextSelectorModal.tsx b/src/components/TextPicker/TextSelectorModal.tsx index a867a7030b54..2016133559d4 100644 --- a/src/components/TextPicker/TextSelectorModal.tsx +++ b/src/components/TextPicker/TextSelectorModal.tsx @@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; @@ -14,7 +15,7 @@ import CONST from '@src/CONST'; import type {TextSelectorModalProps} from './types'; import usePaddingStyle from './usePaddingStyle'; -function TextSelectorModal({value, description = '', onValueSelected, isVisible, onClose, ...rest}: TextSelectorModalProps) { +function TextSelectorModal({value, description = '', subtitle, onValueSelected, isVisible, onClose, shouldClearOnClose, ...rest}: TextSelectorModalProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -24,6 +25,13 @@ function TextSelectorModal({value, description = '', onValueSelected, isVisible, const inputRef = useRef(null); const focusTimeoutRef = useRef(null); + const hide = useCallback(() => { + onClose(); + if (shouldClearOnClose) { + setValue(''); + } + }, [onClose, shouldClearOnClose]); + useFocusEffect( useCallback(() => { focusTimeoutRef.current = setTimeout(() => { @@ -44,8 +52,8 @@ function TextSelectorModal({value, description = '', onValueSelected, isVisible, + {!!subtitle && {subtitle}} & + + /** Whether to clear the input value when the modal closes */ + shouldClearOnClose?: boolean; +} & Pick & TextProps; type TextPickerProps = { @@ -39,7 +42,7 @@ type TextPickerProps = { /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; -} & Pick & +} & Pick & TextProps; export type {TextSelectorModalProps, TextPickerProps}; diff --git a/src/languages/en.ts b/src/languages/en.ts index aa3803438496..153d2e40fc25 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -16,6 +16,7 @@ import type { ChangePolicyParams, ChangeTypeParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, @@ -355,6 +356,9 @@ export default { companyID: 'Company ID', userID: 'User ID', disable: 'Disable', + initialValue: 'Initial value', + currentDate: 'Current date', + value: 'Value', }, location: { useCurrent: 'Use current location', @@ -699,6 +703,7 @@ export default { settledExpensify: 'Paid', settledElsewhere: 'Paid elsewhere', individual: 'Individual', + business: 'Business', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with Expensify` : `Pay with Expensify`), settlePersonal: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} as an individual` : `Pay as an individual`), settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount}`, @@ -788,8 +793,12 @@ export default { keepAll: 'Keep all', confirmApprove: 'Confirm approval amount', confirmApprovalAmount: "Approve what's not on hold, or approve the entire report.", + confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to approve anyway?`, confirmPay: 'Confirm payment amount', - confirmPayAmount: "Pay what's not on hold, or pay all out-of-pocket spend.", + confirmPayAmount: "Pay what's not on hold, or pay the entire report.", + confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to pay anyway?`, payOnly: 'Pay only', approveOnly: 'Approve only', holdEducationalTitle: 'This expense is on', @@ -972,6 +981,8 @@ export default { deviceCredentials: 'Device credentials', invalidate: 'Invalidate', destroy: 'Destroy', + maskExportOnyxStateData: 'Mask fragile user data while exporting Onyx state', + exportOnyxState: 'Export Onyx state', }, debugConsole: { saveLog: 'Save log', @@ -1987,7 +1998,7 @@ export default { reimburse: 'Reimbursements', categories: 'Categories', tags: 'Tags', - reportFields: 'Report Fields', + reportFields: 'Report fields', taxes: 'Taxes', bills: 'Bills', invoices: 'Invoices', @@ -2329,6 +2340,37 @@ export default { noItemsFoundDescription: 'Add invoice items in NetSuite and sync the connection again.', noSubsidiariesFound: 'No subsidiaries found', noSubsidiariesFoundDescription: 'Add the subsidiary in NetSuite and sync the connection again.', + tokenInput: { + title: 'NetSuite setup', + formSteps: { + installBundle: { + title: 'Install the Expensify bundle', + description: 'In NetSuite, go to *Customization > SuiteBundler > Search & Install Bundles* > search for "Expensify" > install the bundle.', + }, + enableTokenAuthentication: { + title: 'Enable token-based authentication', + description: 'In NetSuite, go to *Setup > Company > Enable Features > SuiteCloud* > enable *token-based authentication*.', + }, + enableSoapServices: { + title: 'Enable SOAP web services', + description: 'In NetSuite, go to *Setup > Company > Enable Features > SuiteCloud* > enable *SOAP Web Services*.', + }, + createAccessToken: { + title: 'Create an access token', + description: + 'In NetSuite, go to *Setup > Users/Roles > Access Tokens* > create an access token for the "Expensify" app and either the "Expensify Integration" or "Administrator" role.\n\n*Important:* Make sure you save the *Token ID* and *Token Secret* from this step. You\'ll need it for the next step.', + }, + enterCredentials: { + title: 'Enter your NetSuite credentials', + formInputs: { + netSuiteAccountID: 'NetSuite Account ID', + netSuiteTokenID: 'Token ID', + netSuiteTokenSecret: 'Token Secret', + }, + netSuiteAccountIDDescription: 'In NetSuite, go to *Setup > Integration > SOAP Web Services Preferences*.', + }, + }, + }, import: { expenseCategories: 'Expense categories', expenseCategoriesDescription: 'NetSuite expense categories import into Expensify as categories.', @@ -2427,6 +2469,16 @@ export default { disableCardTitle: 'Disable Expensify Card', disableCardPrompt: 'You can’t disable the Expensify Card because it’s already in use. Reach out to Concierge for next steps.', disableCardButton: 'Chat with Concierge', + feed: { + title: 'Get the Expensify Card', + subTitle: 'Streamline your business with the Expensify Card', + features: { + cashBack: 'Up to 2% cash back on every US purchase', + unlimited: 'Issue unlimited virtual cards', + spend: 'Spend controls and custom limits', + }, + ctaTitle: 'Issue new card', + }, }, workflows: { title: 'Workflows', @@ -2467,9 +2519,42 @@ export default { title: "You haven't created any report fields", subtitle: 'Add a custom field (text, date, or dropdown) that appears on reports.', }, - subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information", + subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information.", disableReportFields: 'Disable report fields', disableReportFieldsConfirmation: 'Are you sure? Text and date fields will be deleted, and lists will be disabled.', + textType: 'Text', + dateType: 'Date', + dropdownType: 'List', + textAlternateText: 'Add a field for free text input.', + dateAlternateText: 'Add a calendar for date selection.', + dropdownAlternateText: 'Add a list of options to choose from.', + nameInputSubtitle: 'Choose a name for the report field.', + typeInputSubtitle: 'Choose what type of report field to use.', + initialValueInputSubtitle: 'Enter a starting value to show in the report field.', + listValuesInputSubtitle: 'These values will appear in your report field dropdown. Enabled values can be selected by members.', + listInputSubtitle: 'These values will appear in your report field list. Enabled values can be selected by members.', + deleteValue: 'Delete value', + deleteValues: 'Delete values', + disableValue: 'Disable value', + disableValues: 'Disable values', + enableValue: 'Enable value', + enableValues: 'Enable values', + emptyReportFieldsValues: { + title: "You haven't created any list values", + subtitle: 'Add custom values to appear on reports.', + }, + deleteValuePrompt: 'Are you sure you want to delete this list value?', + deleteValuesPrompt: 'Are you sure you want to delete these list values?', + listValueRequiredError: 'Please enter a list value name', + existingListValueError: 'A list value with this name already exists', + editValue: 'Edit value', + listValues: 'List values', + addValue: 'Add value', + existingReportFieldNameError: 'A report field with this name already exists', + reportFieldNameRequiredError: 'Please enter a report field name', + reportFieldTypeRequiredError: 'Please choose a report field type', + reportFieldInitialValueRequiredError: 'Please choose a report field initial value', + genericFailureMessage: 'An error occurred while updating the report field. Please try again.', }, tags: { tagName: 'Tag name', @@ -2883,6 +2968,8 @@ export default { editor: { descriptionInputLabel: 'Description', nameInputLabel: 'Name', + typeInputLabel: 'Type', + initialValueInputLabel: 'Initial value', nameInputHelpText: "This is the name you'll see on your workspace.", nameIsRequiredError: "You'll need to give your workspace a name.", currencyInputLabel: 'Default currency', diff --git a/src/languages/es.ts b/src/languages/es.ts index 26fa8a037172..6091cfa8559d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -15,6 +15,7 @@ import type { ChangePolicyParams, ChangeTypeParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, @@ -346,6 +347,9 @@ export default { companyID: 'Empresa ID', userID: 'Usuario ID', disable: 'Deshabilitar', + initialValue: 'Valor inicial', + currentDate: 'Fecha actual', + value: 'Valor', }, connectionComplete: { title: 'Conexión completa', @@ -693,6 +697,7 @@ export default { settledExpensify: 'Pagado', settledElsewhere: 'Pagado de otra forma', individual: 'Individual', + business: 'Empresa', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`), settlePersonal: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pago ${formattedAmount} como individuo` : `Pago individual`), settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount}`, @@ -782,8 +787,20 @@ export default { keepAll: 'Mantener todos', confirmApprove: 'Confirmar importe a aprobar', confirmApprovalAmount: 'Aprueba lo que no está bloqueado, o aprueba todo el informe.', + confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( + 'aprobar', + 'aprobarlos', + transactionCount, + )} de todos modos?`, confirmPay: 'Confirmar importe de pago', - confirmPayAmount: 'Paga lo que no está bloqueado, o paga todos los gastos por cuenta propia.', + confirmPayAmount: 'Paga lo que no está bloqueado, o paga el informe completo.', + confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( + 'pagar', + 'pagarlo', + transactionCount, + )} de todos modos?`, payOnly: 'Solo pagar', approveOnly: 'Solo aprobar', hold: 'Bloquear', @@ -969,6 +986,8 @@ export default { deviceCredentials: 'Credenciales del dispositivo', invalidate: 'Invalidar', destroy: 'Destruir', + maskExportOnyxStateData: 'Enmascare los datos frágiles del usuario mientras exporta el estado Onyx', + exportOnyxState: 'Exportar estado Onyx', }, debugConsole: { saveLog: 'Guardar registro', @@ -2363,6 +2382,37 @@ export default { noItemsFoundDescription: 'Añade artículos de factura en NetSuite y sincroniza la conexión de nuevo.', noSubsidiariesFound: 'No se ha encontrado subsidiarias', noSubsidiariesFoundDescription: 'Añade la subsidiaria en NetSuite y sincroniza de nuevo la conexión.', + tokenInput: { + title: 'Netsuite configuración', + formSteps: { + installBundle: { + title: 'Instala el paquete de Expensify', + description: 'En NetSuite, ir a *Personalización > SuiteBundler > Buscar e Instalar Paquetes* > busca "Expensify" > instala el paquete.', + }, + enableTokenAuthentication: { + title: 'Habilitar la autenticación basada en token', + description: 'En NetSuite, ir a *Configuración > Empresa > Habilitar Funciones > SuiteCloud* > activar *autenticación basada en token*.', + }, + enableSoapServices: { + title: 'Habilitar servicios web SOAP', + description: 'En NetSuite, ir a *Configuración > Empresa > Habilitar funciones > SuiteCloud* > habilitar *Servicios Web SOAP*.', + }, + createAccessToken: { + title: 'Crear un token de acceso', + description: + 'En NetSuite, ir a *Configuración > Usuarios/Roles > Tokens de Acceso* > crear un token de acceso para la aplicación "Expensify" y tambiém para el rol de "Integración Expensify" o "Administrador".\n\n*Importante:* Asegúrese de guardar el ID y el secreto del Token en este paso. Los necesitará para el siguiente paso.', + }, + enterCredentials: { + title: 'Ingresa tus credenciales de NetSuite', + formInputs: { + netSuiteAccountID: 'ID de Cuenta NetSuite', + netSuiteTokenID: 'ID de Token', + netSuiteTokenSecret: 'Secreto de Token', + }, + netSuiteAccountIDDescription: 'En NetSuite, ir a *Configuración > Integración > Preferencias de Servicios Web SOAP*.', + }, + }, + }, import: { expenseCategories: 'Categorías de gastos', expenseCategoriesDescription: 'Las categorías de gastos de NetSuite se importan a Expensify como categorías.', @@ -2375,7 +2425,7 @@ export default { }, importTaxDescription: 'Importar grupos de impuestos desde NetSuite', importCustomFields: { - customSegments: 'Segmentos/registros personalizado', + customSegments: 'Segmentos/registros personalizados', customLists: 'Listas personalizado', }, }, @@ -2458,6 +2508,16 @@ export default { disableCardTitle: 'Deshabilitar la Tarjeta Expensify', disableCardPrompt: 'No puedes deshabilitar la Tarjeta Expensify porque ya está en uso. Por favor, contacta con Concierge para conocer los pasos a seguir.', disableCardButton: 'Chatear con Concierge', + feed: { + title: 'Consigue la Tarjeta Expensify', + subTitle: 'Optimiza tu negocio con la Tarjeta Expensify', + features: { + cashBack: 'Hasta un 2% de devolución en cada compra en Estadios Unidos', + unlimited: 'Emitir un número ilimitado de tarjetas virtuales', + spend: 'Controles de gastos y límites personalizados', + }, + ctaTitle: 'Emitir nueva tarjeta', + }, }, distanceRates: { title: 'Tasas de distancia', @@ -2502,9 +2562,42 @@ export default { title: 'No has creado ningún campo de informe', subtitle: 'Añade un campo personalizado (texto, fecha o desplegable) que aparezca en los informes.', }, - subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando desees solicitar información adicional', + subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando quieras solicitar información adicional.', disableReportFields: 'Desactivar campos de informe', disableReportFieldsConfirmation: 'Estás seguro? Se eliminarán los campos de texto y fecha y se desactivarán las listas.', + textType: 'Texto', + dateType: 'Fecha', + dropdownType: 'Lista', + textAlternateText: 'Añade un campo para introducir texto libre.', + dateAlternateText: 'Añade un calendario para la selección de fechas.', + dropdownAlternateText: 'Añade una lista de opciones para elegir.', + nameInputSubtitle: 'Elige un nombre para el campo del informe.', + typeInputSubtitle: 'Elige qué tipo de campo de informe utilizar.', + initialValueInputSubtitle: 'Ingresa un valor inicial para mostrar en el campo del informe.', + listValuesInputSubtitle: 'Estos valores aparecerán en el desplegable del campo de tu informe. Los miembros pueden seleccionar los valores habilitados.', + listInputSubtitle: 'Estos valores aparecerán en la lista de campos de tu informe. Los miembros pueden seleccionar los valores habilitados.', + deleteValue: 'Eliminar valor', + deleteValues: 'Eliminar valores', + disableValue: 'Desactivar valor', + disableValues: 'Desactivar valores', + enableValue: 'Habilitar valor', + enableValues: 'Habilitar valores', + emptyReportFieldsValues: { + title: 'No has creado ningún valor en la lista', + subtitle: 'Añade valores personalizados para que aparezcan en los informes.', + }, + deleteValuePrompt: '¿Estás seguro de que quieres eliminar este valor de la lista?', + deleteValuesPrompt: '¿Estás seguro de que quieres eliminar estos valores de la lista?', + listValueRequiredError: 'Ingresa un nombre para el valor de la lista', + existingListValueError: 'Ya existe un valor en la lista con este nombre', + editValue: 'Editar valor', + listValues: 'Valores de la lista', + addValue: 'Añade valor', + existingReportFieldNameError: 'Ya existe un campo de informe con este nombre', + reportFieldNameRequiredError: 'Ingresa un nombre de campo de informe', + reportFieldTypeRequiredError: 'Elige un tipo de campo de informe', + reportFieldInitialValueRequiredError: 'Elige un valor inicial de campo de informe', + genericFailureMessage: 'Se ha producido un error al actualizar el campo del informe. Por favor, inténtalo de nuevo.', }, tags: { tagName: 'Nombre de etiqueta', @@ -2918,6 +3011,8 @@ export default { editor: { nameInputLabel: 'Nombre', descriptionInputLabel: 'Descripción', + typeInputLabel: 'Tipo', + initialValueInputLabel: 'Valor inicial', nameInputHelpText: 'Este es el nombre que verás en tu espacio de trabajo.', nameIsRequiredError: 'Debes definir un nombre para tu espacio de trabajo.', currencyInputLabel: 'Moneda por defecto', diff --git a/src/languages/types.ts b/src/languages/types.ts index 7ec56760c2f1..78a711fe8282 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -298,6 +298,8 @@ type DistanceRateOperationsParams = {count: number}; type ReimbursementRateParams = {unit: Unit}; +type ConfirmHoldExpenseParams = {transactionCount: number}; + type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string}; type ChangePolicyParams = {fromPolicy: string; toPolicy: string}; @@ -350,6 +352,7 @@ export type { BeginningOfChatHistoryDomainRoomPartOneParams, CanceledRequestParams, CharacterLimitParams, + ConfirmHoldExpenseParams, ConfirmThatParams, DateShouldBeAfterParams, DateShouldBeBeforeParams, diff --git a/src/libs/API/parameters/ConnectPolicyToNetSuiteParams.ts b/src/libs/API/parameters/ConnectPolicyToNetSuiteParams.ts new file mode 100644 index 000000000000..2143ca1b039c --- /dev/null +++ b/src/libs/API/parameters/ConnectPolicyToNetSuiteParams.ts @@ -0,0 +1,8 @@ +type ConnectPolicyToNetSuiteParams = { + policyID: string; + netSuiteAccountID: string; + netSuiteTokenID: string; + netSuiteTokenSecret: string; +}; + +export default ConnectPolicyToNetSuiteParams; diff --git a/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts b/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts new file mode 100644 index 000000000000..13844a279905 --- /dev/null +++ b/src/libs/API/parameters/CreateWorkspaceReportFieldParams.ts @@ -0,0 +1,6 @@ +type CreateWorkspaceReportFieldParams = { + policyID: string; + reportFields: string; +}; + +export default CreateWorkspaceReportFieldParams; diff --git a/src/libs/API/parameters/PayInvoiceParams.ts b/src/libs/API/parameters/PayInvoiceParams.ts index 4c6633749adb..a6b9746d87bc 100644 --- a/src/libs/API/parameters/PayInvoiceParams.ts +++ b/src/libs/API/parameters/PayInvoiceParams.ts @@ -4,6 +4,7 @@ type PayInvoiceParams = { reportID: string; reportActionID: string; paymentMethodType: PaymentMethodType; + payAsBusiness: boolean; }; export default PayInvoiceParams; diff --git a/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts b/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts new file mode 100644 index 000000000000..9227f40997ff --- /dev/null +++ b/src/libs/API/parameters/SyncPolicyToNetSuiteParams.ts @@ -0,0 +1,6 @@ +type SyncPolicyToNetSuiteParams = { + policyID: string; + idempotencyKey: string; +}; + +export default SyncPolicyToNetSuiteParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 2f203a4cfd9a..848af7e34634 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -14,6 +14,7 @@ export type {default as ConnectBankAccountParams} from './ConnectBankAccountPara export type {default as ConnectPolicyToAccountingIntegrationParams} from './ConnectPolicyToAccountingIntegrationParams'; export type {default as SyncPolicyToQuickbooksOnlineParams} from './SyncPolicyToQuickbooksOnlineParams'; export type {default as SyncPolicyToXeroParams} from './SyncPolicyToXeroParams'; +export type {default as SyncPolicyToNetSuiteParams} from './SyncPolicyToNetSuiteParams'; export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams'; export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams'; export type {default as DeletePaymentCardParams} from './DeletePaymentCardParams'; @@ -237,6 +238,8 @@ export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyReq export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams'; export type {default as UnholdMoneyRequestOnSearchParams} from './UnholdMoneyRequestOnSearchParams'; export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams'; +export type {default as ConnectPolicyToNetSuiteParams} from './ConnectPolicyToNetSuiteParams'; +export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams'; export type {default as OpenPolicyExpensifyCardsPageParams} from './OpenPolicyExpensifyCardsPageParams'; export type {default as RequestExpensifyCardLimitIncreaseParams} from './RequestExpensifyCardLimitIncreaseParams'; export type {default as UpdateNetSuiteGenericTypeParams} from './UpdateNetSuiteGenericTypeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 5aa4046bb25a..e2ea98bb3901 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -234,6 +234,7 @@ const WRITE_COMMANDS = { UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', REQUEST_REFUND: 'User_RefundPurchase', UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary', + CREATE_WORKSPACE_REPORT_FIELD: 'CreatePolicyReportField', UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION: 'UpdateNetSuiteSyncTaxConfiguration', UPDATE_NETSUITE_EXPORTER: 'UpdateNetSuiteExporter', UPDATE_NETSUITE_EXPORT_DATE: 'UpdateNetSuiteExportDate', @@ -258,6 +259,8 @@ const WRITE_COMMANDS = { UPDATE_NETSUITE_CUSTOM_FORM_ID_OPTIONS_ENABLED: 'UpdateNetSuiteCustomFormIDOptionsEnabled', REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE: 'RequestExpensifyCardLimitIncrease', CONNECT_POLICY_TO_SAGE_INTACCT: 'ConnectPolicyToSageIntacct', + CONNECT_POLICY_TO_NETSUITE: 'ConnectPolicyToNetSuite', + CLEAR_OUTSTANDING_BALANCE: 'ClearOutstandingBalance', } as const; type WriteCommand = ValueOf; @@ -462,6 +465,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams; + [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams; [WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams; @@ -498,6 +502,11 @@ type WriteCommandParameters = { // Netsuite parameters [WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY]: Parameters.UpdateNetSuiteSubsidiaryParams; + [WRITE_COMMANDS.CONNECT_POLICY_TO_NETSUITE]: Parameters.ConnectPolicyToNetSuiteParams; + + // Workspace report field parameters + [WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD]: Parameters.CreateWorkspaceReportFieldParams; + [WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION]: Parameters.UpdateNetSuiteGenericTypeParams<'enabled', boolean>; [WRITE_COMMANDS.UPDATE_NETSUITE_EXPORTER]: Parameters.UpdateNetSuiteGenericTypeParams<'email', string>; [WRITE_COMMANDS.UPDATE_NETSUITE_EXPORT_DATE]: Parameters.UpdateNetSuiteGenericTypeParams<'value', ValueOf>; @@ -527,6 +536,7 @@ const READ_COMMANDS = { CONNECT_POLICY_TO_XERO: 'ConnectPolicyToXero', SYNC_POLICY_TO_QUICKBOOKS_ONLINE: 'SyncPolicyToQuickbooksOnline', SYNC_POLICY_TO_XERO: 'SyncPolicyToXero', + SYNC_POLICY_TO_NETSUITE: 'SyncPolicyToNetSuite', OPEN_REIMBURSEMENT_ACCOUNT_PAGE: 'OpenReimbursementAccountPage', OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', @@ -576,6 +586,7 @@ type ReadCommandParameters = { [READ_COMMANDS.CONNECT_POLICY_TO_XERO]: Parameters.ConnectPolicyToAccountingIntegrationParams; [READ_COMMANDS.SYNC_POLICY_TO_QUICKBOOKS_ONLINE]: Parameters.SyncPolicyToQuickbooksOnlineParams; [READ_COMMANDS.SYNC_POLICY_TO_XERO]: Parameters.SyncPolicyToXeroParams; + [READ_COMMANDS.SYNC_POLICY_TO_NETSUITE]: Parameters.SyncPolicyToNetSuiteParams; [READ_COMMANDS.OPEN_REIMBURSEMENT_ACCOUNT_PAGE]: Parameters.OpenReimbursementAccountPageParams; [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: Parameters.OpenWorkspaceViewParams; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: null; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index f538e5e719e2..8a8888902e92 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -806,6 +806,19 @@ function doesDateBelongToAPastYear(date: string): boolean { return transactionYear !== new Date().getFullYear(); } +/** + * Returns a boolean value indicating whether the card has expired. + * @param expiryMonth month when card expires (starts from 1 so can be any number between 1 and 12) + * @param expiryYear year when card expires + */ + +function isCardExpired(expiryMonth: number, expiryYear: number): boolean { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + return expiryYear < currentYear || (expiryYear === currentYear && expiryMonth < currentMonth); +} + const DateUtils = { isDate, formatToDayOfWeek, @@ -850,6 +863,7 @@ const DateUtils = { getFormattedReservationRangeDate, getFormattedTransportDate, doesDateBelongToAPastYear, + isCardExpired, }; export default DateUtils; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts new file mode 100644 index 000000000000..ac2adf010eca --- /dev/null +++ b/src/libs/ExportOnyxState/common.ts @@ -0,0 +1,34 @@ +import {Str} from 'expensify-common'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const maskFragileData = (data: Record, parentKey?: string): Record => { + const maskedData: Record = {}; + + if (!data) { + return maskedData; + } + + Object.keys(data).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(data, key)) { + return; + } + + const value = data[key]; + + if (typeof value === 'string' && (Str.isValidEmail(value) || key === 'authToken' || key === 'encryptedAuthToken')) { + maskedData[key] = '***'; + } else if (parentKey && parentKey.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) && (key === 'text' || key === 'html')) { + maskedData[key] = '***'; + } else if (typeof value === 'object') { + maskedData[key] = maskFragileData(value as Record, key.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) ? key : parentKey); + } else { + maskedData[key] = value; + } + }); + + return maskedData; +}; + +export default { + maskFragileData, +}; diff --git a/src/libs/ExportOnyxState/index.native.ts b/src/libs/ExportOnyxState/index.native.ts new file mode 100644 index 000000000000..778b7f9f9cb2 --- /dev/null +++ b/src/libs/ExportOnyxState/index.native.ts @@ -0,0 +1,42 @@ +import RNFS from 'react-native-fs'; +import {open} from 'react-native-quick-sqlite'; +import Share from 'react-native-share'; +import CONST from '@src/CONST'; +import common from './common'; + +const readFromOnyxDatabase = () => + new Promise((resolve) => { + const db = open({name: CONST.DEFAULT_DB_NAME}); + const query = `SELECT * FROM ${CONST.DEFAULT_TABLE_NAME}`; + + db.executeAsync(query, []).then(({rows}) => { + // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-unsafe-member-access + const result = rows?._array.map((row) => ({[row?.record_key]: JSON.parse(row?.valueJSON as string)})); + + resolve(result); + }); + }); + +const shareAsFile = (value: string) => { + try { + // Define new filename and path for the app info file + const infoFileName = CONST.DEFAULT_ONYX_DUMP_FILE_NAME; + const infoFilePath = `${RNFS.DocumentDirectoryPath}/${infoFileName}`; + const actualInfoFile = `file://${infoFilePath}`; + + RNFS.writeFile(infoFilePath, value, 'utf8').then(() => { + Share.open({ + url: actualInfoFile, + failOnCancel: false, + }); + }); + } catch (error) { + console.error('Error renaming and sharing file:', error); + } +}; + +export default { + maskFragileData: common.maskFragileData, + readFromOnyxDatabase, + shareAsFile, +}; diff --git a/src/libs/ExportOnyxState/index.ts b/src/libs/ExportOnyxState/index.ts new file mode 100644 index 000000000000..148548ce5d1c --- /dev/null +++ b/src/libs/ExportOnyxState/index.ts @@ -0,0 +1,50 @@ +import CONST from '@src/CONST'; +import common from './common'; + +const readFromOnyxDatabase = () => + new Promise>((resolve) => { + let db: IDBDatabase; + const openRequest = indexedDB.open(CONST.DEFAULT_DB_NAME, 1); + openRequest.onsuccess = () => { + db = openRequest.result; + const transaction = db.transaction(CONST.DEFAULT_TABLE_NAME); + const objectStore = transaction.objectStore(CONST.DEFAULT_TABLE_NAME); + const cursor = objectStore.openCursor(); + + const queryResult: Record = {}; + + cursor.onerror = () => { + console.error('Error reading cursor'); + }; + + cursor.onsuccess = (event) => { + const {result} = event.target as IDBRequest; + if (result) { + queryResult[result.primaryKey as string] = result.value; + result.continue(); + } else { + // no results mean the cursor has reached the end of the data + resolve(queryResult); + } + }; + }; + }); + +const shareAsFile = (value: string) => { + const element = document.createElement('a'); + element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(value)}`); + element.setAttribute('download', CONST.DEFAULT_ONYX_DUMP_FILE_NAME); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +}; + +export default { + maskFragileData: common.maskFragileData, + readFromOnyxDatabase, + shareAsFile, +}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 3c1561b4adc4..ab91bccc8194 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -320,6 +320,8 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: () => require('../../../../pages/workspace/accounting/netsuite/NetSuiteSubsidiarySelector').default, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: () => + require('../../../../pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteTokenInputPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: () => require('../../../../pages/workspace/accounting/netsuite/import/NetSuiteImportPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT]: () => require('../../../../pages/workspace/accounting/netsuite/export/NetSuiteExportConfigurationPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PREFERRED_EXPORTER_SELECT]: () => @@ -362,6 +364,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default, [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: () => require('../../../../pages/settings/Subscription/PaymentCard').default, [SCREENS.SETTINGS.ADD_PAYMENT_CARD_CHANGE_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/WorkspaceCreateReportFieldPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldListValuesPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldAddListValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ValueSettingsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldEditValuePage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx index 16e8404f5fe9..748d92b49a1c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx @@ -19,7 +19,6 @@ type Screens = Partial React.Co const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../pages/workspace/WorkspaceProfilePage').default, [SCREENS.WORKSPACE.CARD]: () => require('../../../../pages/workspace/card/WorkspaceCardPage').default, - [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default, [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default, [SCREENS.WORKSPACE.BILLS]: () => require('../../../../pages/workspace/bills/WorkspaceBillsPage').default, @@ -32,6 +31,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.TAGS]: () => require('../../../../pages/workspace/tags/WorkspaceTagsPage').default, [SCREENS.WORKSPACE.TAXES]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesPage').default, [SCREENS.WORKSPACE.REPORT_FIELDS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldsPage').default, + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, } satisfies Screens; diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index ca1ce9e3fdf5..a2dba2b38938 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -56,6 +56,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR, SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_BANK_ACCOUNT_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR, + SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PREFERRED_EXPORTER_SELECT, @@ -103,7 +104,13 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT, SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS, ], - [SCREENS.WORKSPACE.REPORT_FIELDS]: [], + [SCREENS.WORKSPACE.REPORT_FIELDS]: [ + SCREENS.WORKSPACE.REPORT_FIELDS_CREATE, + SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES, + SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE, + SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS, + SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE, + ], [SCREENS.WORKSPACE.EXPENSIFY_CARD]: [], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index a55d1b16701d..30918d81d22b 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -354,6 +354,7 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_PREFERRED_EXPORTER_SELECT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT.route}, [SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_XERO_BILL_PAYMENT_ACCOUNT_SELECTOR.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.route}, + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT]: { path: ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.route, @@ -528,6 +529,21 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: { path: ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.route, }, + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: { + path: ROUTES.WORKSPACE_CREATE_REPORT_FIELD.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_LIST_VALUES.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_ADD_VALUE.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_VALUE_SETTINGS.route, + }, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + path: ROUTES.WORKSPACE_REPORT_FIELD_EDIT_VALUE.route, + }, [SCREENS.REIMBURSEMENT_ACCOUNT]: { path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 43c83c6f8d42..f4521afb8766 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -268,6 +268,23 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: { policyID: string; }; + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + policyID: string; + valueIndex: number; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + policyID: string; + valueIndex: number; + }; [SCREENS.WORKSPACE.MEMBER_DETAILS]: { policyID: string; accountID: string; @@ -399,6 +416,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: { policyID: string; }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 4ad79c30e86d..b952fbe9af4e 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -175,6 +175,7 @@ type GetOptionsConfig = { recentlyUsedPolicyReportFieldOptions?: string[]; transactionViolations?: OnyxCollection; includeInvoiceRooms?: boolean; + includeDomainEmail?: boolean; }; type GetUserToInviteConfig = { @@ -1802,6 +1803,7 @@ function getOptions( policyReportFieldOptions = [], recentlyUsedPolicyReportFieldOptions = [], includeInvoiceRooms = false, + includeDomainEmail = false, }: GetOptionsConfig, ): Options { if (includeCategories) { @@ -1878,6 +1880,8 @@ function getOptions( isInFocusMode: false, excludeEmptyChats: false, includeSelfDM, + login: option.login, + includeDomainEmail, }); }); @@ -1951,7 +1955,9 @@ function getOptions( return option; }); - const havingLoginPersonalDetails = includeP2P ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail) : []; + const havingLoginPersonalDetails = includeP2P + ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail && (includeDomainEmail || !Str.isDomainEmail(detail.login))) + : []; let allPersonalDetailsOptions = havingLoginPersonalDetails; if (sortPersonalDetailsByAlphaAsc) { diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 8ba468e87ed0..8bdf0cb1d5fe 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -157,6 +157,7 @@ function getPersonalDetailsOnyxDataForOptimisticUsers(newLogins: string[], newAc login, accountID, displayName: LocalePhoneNumber.formatPhoneNumber(login), + isOptimisticPersonalDetail: true, }; /** diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2830096e0e8f..4a9757858c13 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -902,11 +902,20 @@ function isTripRoom(report: OnyxEntry): boolean { return isChatReport(report) && getChatType(report) === CONST.REPORT.CHAT_TYPE.TRIP_ROOM; } +function isIndividualInvoiceRoom(report: OnyxEntry): boolean { + return isInvoiceRoom(report) && report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; +} + function isCurrentUserInvoiceReceiver(report: OnyxEntry): boolean { if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { return currentUserAccountID === report.invoiceReceiver.accountID; } + if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS) { + const policy = PolicyUtils.getPolicy(report.invoiceReceiver.policyID); + return PolicyUtils.isPolicyAdmin(policy); + } + return false; } @@ -1111,12 +1120,13 @@ function isProcessingReport(report: OnyxEntry): boolean { * and personal detail of participant is optimistic data */ function shouldDisableDetailPage(report: OnyxEntry): boolean { - const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); - if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report) || isTaskReport(report)) { return false; } - if (participantAccountIDs.length === 1) { + if (isOneOnOneChat(report)) { + const participantAccountIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); return isOptimisticPersonalDetail(participantAccountIDs[0]); } return false; @@ -1444,6 +1454,22 @@ function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | string): bo return isIOUReport(report) || isExpenseReport(report); } +/** + * Checks if a report contains only Non-Reimbursable transactions + */ +function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): boolean { + if (!iouReportID) { + return false; + } + + const transactions = TransactionUtils.getAllReportTransactions(iouReportID); + if (!transactions || transactions.length === 0) { + return false; + } + + return transactions.every((transaction) => !TransactionUtils.getReimbursable(transaction)); +} + /** * Checks if a report has only one transaction associated with it */ @@ -1543,6 +1569,11 @@ function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry): bool return false; } + const policy = getPolicy(moneyRequestReport?.policyID); + if (PolicyUtils.isInstantSubmitEnabled(policy) && PolicyUtils.isSubmitAndClose(policy) && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID)) { + return false; + } + if (isReportApproved(moneyRequestReport) || isSettled(moneyRequestReport?.reportID)) { return false; } @@ -1887,7 +1918,6 @@ function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldEx if (shouldExcludeDeleted && report?.pendingChatMembers?.findLast((member) => member.accountID === accountID)?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return false; } - return true; }); } @@ -2028,9 +2058,15 @@ function getIcons( if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { icons.push(...getIconsForParticipants([report?.invoiceReceiver.accountID], personalDetails)); } else { - const receiverPolicy = getPolicy(report?.invoiceReceiver?.policyID); + const receiverPolicyID = report?.invoiceReceiver?.policyID; + const receiverPolicy = getPolicy(receiverPolicyID); if (!isEmptyObject(receiverPolicy)) { - icons.push(getWorkspaceIcon(report, receiverPolicy)); + icons.push({ + source: receiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(receiverPolicy.name), + type: CONST.ICON_TYPE_WORKSPACE, + name: receiverPolicy.name, + id: receiverPolicyID, + }); } } } @@ -2102,10 +2138,16 @@ function getIcons( return icons; } - const receiverPolicy = getPolicy(invoiceRoomReport?.invoiceReceiver?.policyID); + const receiverPolicyID = invoiceRoomReport?.invoiceReceiver?.policyID; + const receiverPolicy = getPolicy(receiverPolicyID); if (!isEmptyObject(receiverPolicy)) { - icons.push(getWorkspaceIcon(invoiceRoomReport, receiverPolicy)); + icons.push({ + source: receiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(receiverPolicy.name), + type: CONST.ICON_TYPE_WORKSPACE, + name: receiverPolicy.name, + id: receiverPolicyID, + }); } return icons; @@ -2570,7 +2612,16 @@ function getMoneyRequestReportName(report: OnyxEntry, policy?: OnyxEntry const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency); - let payerOrApproverName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report?.managerID) ?? ''; + let payerOrApproverName; + if (isExpenseReport(report)) { + payerOrApproverName = getPolicyName(report, false, policy); + } else if (isInvoiceReport(report)) { + const chatReport = getReportOrDraftReport(report?.chatReportID); + payerOrApproverName = getInvoicePayerName(chatReport); + } else { + payerOrApproverName = getDisplayNameForParticipant(report?.managerID) ?? ''; + } + const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', { payer: payerOrApproverName, amount: formattedAmount, @@ -5409,6 +5460,8 @@ function shouldReportBeInOptionList({ excludeEmptyChats, doesReportHaveViolations, includeSelfDM = false, + login, + includeDomainEmail = false, }: { report: OnyxEntry; currentReportId: string; @@ -5418,6 +5471,8 @@ function shouldReportBeInOptionList({ excludeEmptyChats: boolean; doesReportHaveViolations: boolean; includeSelfDM?: boolean; + login?: string; + includeDomainEmail?: boolean; }) { const isInDefaultMode = !isInFocusMode; // Exclude reports that have no data because there wouldn't be anything to show in the option item. @@ -5521,6 +5576,11 @@ function shouldReportBeInOptionList({ if (isSelfDM(report)) { return includeSelfDM; } + + if (Str.isDomainEmail(login ?? '') && !includeDomainEmail) { + return false; + } + const parentReportAction = ReportActionsUtils.getParentReportAction(report); // Hide chat threads where the parent message is pending removal @@ -5560,6 +5620,7 @@ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollec isChatThread(report) || isTaskReport(report) || isMoneyRequestReport(report) || + isInvoiceReport(report) || isChatRoom(report) || isPolicyExpenseChat(report) || (isGroupChat(report) && !shouldIncludeGroupChats) @@ -7342,6 +7403,8 @@ export { isChatUsedForOnboarding, getChatUsedForOnboarding, findPolicyExpenseChatByPolicyID, + isIndividualInvoiceRoom, + hasOnlyNonReimbursableTransactions, }; export type { diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 5a7f514a7196..cd641cdc8c90 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -166,6 +166,8 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...value, + from: data.personalDetailsList?.[value.accountID], + to: data.personalDetailsList?.[value.managerID], transactions, }; } else if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { @@ -199,7 +201,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx if (reportIDToTransactions[reportKey]?.transactions) { reportIDToTransactions[reportKey].transactions.push(transaction); } else { - reportIDToTransactions[reportKey] = {transactions: [transaction]}; + reportIDToTransactions[reportKey].transactions = [transaction]; } } } @@ -217,7 +219,7 @@ const searchTypeToItemMap: SearchTypeToItemMap = { listItem: ReportListItem, getSections: getReportSections, // sorting for ReportItems not yet implemented - getSortedSections: (data) => data, + getSortedSections: getSortedReportData, }, }; @@ -278,6 +280,19 @@ function getSortedTransactionData(data: TransactionListItemType[], sortBy?: Sear }); } +function getSortedReportData(data: ReportListItemType[]) { + return data.sort((a, b) => { + const aValue = a?.created; + const bValue = b?.created; + + if (aValue === undefined || bValue === undefined) { + return 0; + } + + return bValue.toLowerCase().localeCompare(aValue); + }); +} + function getSearchParams() { const topmostCentralPaneRoute = getTopmostCentralPaneRoute(navigationRef.getRootState() as State); return topmostCentralPaneRoute?.params as AuthScreensParamList['Search_Central_Pane']; diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 8569a3f03128..c807d0ca4a7e 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -81,6 +81,7 @@ Onyx.connect({ let retryBillingSuccessful: OnyxEntry; Onyx.connect({ key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + initWithStoredValues: false, callback: (value) => { if (value === undefined) { return; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index b28e5b782965..c8bfa316ca15 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -387,6 +387,13 @@ function getDistance(transaction: OnyxInputOrEntry): number { return transaction?.comment?.customUnit?.quantity ?? 0; } +/** + * Return the reimbursable value. Defaults to true to match BE logic. + */ +function getReimbursable(transaction: Transaction): boolean { + return transaction?.reimbursable ?? true; +} + /** * Return the mccGroup field from the transaction, return the modifiedMCCGroup if present. */ @@ -878,6 +885,7 @@ export { isCustomUnitRateIDForP2P, getRateID, getTransaction, + getReimbursable, }; export type {TransactionChanges}; diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index ead786b8eafd..e937979ae7b9 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,3 +1,4 @@ +import type {EReceiptColorName} from '@styles/utils/types'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import type {Reservation, ReservationType} from '@src/types/onyx/Transaction'; @@ -24,4 +25,26 @@ function getReservationsFromTripTransactions(transactions: Transaction[]): Reser .flat(); } -export {getTripReservationIcon, getReservationsFromTripTransactions}; +type TripEReceiptData = { + /** Icon asset associated with the type of trip reservation */ + tripIcon?: IconAsset; + + /** EReceipt background color associated with the type of trip reservation */ + tripBGColor?: EReceiptColorName; +}; + +function getTripEReceiptData(transaction?: Transaction): TripEReceiptData { + const reservationType = transaction ? transaction.receipt?.reservationList?.[0]?.type : ''; + + switch (reservationType) { + case CONST.RESERVATION_TYPE.FLIGHT: + case CONST.RESERVATION_TYPE.CAR: + return {tripIcon: Expensicons.Plane, tripBGColor: CONST.ERECEIPT_COLORS.PINK}; + case CONST.RESERVATION_TYPE.HOTEL: + return {tripIcon: Expensicons.Bed, tripBGColor: CONST.ERECEIPT_COLORS.YELLOW}; + default: + return {}; + } +} + +export {getTripReservationIcon, getReservationsFromTripTransactions, getTripEReceiptData}; diff --git a/src/libs/WorkspaceReportFieldsUtils.ts b/src/libs/WorkspaceReportFieldsUtils.ts new file mode 100644 index 000000000000..0cc3cae24a23 --- /dev/null +++ b/src/libs/WorkspaceReportFieldsUtils.ts @@ -0,0 +1,70 @@ +import type {FormInputErrors} from '@components/Form/types'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type ONYXKEYS from '@src/ONYXKEYS'; +import type {InputID} from '@src/types/form/WorkspaceReportFieldsForm'; +import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; +import * as ErrorUtils from './ErrorUtils'; +import * as Localize from './Localize'; +import * as ValidationUtils from './ValidationUtils'; + +/** + * Gets the translation key for the report field type. + */ +function getReportFieldTypeTranslationKey(reportFieldType: PolicyReportFieldType): TranslationPaths { + const typeTranslationKeysStrategy: Record = { + [CONST.REPORT_FIELD_TYPES.TEXT]: 'workspace.reportFields.textType', + [CONST.REPORT_FIELD_TYPES.DATE]: 'workspace.reportFields.dateType', + [CONST.REPORT_FIELD_TYPES.LIST]: 'workspace.reportFields.dropdownType', + }; + + return typeTranslationKeysStrategy[reportFieldType]; +} + +/** + * Gets the translation key for the alternative text for the report field. + */ +function getReportFieldAlternativeTextTranslationKey(reportFieldType: PolicyReportFieldType): TranslationPaths { + const typeTranslationKeysStrategy: Record = { + [CONST.REPORT_FIELD_TYPES.TEXT]: 'workspace.reportFields.textAlternateText', + [CONST.REPORT_FIELD_TYPES.DATE]: 'workspace.reportFields.dateAlternateText', + [CONST.REPORT_FIELD_TYPES.LIST]: 'workspace.reportFields.dropdownAlternateText', + }; + + return typeTranslationKeysStrategy[reportFieldType]; +} + +/** + * Validates the list value name. + */ +function validateReportFieldListValueName( + valueName: string, + priorValueName: string, + listValues: string[], + inputID: InputID, +): FormInputErrors { + const errors: FormInputErrors = {}; + + if (!ValidationUtils.isRequiredFulfilled(valueName)) { + errors[inputID] = Localize.translateLocal('workspace.reportFields.listValueRequiredError'); + } else if (priorValueName !== valueName && listValues.some((currentValueName) => currentValueName === valueName)) { + errors[inputID] = Localize.translateLocal('workspace.reportFields.existingListValueError'); + } else if ([...valueName].length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 code units. + ErrorUtils.addErrorMessage( + errors, + inputID, + Localize.translateLocal('common.error.characterLimitExceedCounter', {length: [...valueName].length, limit: CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH}), + ); + } + + return errors; +} +/** + * Generates a field ID based on the field name. + */ +function generateFieldID(name: string) { + return `field_id_${name.replace(CONST.REGEX.ANY_SPACE, '_').toUpperCase()}`; +} + +export {getReportFieldTypeTranslationKey, getReportFieldAlternativeTextTranslationKey, validateReportFieldListValueName, generateFieldID}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 48c70021cacc..42381d9008a7 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -291,6 +291,12 @@ Onyx.connect({ }, }); +let primaryPolicyID: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, + callback: (value) => (primaryPolicyID = value), +}); + /** * Get the report or draft report given a reportID */ @@ -5938,13 +5944,22 @@ function getSendMoneyParams( } function getPayMoneyRequestParams( - chatReport: OnyxTypes.Report, + initialChatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, recipient: Participant, paymentMethodType: PaymentMethodType, full: boolean, + payAsBusiness?: boolean, ): PayMoneyRequestData { const isInvoiceReport = ReportUtils.isInvoiceReport(iouReport); + let chatReport = initialChatReport; + + if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && primaryPolicyID) { + const existingB2BInvoiceRoom = ReportUtils.getInvoiceChatByParticipants(chatReport.policyID ?? '', primaryPolicyID); + if (existingB2BInvoiceRoom) { + chatReport = existingB2BInvoiceRoom; + } + } let total = (iouReport.total ?? 0) - (iouReport.nonReimbursableTotal ?? 0); if (ReportUtils.hasHeldExpenses(iouReport.reportID) && !full && !!iouReport.unheldTotal) { @@ -5977,19 +5992,27 @@ function getPayMoneyRequestParams( optimisticNextStep = NextStepUtils.buildNextStep(iouReport, CONST.REPORT.STATUS_NUM.REIMBURSED, {isPaidWithExpensify: paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA}); } + const optimisticChatReport = { + ...chatReport, + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: optimisticIOUReportAction.created, + hasOutstandingChildRequest: false, + iouReportID: null, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), + }; + if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && primaryPolicyID) { + optimisticChatReport.invoiceReceiver = { + type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, + policyID: primaryPolicyID, + }; + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - value: { - ...chatReport, - lastReadTime: DateUtils.getDBTime(), - lastVisibleActionCreated: optimisticIOUReportAction.created, - hasOutstandingChildRequest: false, - iouReportID: null, - lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), - lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), - }, + value: optimisticChatReport, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -6611,19 +6634,20 @@ function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.R Navigation.dismissModalWithReport(chatReport); } -function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxTypes.Report) { +function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxTypes.Report, payAsBusiness = false) { const recipient = {accountID: invoiceReport.ownerAccountID}; const { optimisticData, successData, failureData, params: {reportActionID}, - } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true); + } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true, payAsBusiness); const params: PayInvoiceParams = { reportID: invoiceReport.reportID, reportActionID, paymentMethodType, + payAsBusiness, }; API.write(WRITE_COMMANDS.PAY_INVOICE, params, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 79a1c8a06cd9..ca61fceaaa78 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -187,7 +187,7 @@ function getPolicy(policyID: string | undefined): OnyxEntry { */ function getPrimaryPolicy(activePolicyID?: OnyxEntry): Policy | undefined { const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies); - const primaryPolicy: Policy | null | undefined = allPolicies?.[activePolicyID ?? '-1']; + const primaryPolicy: Policy | null | undefined = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]; return primaryPolicy ?? activeAdminWorkspaces[0]; } diff --git a/src/libs/actions/Policy/ReportFields.ts b/src/libs/actions/Policy/ReportFields.ts new file mode 100644 index 000000000000..220432cbc3c6 --- /dev/null +++ b/src/libs/actions/Policy/ReportFields.ts @@ -0,0 +1,209 @@ +import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {CreateWorkspaceReportFieldParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import {generateFieldID} from '@libs/WorkspaceReportFieldsUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {WorkspaceReportFieldsForm} from '@src/types/form/WorkspaceReportFieldsForm'; +import INPUT_IDS from '@src/types/form/WorkspaceReportFieldsForm'; +import type {Policy, PolicyReportField} from '@src/types/onyx'; +import type {OnyxData} from '@src/types/onyx/Request'; + +let listValues: string[]; +let disabledListValues: boolean[]; +Onyx.connect({ + key: ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, + callback: (value) => { + if (!value) { + return; + } + + listValues = value[INPUT_IDS.LIST_VALUES] ?? []; + disabledListValues = value[INPUT_IDS.DISABLED_LIST_VALUES] ?? []; + }, +}); + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (value, key) => { + if (!key) { + return; + } + + if (value === null || value === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = value; + }, +}); + +/** + * Sets the initial form values for the workspace report fields form. + */ +function setInitialCreateReportFieldsForm() { + Onyx.set(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.INITIAL_VALUE]: '', + }); +} + +/** + * Creates a new list value in the workspace report fields form. + */ +function createReportFieldsListValue(valueName: string) { + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: [...listValues, valueName], + [INPUT_IDS.DISABLED_LIST_VALUES]: [...disabledListValues, false], + }); +} + +/** + * Renames a list value in the workspace report fields form. + */ +function renameReportFieldsListValue(valueIndex: number, newValueName: string) { + const listValuesCopy = [...listValues]; + listValuesCopy[valueIndex] = newValueName; + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: listValuesCopy, + }); +} + +/** + * Sets the enabled state of a list value in the workspace report fields form. + */ +function setReportFieldsListValueEnabled(valueIndexes: number[], enabled: boolean) { + const disabledListValuesCopy = [...disabledListValues]; + + valueIndexes.forEach((valueIndex) => { + disabledListValuesCopy[valueIndex] = !enabled; + }); + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.DISABLED_LIST_VALUES]: disabledListValuesCopy, + }); +} + +/** + * Deletes a list value from the workspace report fields form. + */ +function deleteReportFieldsListValue(valueIndexes: number[]) { + const listValuesCopy = [...listValues]; + const disabledListValuesCopy = [...disabledListValues]; + + valueIndexes + .sort((a, b) => b - a) + .forEach((valueIndex) => { + listValuesCopy.splice(valueIndex, 1); + disabledListValuesCopy.splice(valueIndex, 1); + }); + + Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, { + [INPUT_IDS.LIST_VALUES]: listValuesCopy, + [INPUT_IDS.DISABLED_LIST_VALUES]: disabledListValuesCopy, + }); +} + +type CreateReportFieldArguments = Pick; + +/** + * Creates a new report field. + */ +function createReportField(policyID: string, {name, type, initialValue}: CreateReportFieldArguments) { + const previousFieldList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.fieldList ?? {}; + const fieldID = generateFieldID(name); + const fieldKey = ReportUtils.getReportFieldKey(fieldID); + const newReportField: PolicyReportField = { + name, + type, + defaultValue: initialValue, + values: listValues, + disabledOptions: disabledListValues, + fieldID, + orderWeight: Object.keys(previousFieldList).length + 1, + deletable: false, + value: type === CONST.REPORT_FIELD_TYPES.LIST ? CONST.REPORT_FIELD_TYPES.LIST : null, + keys: [], + externalIDs: [], + isTax: false, + }; + const onyxData: OnyxData = { + optimisticData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: newReportField, + }, + pendingFields: { + [fieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + errorFields: null, + }, + }, + ], + successData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + pendingFields: { + [fieldKey]: null, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + fieldList: { + [fieldKey]: null, + }, + pendingFields: { + [fieldKey]: null, + }, + errorFields: { + [fieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'), + }, + }, + }, + ], + }; + const parameters: CreateWorkspaceReportFieldParams = { + policyID, + reportFields: JSON.stringify([newReportField]), + }; + + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_REPORT_FIELD, parameters, onyxData); +} + +export type {CreateReportFieldArguments}; + +export {setInitialCreateReportFieldsForm, createReportFieldsListValue, renameReportFieldsListValue, setReportFieldsListValueEnabled, deleteReportFieldsListValue, createReportField}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 31e801deeea4..2aae7112ff99 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -3118,6 +3118,7 @@ function completeOnboarding( lastName: string; }, adminsChatReportID?: string, + onboardingPolicyID?: string, ) { const isAccountIDOdd = AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? 0); const targetEmail = isAccountIDOdd ? CONST.EMAIL.NOTIFICATIONS : CONST.EMAIL.CONCIERGE; @@ -3161,6 +3162,7 @@ function completeOnboarding( typeof task.description === 'function' ? task.description({ adminsRoomLink: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.REPORT_WITH_ID.getRoute(adminsChatReportID ?? '-1')}`, + workspaceLink: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.WORKSPACE_INITIAL.getRoute(onboardingPolicyID ?? '-1')}`, }) : task.description; const currentTask = ReportUtils.buildOptimisticTaskReport( diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts index 19a3bf0c547e..beed2b1b2962 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -231,4 +231,60 @@ function clearUpdateSubscriptionSizeError() { }); } -export {openSubscriptionPage, updateSubscriptionAutoRenew, updateSubscriptionAddNewUsersAutomatically, updateSubscriptionSize, clearUpdateSubscriptionSizeError, updateSubscriptionType}; +function clearOutstandingBalance() { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: true, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + value: true, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + value: false, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + value: true, + }, + ], + }; + + API.write(WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE, null, onyxData); +} + +export { + openSubscriptionPage, + updateSubscriptionAutoRenew, + updateSubscriptionAddNewUsersAutomatically, + updateSubscriptionSize, + clearUpdateSubscriptionSizeError, + updateSubscriptionType, + clearOutstandingBalance, +}; diff --git a/src/libs/actions/connections/NetSuiteCommands.ts b/src/libs/actions/connections/NetSuiteCommands.ts index 7c0d4f2f418a..5d04613561ad 100644 --- a/src/libs/actions/connections/NetSuiteCommands.ts +++ b/src/libs/actions/connections/NetSuiteCommands.ts @@ -2,6 +2,7 @@ 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 {ConnectPolicyToNetSuiteParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import CONST from '@src/CONST'; @@ -14,6 +15,25 @@ type SubsidiaryParam = { subsidiary: string; }; +function connectPolicyToNetSuite(policyID: string, credentials: Omit) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`, + value: { + stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.NETSUITE_SYNC_CONNECTION, + connectionName: CONST.POLICY.CONNECTIONS.NAME.NETSUITE, + timestamp: new Date().toISOString(), + }, + }, + ]; + const parameters: ConnectPolicyToNetSuiteParams = { + policyID, + ...credentials, + }; + API.write(WRITE_COMMANDS.CONNECT_POLICY_TO_NETSUITE, parameters, {optimisticData}); +} + function updateNetSuiteOnyxData( policyID: string, settingName: TSettingName, @@ -599,4 +619,5 @@ export { updateNetSuiteAutoCreateEntities, updateNetSuiteEnableNewCategories, updateNetSuiteCustomFormIDOptionsEnabled, + connectPolicyToNetSuite, }; diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 872e82951834..fd6440c3a92c 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -132,6 +132,9 @@ function getSyncConnectionParameters(connectionName: PolicyConnectionName) { case CONST.POLICY.CONNECTIONS.NAME.XERO: { return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_XERO, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.STARTING_IMPORT_XERO}; } + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: { + return {readCommand: READ_COMMANDS.SYNC_POLICY_TO_NETSUITE, stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.NETSUITE_SYNC_CONNECTION}; + } default: return undefined; } diff --git a/src/pages/EnablePayments/EnablePayments.tsx b/src/pages/EnablePayments/EnablePayments.tsx index 8bbf4d83726b..b8501551204a 100644 --- a/src/pages/EnablePayments/EnablePayments.tsx +++ b/src/pages/EnablePayments/EnablePayments.tsx @@ -40,7 +40,7 @@ function EnablePaymentsPage() { return ( <> Navigation.goBack(ROUTES.SETTINGS_WALLET)} /> diff --git a/src/pages/EnablePayments/IdologyQuestions.tsx b/src/pages/EnablePayments/IdologyQuestions.tsx index 756965e961c8..b9b0ac4eca34 100644 --- a/src/pages/EnablePayments/IdologyQuestions.tsx +++ b/src/pages/EnablePayments/IdologyQuestions.tsx @@ -3,11 +3,14 @@ import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; import type {Choice} from '@components/RadioButtons'; import SingleChoiceQuestion from '@components/SingleChoiceQuestion'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; @@ -34,6 +37,7 @@ type Answer = { function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { const styles = useThemeStyles(); + const theme = useTheme(); const {translate} = useLocalize(); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); @@ -103,13 +107,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { return ( - {translate('additionalDetailsStep.helpTextIdologyQuestions')} - - {translate('additionalDetailsStep.helpLink')} - + {translate('additionalDetailsStep.helpTextIdologyQuestions')} - { - chooseAnswer(String(value)); - }} - onInputChange={() => {}} - /> + <> + { + chooseAnswer(String(value)); + }} + onInputChange={() => {}} + /> + + + + {translate('additionalDetailsStep.helpLink')} + + + ); diff --git a/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx b/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx index 0162fb311a19..55d369b4a2c5 100644 --- a/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx @@ -10,6 +10,7 @@ import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; import {parsePhoneNumber} from '@libs/PhoneNumber'; +import IdologyQuestions from '@pages/EnablePayments/IdologyQuestions'; import getInitialSubstepForPersonalInfo from '@pages/EnablePayments/utils/getInitialSubstepForPersonalInfo'; import getSubstepValues from '@pages/EnablePayments/utils/getSubstepValues'; import * as Wallet from '@userActions/Wallet'; @@ -41,6 +42,7 @@ const bodyContent: Array> = [FullName, DateOfB function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft}: PersonalInfoPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const showIdologyQuestions = walletAdditionalDetails?.questions && walletAdditionalDetails?.questions.length > 0; const values = useMemo(() => getSubstepValues(PERSONAL_INFO_STEP_KEYS, walletAdditionalDetailsDraft, walletAdditionalDetails), [walletAdditionalDetails, walletAdditionalDetailsDraft]); const submit = () => { @@ -84,6 +86,10 @@ function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft Wallet.updateCurrentStep(CONST.WALLET.STEP.ADD_BANK_ACCOUNT); return; } + if (showIdologyQuestions) { + Wallet.setAdditionalDetailsQuestions(null, ''); + return; + } prevScreen(); }; @@ -102,11 +108,18 @@ function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft stepNames={CONST.WALLET.STEP_NAMES} /> - + {showIdologyQuestions ? ( + + ) : ( + + )} ); } diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 78cb5dfcd991..91fdd903ec3a 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -197,7 +197,6 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen onSubmit={inviteUsers} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} enabledWhenOffline - disablePressOnEnter /> ), [selectedOptions.length, inviteUsers, translate, styles], diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index ba90def232d5..f5bd14ed7aa1 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -31,7 +31,13 @@ import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; import type {BaseOnboardingPersonalDetailsOnyxProps, BaseOnboardingPersonalDetailsProps} from './types'; -function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNativeStyles, onboardingPurposeSelected, onboardingAdminsChatReportID}: BaseOnboardingPersonalDetailsProps) { +function BaseOnboardingPersonalDetails({ + currentUserPersonalDetails, + shouldUseNativeStyles, + onboardingPurposeSelected, + onboardingAdminsChatReportID, + onboardingPolicyID, +}: BaseOnboardingPersonalDetailsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -61,6 +67,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat lastName, }, onboardingAdminsChatReportID ?? undefined, + onboardingPolicyID, ); Welcome.setOnboardingAdminsChatReportID(); @@ -84,7 +91,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat Navigation.navigate(ROUTES.WELCOME_VIDEO_ROOT); }, variables.welcomeVideoDelay); }, - [isSmallScreenWidth, onboardingPurposeSelected, onboardingAdminsChatReportID, accountID], + [onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingPolicyID, isSmallScreenWidth, accountID], ); const validate = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { @@ -194,5 +201,8 @@ export default withCurrentUserPersonalDetails( onboardingAdminsChatReportID: { key: ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID, }, + onboardingPolicyID: { + key: ONYXKEYS.ONBOARDING_POLICY_ID, + }, })(BaseOnboardingPersonalDetails), ); diff --git a/src/pages/OnboardingPersonalDetails/types.ts b/src/pages/OnboardingPersonalDetails/types.ts index a89fe5ff8df7..ccd4d3a52254 100644 --- a/src/pages/OnboardingPersonalDetails/types.ts +++ b/src/pages/OnboardingPersonalDetails/types.ts @@ -10,6 +10,9 @@ type BaseOnboardingPersonalDetailsOnyxProps = { /** Saved onboarding admin chat report ID */ onboardingAdminsChatReportID: OnyxEntry; + + /** Saved onboarding policy ID */ + onboardingPolicyID: OnyxEntry; }; type BaseOnboardingPersonalDetailsProps = WithCurrentUserPersonalDetailsProps & diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index afa57755ad70..8a807655ae57 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -266,7 +266,6 @@ function RoomInvitePage({ onSubmit={inviteUsers} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.mb5, styles.ph5]} enabledWhenOffline - disablePressOnEnter isAlertVisible={false} /> diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 3c2ae7bbc6e6..ceee1972256f 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -406,7 +406,13 @@ function ReportScreen({ return reportIDFromRoute !== '' && !!report.reportID && !isTransitioning; }, [report, reportIDFromRoute]); - const isLoading = isLoadingApp ?? (!reportIDFromRoute || (!isSidebarLoaded && !isReportOpenInRHP) || PersonalDetailsUtils.isPersonalDetailsEmpty()); + /** + * Using logical OR operator because with nullish coalescing operator, when `isLoadingApp` is false, the right hand side of the operator + * is not evaluated. This causes issues where we have `isLoading` set to false and later set to true and then set to false again. + * Ideally, `isLoading` should be set initially to true and then set to false. We can achieve this by using logical OR operator. + */ + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isLoading = isLoadingApp || !reportIDFromRoute || (!isSidebarLoaded && !isReportOpenInRHP) || PersonalDetailsUtils.isPersonalDetailsEmpty(); const shouldShowSkeleton = !isLinkedMessageAvailable && (isLinkingToMessage || diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 136d8e5b59eb..5ab38cbf2e7e 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -166,7 +166,6 @@ function BaseReportActionContextMenu({ disabledIndexes, maxIndex: filteredContextMenuActions.length - 1, isActive: shouldEnableArrowNavigation, - disableCyclicTraversal: true, }); /** diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index abf5d4dab8ee..2bfc46cfca89 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -68,7 +68,7 @@ function ReportActionItemCreated(props: ReportActionItemCreatedProps) { ReportUtils.navigateToDetailsPage(props.report)} - style={[styles.mh5, styles.mb3, styles.alignSelfStart]} + style={[styles.mh5, styles.mb3, styles.alignSelfStart, shouldDisableDetailPage && styles.cursorDefault]} accessibilityLabel={translate('common.details')} role={CONST.ROLE.BUTTON} disabled={shouldDisableDetailPage} diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 7b0db3e0d844..53527e85b215 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -19,6 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import {getReportActionMessage} from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -112,21 +113,30 @@ function ReportActionItemSingle({ let secondaryAvatar: Icon; const primaryDisplayName = displayName; if (displayAllActors) { - // The ownerAccountID and actorAccountID can be the same if a user submits an expense back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice - const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID; - const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; - const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId); + if (ReportUtils.isInvoiceRoom(report) && !ReportUtils.isIndividualInvoiceRoom(report)) { + const secondaryPolicyID = report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : '-1'; + const secondaryPolicy = PolicyUtils.getPolicy(secondaryPolicyID); + const secondaryPolicyAvatar = secondaryPolicy?.avatarURL ?? ReportUtils.getDefaultWorkspaceAvatar(secondaryPolicy?.name); - if (!isInvoiceReport) { - displayName = `${primaryDisplayName} & ${secondaryDisplayName}`; - } + secondaryAvatar = { + source: secondaryPolicyAvatar, + type: CONST.ICON_TYPE_WORKSPACE, + name: secondaryPolicy?.name, + id: secondaryPolicyID, + }; + } else { + // The ownerAccountID and actorAccountID can be the same if a user submits an expense back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice + const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID; + const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; + const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId); - secondaryAvatar = { - source: secondaryUserAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: secondaryDisplayName ?? '', - id: secondaryAccountId, - }; + secondaryAvatar = { + source: secondaryUserAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: secondaryDisplayName ?? '', + id: secondaryAccountId, + }; + } } else if (!isWorkspaceActor) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const avatarIconIndex = report.isOwnPolicyExpenseChat || ReportUtils.isPolicyExpenseChat(report) ? 0 : 1; diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx index 4587dfee2fe6..bbb06dac4549 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithoutFeedback} from '@components/Pressable'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,12 +36,50 @@ type BillingBannerProps = { /** An icon to be rendered instead of the RBR / GBR indicator. */ rightIcon?: IconAsset; + + /** Callback to be called when the right icon is pressed. */ + onRightIconPress?: () => void; + + /** Accessibility label for the right icon. */ + rightIconAccessibilityLabel?: string; }; -function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon}: BillingBannerProps) { +function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon, onRightIconPress, rightIconAccessibilityLabel}: BillingBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); + const rightIconComponent = useMemo(() => { + if (rightIcon) { + return onRightIconPress && rightIconAccessibilityLabel ? ( + + + + ) : ( + + ); + } + + return ( + !!brickRoadIndicator && ( + + ) + ); + }, [brickRoadIndicator, onRightIconPress, rightIcon, rightIconAccessibilityLabel, styles.touchableButtonImage, theme.danger, theme.icon, theme.success]); + return ( {title} : title} {typeof subtitle === 'string' ? {subtitle} : subtitle} - {rightIcon ? ( - - ) : ( - !!brickRoadIndicator && ( - - ) - )} + {rightIconComponent} ); } diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx index dce215e7dbbc..d949e2699e44 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx @@ -14,7 +14,7 @@ type SubscriptionBillingBannerProps = Omit ); } diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index e873569e4583..f3b78b3f2b95 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -1,6 +1,7 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -8,6 +9,7 @@ import MenuItem from '@components/MenuItem'; import Section from '@components/Section'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,6 +17,7 @@ import * as User from '@libs/actions/User'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import * as Subscription from '@userActions/Subscription'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -24,6 +27,7 @@ import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner import TrialStartedBillingBanner from './BillingBanner/TrialStartedBillingBanner'; import CardSectionActions from './CardSectionActions'; import CardSectionDataEmpty from './CardSectionDataEmpty'; +import type {BillingStatusResult} from './utils'; import CardSectionUtils from './utils'; function CardSection() { @@ -35,8 +39,10 @@ function CardSection() { const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const subscriptionPlan = useSubscriptionPlan(); - const [network] = useOnyx(ONYXKEYS.NETWORK); - + const [subscriptionRetryBillingStatusPending] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING); + const [subscriptionRetryBillingStatusSuccessful] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL); + const [subscriptionRetryBillingStatusFailed] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED); + const {isOffline} = useNetwork(); const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); const cardMonth = useMemo(() => DateUtils.getMonthNames(preferredLocale)[(defaultCard?.accountData?.cardMonth ?? 1) - 1], [defaultCard?.accountData?.cardMonth, preferredLocale]); @@ -47,12 +53,24 @@ function CardSection() { Navigation.resetToHome(); }, []); - const billingStatus = CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData?.cardNumber ?? ''); + const [billingStatus, setBillingStatus] = useState(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); const nextPaymentDate = !isEmptyObject(privateSubscription) ? CardSectionUtils.getNextBillingDate() : undefined; const sectionSubtitle = defaultCard && !!nextPaymentDate ? translate('subscription.cardSection.cardNextPayment', {nextPaymentDate}) : translate('subscription.cardSection.subtitle'); + useEffect(() => { + setBillingStatus(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); + }, [subscriptionRetryBillingStatusPending, subscriptionRetryBillingStatusSuccessful, subscriptionRetryBillingStatusFailed, translate, defaultCard?.accountData]); + + const handleRetryPayment = () => { + Subscription.clearOutstandingBalance(); + }; + + const handleBillingBannerClose = () => { + setBillingStatus(undefined); + }; + let BillingBanner: React.ReactNode | undefined; if (CardSectionUtils.shouldShowPreTrialBillingBanner()) { BillingBanner = ; @@ -66,6 +84,8 @@ function CardSection() { isError={billingStatus.isError} icon={billingStatus.icon} rightIcon={billingStatus.rightIcon} + onRightIconPress={handleBillingBannerClose} + rightIconAccessibilityLabel={translate('common.close')} /> ); } @@ -105,6 +125,18 @@ function CardSection() { {isEmptyObject(defaultCard?.accountData) && } + + {billingStatus?.isRetryAvailable !== undefined && ( +