diff --git a/android/app/build.gradle b/android/app/build.gradle index 0f69346a44bc..192537f08e3d 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 1009000305 - versionName "9.0.3-5" + versionCode 1009000306 + versionName "9.0.3-6" // 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/config/electronBuilder.config.js b/config/electronBuilder.config.js index 5a995fb5de91..ad3a23407b89 100644 --- a/config/electronBuilder.config.js +++ b/config/electronBuilder.config.js @@ -47,7 +47,7 @@ module.exports = { }, target: [ { - target: 'dmg', + target: 'default', arch: ['universal'], }, ], 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 ad8cc982e052..17eaae3cc3fc 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.3.5 + 9.0.3.6 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8fc553fe8c0c..618d394349ed 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.3.5 + 9.0.3.6 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 83e4d904584b..d5e50828e3c7 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.3 CFBundleVersion - 9.0.3.5 + 9.0.3.6 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index e9cc310d29e9..9f63be958d1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.3-5", + "version": "9.0.3-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.3-5", + "version": "9.0.3-6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e5e8f8fa8e60..c61316e22030 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.3-5", + "version": "9.0.3-6", "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 d74474978c2b..46782be36b62 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -365,6 +365,7 @@ const CONST = { REPORT_FIELDS_FEATURE: 'reportFieldsFeature', WORKSPACE_FEEDS: 'workspaceFeeds', NETSUITE_USA_TAX: 'netsuiteUsaTax', + INTACCT_ON_NEW_EXPENSIFY: 'intacctOnNewExpensify', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1288,6 +1289,7 @@ const CONST = { REPORT_FIELD: 'REPORT_FIELD', NOT_IMPORTED: 'NOT_IMPORTED', IMPORTED: 'IMPORTED', + NETSUITE_DEFAULT: 'NETSUITE_DEFAULT', }, QUICKBOOKS_ONLINE: 'quickbooksOnline', @@ -1367,6 +1369,11 @@ const CONST = { PROVINCIAL_TAX_POSTING_ACCOUNT: 'provincialTaxPostingAccount', ALLOW_FOREIGN_CURRENCY: 'allowForeignCurrency', EXPORT_TO_NEXT_OPEN_PERIOD: 'exportToNextOpenPeriod', + IMPORT_FIELDS: ['departments', 'classes', 'locations', 'customers', 'jobs'], + IMPORT_CUSTOM_FIELDS: ['customSegments', 'customLists'], + SYNC_OPTIONS: { + SYNC_TAX: 'syncTax', + }, }, NETSUITE_EXPORT_DATE: { @@ -5022,6 +5029,14 @@ const CONST = { ACTION: 'action', TAX_AMOUNT: 'taxAmount', }, + BULK_ACTION_TYPES: { + DELETE: 'delete', + HOLD: 'hold', + UNHOLD: 'unhold', + SUBMIT: 'submit', + APPROVE: 'approve', + PAY: 'pay', + }, }, REFERRER: { @@ -5057,6 +5072,12 @@ const CONST = { }, }, + WORKSPACE_CARDS_LIST_LABEL_TYPE: { + CURRENT_BALANCE: 'currentBalance', + REMAINING_LIMIT: 'remainingLimit', + CASH_BACK: 'cashBack', + }, + EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], } as const; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 8ec415442041..5088c1d3158f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -427,6 +427,15 @@ const ONYXKEYS = { // Shared NVPs /** Collection of objects where each object represents the owner of the workspace that is past due billing AND the user is a member of. */ SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END: 'sharedNVP_private_billingGracePeriodEnd_', + + /** Expensify cards settings */ + SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS: 'sharedNVP_private_expensifyCardSettings_', + + /** + * Stores the card list for a given fundID and feed in the format: card__ + * So for example: card_12345_Expensify Card + */ + WORKSPACE_CARDS_LIST: 'card_', }, /** List of Form ids */ @@ -650,6 +659,8 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress; [ONYXKEYS.COLLECTION.SNAPSHOT]: OnyxTypes.SearchResults; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod; + [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS]: OnyxTypes.ExpensifyCardSettings; + [ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST]: OnyxTypes.WorkspaceCardsList; }; type OnyxValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1798b79bde0f..45c56abc71d5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -783,6 +783,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields` as const, }, + WORKSPACE_EXPENSIFY_CARD: { + route: 'settings/workspaces/:policyID/expensify-card', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, + }, // TODO: uncomment after development is done // WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: { // route: 'settings/workspaces/:policyID/expensify-card/issues-new', @@ -794,10 +798,6 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates', getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const, }, - WORKSPACE_EXPENSIFY_CARD: { - route: 'settings/workspaces/:policyID/expensify-card', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, - }, WORKSPACE_CREATE_DISTANCE_RATE: { route: 'settings/workspaces/:policyID/distance-rates/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/new` as const, @@ -937,8 +937,12 @@ const ROUTES = { getRoute: (policyID: string) => `restricted-action/workspace/${policyID}` as const, }, POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: { - route: 'settings/workspaces/:policyID/accounting/net-suite/subsidiary-selector', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/net-suite/subsidiary-selector` as const, + route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const, + }, + POLICY_ACCOUNTING_NETSUITE_IMPORT: { + route: 'settings/workspaces/:policyID/accounting/netsuite/import', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/import` as const, }, POLICY_ACCOUNTING_NETSUITE_EXPORT: { route: 'settings/workspaces/:policyID/connections/netsuite/export/', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 83a110609afd..8214c04cef75 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -272,7 +272,8 @@ 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_SUBSIDIARY_SELECTOR: 'Policy_Accounting_Net_Suite_Subsidiary_Selector', + NETSUITE_SUBSIDIARY_SELECTOR: 'Policy_Accounting_NetSuite_Subsidiary_Selector', + NETSUITE_IMPORT: 'Policy_Accounting_NetSuite_Import', NETSUITE_EXPORT: 'Policy_Accounting_NetSuite_Export', NETSUITE_PREFERRED_EXPORTER_SELECT: 'Policy_Accounting_NetSuite_Preferred_Exporter_Select', NETSUITE_DATE_SELECT: 'Policy_Accounting_NetSuite_Date_Select', diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 1ad2ccb0d717..702f0380ceef 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -23,6 +23,10 @@ type DropdownOption = { iconDescription?: string; onSelected?: () => void; disabled?: boolean; + iconFill?: string; + interactive?: boolean; + numberOfLinesTitle?: number; + titleStyle?: ViewStyle; }; type ButtonWithDropdownMenuProps = { diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index c7797a37fd12..7703b804611a 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useRef, useState} from 'react'; import type {GestureResponderEvent, ViewStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import Badge from '@components/Badge'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; @@ -25,6 +26,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -227,6 +229,13 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti ReportUtils.isSystemChat(report) } /> + {ReportUtils.isChatUsedForOnboarding(report) && SubscriptionUtils.isUserOnFreeTrial() && ( + + )} {isStatusVisible && ( { + 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); } diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 36e33fdda799..154f5c1e1cd3 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -225,6 +225,7 @@ function PopoverMenu({ iconFill={item.iconFill} contentFit={item.contentFit} title={item.text} + titleStyle={item.titleStyle} shouldCheckActionAllowedOnPress={false} description={item.description} numberOfLinesDescription={item.numberOfLinesDescription} @@ -247,6 +248,8 @@ function PopoverMenu({ shouldForceRenderingTooltipLeft={item.shouldForceRenderingTooltipLeft} tooltipWrapperStyle={item.tooltipWrapperStyle} renderTooltipContent={item.renderTooltipContent} + numberOfLinesTitle={item.numberOfLinesTitle} + interactive={item.interactive} /> ))} diff --git a/src/components/Reactions/EmojiReactionBubble.tsx b/src/components/Reactions/EmojiReactionBubble.tsx index 26a14b078b6f..6fa4ee8bb6fb 100644 --- a/src/components/Reactions/EmojiReactionBubble.tsx +++ b/src/components/Reactions/EmojiReactionBubble.tsx @@ -7,7 +7,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ReactionListEvent} from '@pages/home/ReportScreenContext'; import CONST from '@src/CONST'; -import getEmojiReactionBubbleTextOffsetStyle from './getEmojiReactionBubbleTextOffsetStyle'; type EmojiReactionBubbleProps = { /** @@ -83,7 +82,7 @@ function EmojiReactionBubble( accessible dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > - {emojiCodes.join('')} + {emojiCodes.join('')} {count > 0 && {count}} ); diff --git a/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ios.ts b/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ios.ts deleted file mode 100644 index 9f7fb248a103..000000000000 --- a/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ios.ts +++ /dev/null @@ -1,6 +0,0 @@ -function getEmojiReactionBubbleTextOffsetStyle() { - // https://github.com/Expensify/App/issues/36739 - return {transform: [{translateY: 2}]}; -} - -export default getEmojiReactionBubbleTextOffsetStyle; diff --git a/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ts b/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ts deleted file mode 100644 index 1e459554789c..000000000000 --- a/src/components/Reactions/getEmojiReactionBubbleTextOffsetStyle/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -function getEmojiReactionBubbleTextOffsetStyle() { - return {transform: [{translateY: 0}]}; -} - -export default getEmojiReactionBubbleTextOffsetStyle; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index c796a267fd01..3c27ec64d130 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}); } diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx new file mode 100644 index 000000000000..48d9a2b4ae3a --- /dev/null +++ b/src/components/Search/SearchListWithHeader.tsx @@ -0,0 +1,124 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useEffect, useMemo, useState} from 'react'; +import SelectionList from '@components/SelectionList'; +import type {BaseSelectionListProps, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types'; +import * as SearchUtils from '@libs/SearchUtils'; +import CONST from '@src/CONST'; +import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; +import SearchPageHeader from './SearchPageHeader'; +import type {SelectedTransactionInfo, SelectedTransactions} from './types'; + +type SearchListWithHeaderProps = Omit, 'onSelectAll' | 'onCheckboxPress' | 'sections'> & { + query: SearchQuery; + hash: number; + data: TransactionListItemType[] | ReportListItemType[]; + searchType: SearchDataTypes; +}; + +function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { + return [item.keyForList, {isSelected: true, canDelete: item.canDelete, action: item.action}]; +} + +function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, selectedItems: SelectedTransactions) { + return {...item, isSelected: !!selectedItems[item.keyForList]?.isSelected}; +} + +function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType, selectedItems: SelectedTransactions) { + return SearchUtils.isTransactionListItemType(item) + ? mapToTransactionItemWithSelectionInfo(item, selectedItems) + : { + ...item, + transactions: item.transactions?.map((tranaction) => mapToTransactionItemWithSelectionInfo(tranaction, selectedItems)), + isSelected: item.transactions.every((transaction) => !!selectedItems[transaction.keyForList]?.isSelected), + }; +} + +function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef) { + const [selectedItems, setSelectedItems] = useState({}); + + const clearSelectedItems = () => setSelectedItems({}); + + useEffect(() => { + clearSelectedItems(); + }, [hash]); + + const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { + if (SearchUtils.isTransactionListItemType(item)) { + if (!item.keyForList) { + return; + } + + setSelectedItems((prev) => { + if (prev[item.keyForList]?.isSelected) { + const {[item.keyForList]: omittedTransaction, ...transactions} = prev; + return transactions; + } + return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}}; + }); + + return; + } + + if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) { + const reducedSelectedItems: SelectedTransactions = {...selectedItems}; + + item.transactions.forEach((transaction) => { + delete reducedSelectedItems[transaction.keyForList]; + }); + + setSelectedItems(reducedSelectedItems); + return; + } + + setSelectedItems({ + ...selectedItems, + ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), + }); + }; + + const toggleAllTransactions = () => { + const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT; + const flattenedItems = areItemsOfReportType ? (data as ReportListItemType[]).flatMap((item) => item.transactions) : data; + const isAllSelected = flattenedItems.length === Object.keys(selectedItems).length; + + if (isAllSelected) { + clearSelectedItems(); + return; + } + + if (areItemsOfReportType) { + setSelectedItems(Object.fromEntries((data as ReportListItemType[]).flatMap((item) => item.transactions.map(mapTransactionItemToSelectedEntry)))); + + return; + } + + setSelectedItems(Object.fromEntries((data as TransactionListItemType[]).map(mapTransactionItemToSelectedEntry))); + }; + + const sortedSelectedData = useMemo(() => data.map((item) => mapToItemWithSelectionInfo(item, selectedItems)), [data, selectedItems]); + + return ( + <> + + + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + sections={[{data: sortedSelectedData, isDisabled: false}]} + ListItem={ListItem} + onSelectRow={onSelectRow} + ref={ref} + onCheckboxPress={toggleTransaction} + onSelectAll={toggleAllTransactions} + /> + + ); +} + +SearchListWithHeader.displayName = 'SearchListWithHeader'; + +export default forwardRef(SearchListWithHeader); diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx new file mode 100644 index 000000000000..8d42f9e6da36 --- /dev/null +++ b/src/components/Search/SearchPageHeader.tsx @@ -0,0 +1,141 @@ +import React, {useCallback} from 'react'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as SearchActions from '@libs/actions/Search'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {SearchQuery} from '@src/types/onyx/SearchResults'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type IconAsset from '@src/types/utils/IconAsset'; +import type {SelectedTransactions} from './types'; + +type SearchHeaderProps = { + query: SearchQuery; + selectedItems?: SelectedTransactions; + clearSelectedItems?: () => void; + hash: number; +}; + +type SearchHeaderOptionValue = DeepValueOf | undefined; + +function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: SearchHeaderProps) { + const {translate} = useLocalize(); + const theme = useTheme(); + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {isSmallScreenWidth} = useResponsiveLayout(); + const headerContent: {[key in SearchQuery]: {icon: IconAsset; title: string}} = { + all: {icon: Illustrations.MoneyReceipts, title: translate('common.expenses')}, + shared: {icon: Illustrations.SendMoney, title: translate('common.shared')}, + drafts: {icon: Illustrations.Pencil, title: translate('common.drafts')}, + finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')}, + }; + + const getHeaderButtons = useCallback(() => { + const options: Array> = []; + const selectedItemsKeys = Object.keys(selectedItems ?? []); + + if (selectedItemsKeys.length === 0) { + return null; + } + + const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete); + + if (itemsToDelete.length > 0) { + options.push({ + icon: Expensicons.Trashcan, + text: translate('search.bulkActions.delete'), + value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE, + onSelected: () => { + clearSelectedItems?.(); + SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete); + }, + }); + } + + const itemsToHold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + + if (itemsToHold.length > 0) { + options.push({ + icon: Expensicons.Stopwatch, + text: translate('search.bulkActions.hold'), + value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD, + onSelected: () => { + clearSelectedItems?.(); + SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, ''); + }, + }); + } + + const itemsToUnhold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); + + if (itemsToUnhold.length > 0) { + options.push({ + icon: Expensicons.Stopwatch, + text: translate('search.bulkActions.unhold'), + value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD, + onSelected: () => { + clearSelectedItems?.(); + SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold); + }, + }); + } + + if (options.length === 0) { + const emptyOptionStyle = { + interactive: false, + iconFill: theme.icon, + iconHeight: variables.iconSizeLarge, + iconWidth: variables.iconSizeLarge, + numberOfLinesTitle: 2, + titleStyle: {...styles.colorMuted, ...styles.fontWeightNormal}, + }; + + options.push({ + icon: Expensicons.Exclamation, + text: translate('search.bulkActions.noOptionsAvailable'), + value: undefined, + ...emptyOptionStyle, + }); + } + + return ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} + options={options} + isSplitButton={false} + isDisabled={isOffline} + /> + ); + }, [clearSelectedItems, hash, isOffline, selectedItems, styles.colorMuted, styles.fontWeightNormal, theme.icon, translate]); + + if (isSmallScreenWidth) { + return null; + } + + return ( + + {getHeaderButtons()} + + ); +} + +SearchPageHeader.displayName = 'SearchPageHeader'; + +export default SearchPageHeader; diff --git a/src/components/Search.tsx b/src/components/Search/index.tsx similarity index 85% rename from src/components/Search.tsx rename to src/components/Search/index.tsx index 714993204afb..6414501fb06d 100644 --- a/src/components/Search.tsx +++ b/src/components/Search/index.tsx @@ -3,6 +3,9 @@ import type {StackNavigationProp} from '@react-navigation/stack'; import React, {useCallback, useEffect, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; +import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -10,8 +13,8 @@ import * as SearchActions from '@libs/actions/Search'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import * as ReportUtils from '@libs/ReportUtils'; -import * as SearchUtils from '@libs/SearchUtils'; import type {SearchColumnType, SortOrder} from '@libs/SearchUtils'; +import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; @@ -19,14 +22,12 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchQuery} from '@src/types/onyx/SearchResults'; import type SearchResults from '@src/types/onyx/SearchResults'; +import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import SelectionList from './SelectionList'; -import SearchTableHeader from './SelectionList/SearchTableHeader'; -import type {ReportListItemType, TransactionListItemType} from './SelectionList/types'; -import TableListItemSkeleton from './Skeletons/TableListItemSkeleton'; +import SearchListWithHeader from './SearchListWithHeader'; +import SearchPageHeader from './SearchPageHeader'; type SearchProps = { query: SearchQuery; @@ -41,11 +42,6 @@ const reportItemTransactionHeight = 52; const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item const searchHeaderHeight = 54; -function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { - const transactionListItem = item as TransactionListItemType; - return transactionListItem.transactionID !== undefined; -} - function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); @@ -55,7 +51,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const getItemHeight = useCallback( (item: TransactionListItemType | ReportListItemType) => { - if (isTransactionListItemType(item)) { + if (SearchUtils.isTransactionListItemType(item)) { return isLargeScreenWidth ? variables.optionRowHeight + listItemPadding : transactionItemMobileHeight + listItemPadding; } @@ -97,22 +93,38 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowEmptyState = !isLoadingItems && isEmptyObject(searchResults?.data); if (isLoadingItems) { - return ; + return ( + <> + + + + ); } if (shouldShowEmptyState) { - return ; + return ( + <> + + + + ); } const openReport = (item: TransactionListItemType | ReportListItemType) => { - let reportID = isTransactionListItemType(item) ? item.transactionThreadReportID : item.reportID; + let reportID = SearchUtils.isTransactionListItemType(item) ? item.transactionThreadReportID : item.reportID; if (!reportID) { return; } // If we're trying to open a legacy transaction without a transaction thread, let's create the thread and navigate the user - if (isTransactionListItemType(item) && reportID === '0' && item.moneyRequestReportActionID) { + if (SearchUtils.isTransactionListItemType(item) && reportID === '0' && item.moneyRequestReportActionID) { reportID = ReportUtils.generateReportID(); SearchActions.createTransactionThread(hash, item.transactionID, reportID, item.moneyRequestReportActionID); } @@ -152,7 +164,11 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data); return ( - + } + canSelectMultiple={isLargeScreenWidth} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. @@ -177,8 +194,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { windowSize={111} updateCellsBatchingPeriod={200} ListItem={ListItem} - sections={[{data: sortedData, isDisabled: false}]} - onSelectRow={(item) => openReport(item)} + onSelectRow={openReport} getItemHeight={getItemHeight} shouldDebounceRowSelect shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts new file mode 100644 index 000000000000..3ebc2797947a --- /dev/null +++ b/src/components/Search/types.ts @@ -0,0 +1,17 @@ +/** Model of the selected transaction */ +type SelectedTransactionInfo = { + /** Whether the transaction is selected */ + isSelected: boolean; + + /** If the transaction can be deleted */ + canDelete: boolean; + + /** The action that can be performed for the transaction */ + action: string; +}; + +/** Model of selected results */ +type SelectedTransactions = Record; + +// eslint-disable-next-line import/prefer-default-export +export type {SelectedTransactionInfo, SelectedTransactions}; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 617c70a1d224..878d25da4af4 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -24,6 +24,7 @@ import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import Log from '@libs/Log'; +import * as SearchUtils from '@libs/SearchUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -431,6 +432,13 @@ function BaseSelectionList( // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const showTooltip = shouldShowTooltips && normalizedIndex < 10; + const handleOnCheckboxPress = () => { + if (SearchUtils.isReportListItemType(item)) { + return onCheckboxPress; + } + return onCheckboxPress ? () => onCheckboxPress(item) : undefined; + }; + return ( <> ( showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={() => selectRow(item)} - onCheckboxPress={onCheckboxPress ? () => onCheckboxPress?.(item) : undefined} + onCheckboxPress={handleOnCheckboxPress()} onDismissError={() => onDismissError?.(item)} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} // We're already handling the Enter key press in the useKeyboardShortcut hook, so we don't want the list item to submit the form diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index 6aabfebf0da9..9e0599d839df 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -14,9 +14,10 @@ type ActionCellProps = { onButtonPress: () => void; action?: string; isLargeScreenWidth?: boolean; + isSelected?: boolean; }; -function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true}: ActionCellProps) { +function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true, isSelected = false}: ActionCellProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -53,6 +54,7 @@ function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isL small pressOnEnter style={[styles.w100]} + innerStyles={isSelected ? styles.buttonDefaultHovered : {}} /> ); } diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index f9e8e1951d9a..553839ae8457 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem, ReportListItemProps, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import Text from '@components/Text'; @@ -54,6 +55,7 @@ function ReportListItem({ showTooltip, isDisabled, canSelectMultiple, + onCheckboxPress, onSelectRow, onDismissError, onFocus, @@ -104,6 +106,7 @@ function ReportListItem({ showTooltip={showTooltip} isDisabled={isDisabled} canSelectMultiple={canSelectMultiple} + onCheckboxPress={() => onCheckboxPress?.(transactionItem as unknown as TItem)} onSelectRow={() => openReportInRHP(transactionItem)} onDismissError={onDismissError} onFocus={onFocus} @@ -142,10 +145,20 @@ function ReportListItem({ onButtonPress={handleOnButtonPress} /> )} - + - + {canSelectMultiple && ( + onCheckboxPress?.(item)} + isChecked={item.isSelected} + containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]} + disabled={!!isDisabled || item.isDisabledCheckbox} + accessibilityLabel={item.text ?? ''} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + /> + )} + {reportItem?.reportName} {`${reportItem.transactions.length} ${translate('search.groupedExpenses')}`} @@ -167,6 +180,7 @@ function ReportListItem({ isLargeScreenWidth={isLargeScreenWidth} onButtonPress={handleOnButtonPress} action={reportItem.action} + isSelected={item.isSelected} /> @@ -180,9 +194,13 @@ function ReportListItem({ onButtonPress={() => { openReportInRHP(transaction); }} + onCheckboxPress={() => onCheckboxPress?.(transaction as unknown as TItem)} showItemHeaderOnNarrowLayout={false} containerStyle={styles.mt3} isChildListItem + isDisabled={!!isDisabled} + canSelectMultiple={!!canSelectMultiple} + isButtonSelected={item.isSelected} /> ))} diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index 23ab549dd495..b00ae0703c2e 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -12,6 +12,7 @@ function TransactionListItem({ isDisabled, canSelectMultiple, onSelectRow, + onCheckboxPress, onDismissError, onFocus, shouldSyncFocus, @@ -54,6 +55,10 @@ function TransactionListItem({ onButtonPress={() => { onSelectRow(item); }} + onCheckboxPress={() => onCheckboxPress?.(item)} + isDisabled={!!isDisabled} + canSelectMultiple={!!canSelectMultiple} + isButtonSelected={item.isSelected} /> ); diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index 0adc7ee21fd1..9f0799143373 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import ReceiptImage from '@components/ReceiptImage'; @@ -43,9 +44,13 @@ type TransactionListItemRowProps = { item: TransactionListItemType; showTooltip: boolean; onButtonPress: () => void; + onCheckboxPress: () => void; showItemHeaderOnNarrowLayout?: boolean; containerStyle?: StyleProp; isChildListItem?: boolean; + isDisabled: boolean; + canSelectMultiple: boolean; + isButtonSelected?: boolean; }; const getTypeIcon = (type?: SearchTransactionType) => { @@ -209,7 +214,18 @@ function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { ); } -function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeaderOnNarrowLayout = true, containerStyle, isChildListItem = false}: TransactionListItemRowProps) { +function TransactionListItemRow({ + item, + showTooltip, + isDisabled, + canSelectMultiple, + onButtonPress, + onCheckboxPress, + showItemHeaderOnNarrowLayout = true, + containerStyle, + isChildListItem = false, + isButtonSelected = false, +}: TransactionListItemRowProps) { const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); const StyleUtils = useStyleUtils(); @@ -280,7 +296,16 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade return ( - + {canSelectMultiple && ( + + )} + diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx index 6ba753273e8c..95e4b680692b 100644 --- a/src/components/SelectionList/SearchTableHeader.tsx +++ b/src/components/SelectionList/SearchTableHeader.tsx @@ -53,17 +53,17 @@ const SearchColumns: SearchColumnConfig[] = [ { columnName: CONST.SEARCH.TABLE_COLUMNS.CATEGORY, translationKey: 'common.category', - shouldShow: (data, metadata) => metadata?.columnsToShow.shouldShowCategoryColumn ?? false, + shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowCategoryColumn ?? false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TAG, translationKey: 'common.tag', - shouldShow: (data, metadata) => metadata?.columnsToShow.shouldShowTagColumn ?? false, + shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowTagColumn ?? false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT, translationKey: 'common.tax', - shouldShow: (data, metadata) => metadata?.columnsToShow.shouldShowTaxColumn ?? false, + shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowTaxColumn ?? false, isColumnSortable: false, }, { @@ -107,7 +107,7 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, isSortingAllowed, } return ( - + {SearchColumns.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { if (!shouldShow(data, metadata)) { diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index fc369adf5169..b26ff9c4eb57 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -172,6 +172,9 @@ type TransactionListItemType = ListItem & * This is true if at least one transaction in the dataset was created in past years */ shouldShowYear: boolean; + + /** Key used internally by React */ + keyForList: string; }; type ReportListItemType = ListItem & diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index d3916220ca88..8375498ed4b7 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -4,10 +4,14 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; +import * as PolicyActions from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -39,7 +43,7 @@ type SettlementButtonOnyxProps = { type SettlementButtonProps = SettlementButtonOnyxProps & { /** Callback to execute when this button is pressed. Receives a single payment type argument. */ - onPress: (paymentType?: PaymentMethodType) => void; + onPress: (paymentType?: PaymentMethodType, payAsBusiness?: boolean) => void; /** The route to redirect if user does not have a payment method setup */ enablePaymentsRoute: EnablePaymentsRoute; @@ -141,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. @@ -197,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) { @@ -224,9 +250,14 @@ 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)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { triggerKYCFlow(event, iouPaymentType); BankAccounts.setPersonalBankAccountContinueKYCOnSuccess(ROUTES.ENABLE_PAYMENTS); @@ -252,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/languages/en.ts b/src/languages/en.ts index eaf22051dd12..c7e006d754ac 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -33,7 +33,6 @@ import type { GoBackMessageParams, GoToRoomParams, InstantSummaryParams, - IntegrationsMessageParams, LocalTimeParams, LoggedInAsParams, LogSizeParams, @@ -700,6 +699,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}`, @@ -1463,6 +1463,7 @@ export default { error: { containsReservedWord: 'Name cannot contain the words Expensify or Concierge.', hasInvalidCharacter: 'Name cannot contain a comma or semicolon.', + requiredFirstName: 'First name cannot be empty.', }, }, privatePersonalDetails: { @@ -1976,6 +1977,7 @@ export default { workspace: { common: { card: 'Cards', + expensifyCard: 'Expensify Card', workflows: 'Workflows', workspace: 'Workspace', edit: 'Edit workspace', @@ -2016,7 +2018,6 @@ export default { moreFeatures: 'More features', requested: 'Requested', distanceRates: 'Distance rates', - expensifyCard: 'Expensify Card', welcomeNote: ({workspaceName}: WelcomeNoteParams) => `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`, subscription: 'Subscription', @@ -2292,6 +2293,22 @@ 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.', + import: { + expenseCategories: 'Expense categories', + expenseCategoriesDescription: 'NetSuite expense categories import into Expensify as categories.', + importFields: { + departments: 'Departments', + classes: 'Classes', + locations: 'Locations', + customers: 'Customers', + jobs: 'Projects (jobs)', + }, + importTaxDescription: 'Import tax groups from NetSuite', + importCustomFields: { + customSegments: 'Custom segments/records', + customLists: 'Custom lists', + }, + }, }, intacct: { sageIntacctSetup: 'Sage Intacct setup', @@ -2309,6 +2326,20 @@ export default { control: 'Control', collect: 'Collect', }, + expensifyCard: { + issueCard: 'Issue card', + name: 'Name', + lastFour: 'Last 4', + limit: 'Limit', + currentBalance: 'Current balance', + currentBalanceDescription: 'Current balance is the sum of all posted Expensify Card transactions that have occurred since the last settlement date.', + remainingLimit: 'Remaining limit', + requestLimitIncrease: 'Request limit increase', + remainingLimitDescription: + 'We consider a number of factors when calculating your remaining limit: your tenure as a customer, the business-related information you provided during signup, and the available cash in your business bank account. Your remaining limit can fluctuate on a daily basis.', + cashBack: 'Cash back', + cashBackDescription: 'Cash back balance is based on settled monthly Expensify Card spend across your workspace.', + }, categories: { deleteCategories: 'Delete categories', deleteCategoriesPrompt: 'Are you sure you want to delete these categories?', @@ -2610,6 +2641,7 @@ export default { [CONST.INTEGRATION_ENTITY_MAP_TYPES.NOT_IMPORTED]: 'Not imported', [CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE]: 'Not imported', [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Imported as report fields', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite employee default', }, disconnectPrompt: (currentIntegration?: ConnectionName): string => { const integrationName = @@ -3024,6 +3056,12 @@ export default { }, }, groupedExpenses: 'grouped expenses', + bulkActions: { + delete: 'Delete', + hold: 'Hold', + unhold: 'Unhold', + noOptionsAvailable: 'No options available for the selected group of expenses.', + }, }, genericErrorPage: { title: 'Uh-oh, something went wrong!', @@ -3133,7 +3171,7 @@ export default { exportedToCSV: `exported this report to CSV`, exportedToIntegration: ({label}: ExportedToIntegrationParams) => `exported this report to ${label}`, forwarded: ({amount, currency}: ForwardedParams) => `approved ${currency}${amount}`, - integrationsMessage: ({errorMessage, label}: IntegrationsMessageParams) => `failed to export this report to ${label}. ${errorMessage}`, + integrationsMessage: (errorMessage: string, label: string) => `failed to export this report to ${label} ("${errorMessage}").`, managerAttachReceipt: `added a receipt`, managerDetachReceipt: `removed the receipt`, markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `paid ${currency}${amount} elsewhere`, @@ -3389,7 +3427,19 @@ export default { overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Amount over daily ${formattedLimit}/person category limit`, receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', - receiptRequired: (params: ViolationsReceiptRequiredParams) => `Receipt required${params ? ` over ${params.formattedLimit}${params.category ? ' category limit' : ''}` : ''}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => { + let message = 'Receipt required'; + if (formattedLimit ?? category) { + message += ' over'; + if (formattedLimit) { + message += ` ${formattedLimit}`; + } + if (category) { + message += ' category limit'; + } + } + return message; + }, reviewRequired: 'Review required', rter: ({brokenBankConnection, email, isAdmin, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { @@ -3463,6 +3513,9 @@ export default { }, subscription: { mobileReducedFunctionalityMessage: 'You can’t make changes to your subscription in the mobile app.', + badge: { + freeTrial: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left`, + }, billingBanner: { policyOwnerAmountOwed: { title: 'Your payment info is outdated', @@ -3516,7 +3569,11 @@ export default { preTrial: { title: 'Start a free trial', subtitle: 'To get started, ', - subtitleLink: 'complete your setup checklist here', + subtitleLink: 'complete your setup checklist here.', + }, + trialStarted: { + title: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left!`, + subtitle: 'Add a payment card to continue using all of your favorite features.', }, }, cardSection: { @@ -3530,6 +3587,13 @@ export default { changeCurrency: 'Change payment currency', cardNotFound: 'No payment card added', retryPaymentButton: 'Retry payment', + requestRefund: 'Request refund', + requestRefundModal: { + phrase1: 'Getting a refund is easy, just downgrade your account before your next billing date and you’ll receive a refund.', + phrase2: + 'Heads up: Downgrading your account means your workspace(s) will be deleted. This action can’t be undone, but you can always create a new workspace if you change your mind.', + confirm: 'Delete workspace(s) and downgrade', + }, viewPaymentHistory: 'View payment history', }, yourPlan: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 87369d407d77..83dcaae008bb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -33,7 +33,6 @@ import type { GoBackMessageParams, GoToRoomParams, InstantSummaryParams, - IntegrationsMessageParams, LocalTimeParams, LoggedInAsParams, LogSizeParams, @@ -694,6 +693,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}`, @@ -1464,6 +1464,7 @@ export default { error: { containsReservedWord: 'El nombre no puede contener las palabras Expensify o Concierge.', hasInvalidCharacter: 'El nombre no puede contener una coma o un punto y coma.', + requiredFirstName: 'El nombre no puede estar vacío.', }, }, privatePersonalDetails: { @@ -2000,6 +2001,7 @@ export default { workspace: { common: { card: 'Tarjetas', + expensifyCard: 'Tarjeta Expensify', workflows: 'Flujos de trabajo', workspace: 'Espacio de trabajo', edit: 'Editar espacio de trabajo', @@ -2015,7 +2017,6 @@ export default { bills: 'Pagar facturas', invoices: 'Enviar facturas', travel: 'Viajes', - expensifyCard: 'Tarjeta Expensify', members: 'Miembros', accounting: 'Contabilidad', plan: 'Plan', @@ -2325,6 +2326,22 @@ 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.', + import: { + expenseCategories: 'Categorías de gastos', + expenseCategoriesDescription: 'Las categorías de gastos de NetSuite se importan a Expensify como categorías.', + importFields: { + departments: 'Departamentos', + classes: 'Clases', + locations: 'Ubicaciones', + customers: 'Clientes', + jobs: 'Proyectos (trabajos)', + }, + importTaxDescription: 'Importar grupos de impuestos desde NetSuite', + importCustomFields: { + customSegments: 'Segmentos/registros personalizado', + customLists: 'Listas personalizado', + }, + }, }, intacct: { sageIntacctSetup: 'Sage Intacct configuración', @@ -2342,6 +2359,21 @@ export default { control: 'Control', collect: 'Recolectar', }, + expensifyCard: { + issueCard: 'Emitir tarjeta', + name: 'Nombre', + lastFour: '4 últimos', + limit: 'Limite', + currentBalance: 'Saldo actual', + currentBalanceDescription: + 'El saldo actual es la suma de todas las transacciones contabilizadas con la Tarjeta Expensify que se han producido desde la última fecha de liquidación.', + remainingLimit: 'Límite restante', + requestLimitIncrease: 'Solicitar aumento de límite', + remainingLimitDescription: + 'A la hora de calcular tu límite restante, tenemos en cuenta una serie de factores: su antigüedad como cliente, la información relacionada con tu negocio que nos facilitaste al darte de alta y el efectivo disponible en tu cuenta bancaria comercial. Tu límite restante puede fluctuar a diario.', + cashBack: 'Reembolso', + cashBackDescription: 'El saldo de devolución se basa en el gasto mensual realizado con la tarjeta Expensify en tu espacio de trabajo.', + }, categories: { deleteCategories: 'Eliminar categorías', deleteCategoriesPrompt: '¿Estás seguro de que quieres eliminar estas categorías?', @@ -2582,6 +2614,7 @@ export default { [CONST.INTEGRATION_ENTITY_MAP_TYPES.NOT_IMPORTED]: 'No importado', [CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE]: 'No importado', [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Importado como campos de informe', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite employee default', }, disconnectPrompt: (currentIntegration?: ConnectionName): string => { const integrationName = @@ -3059,6 +3092,12 @@ export default { }, }, groupedExpenses: 'gastos agrupados', + bulkActions: { + delete: 'Eliminar', + hold: 'Bloquear', + unhold: 'Desbloquear', + noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.', + }, }, genericErrorPage: { title: '¡Oh-oh, algo salió mal!', @@ -3169,7 +3208,7 @@ export default { exportedToCSV: `exportó este informe a CSV`, exportedToIntegration: ({label}: ExportedToIntegrationParams) => `exportó este informe a ${label}`, forwarded: ({amount, currency}: ForwardedParams) => `aprobado ${currency}${amount}`, - integrationsMessage: ({errorMessage, label}: IntegrationsMessageParams) => `no se pudo exportar este informe a ${label}. ${errorMessage}`, + integrationsMessage: (errorMessage: string, label: string) => `no se pudo exportar este informe a ${label} ("${errorMessage}").`, managerAttachReceipt: `agregó un recibo`, managerDetachReceipt: `quitó el recibo`, markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `pagó ${currency}${amount} en otro lugar`, @@ -3887,8 +3926,19 @@ export default { overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma tu exactitud', - receiptRequired: (params: ViolationsReceiptRequiredParams) => - `Recibo obligatorio${params ? ` para importes sobre${params.formattedLimit ? ` ${params.formattedLimit}` : ''}${params.category ? ' el límite de la categoría' : ''}` : ''}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => { + let message = 'Recibo obligatorio'; + if (formattedLimit ?? category) { + message += ' para importes sobre'; + if (formattedLimit) { + message += ` ${formattedLimit}`; + } + if (category) { + message += ' el límite de la categoría'; + } + } + return message; + }, reviewRequired: 'Revisión requerida', rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { @@ -3963,6 +4013,9 @@ export default { }, subscription: { mobileReducedFunctionalityMessage: 'No puedes hacer cambios en tu suscripción en la aplicación móvil.', + badge: { + freeTrial: ({numOfDays}) => `Prueba gratuita: ${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}`, + }, billingBanner: { policyOwnerAmountOwed: { title: 'Tu información de pago está desactualizada', @@ -4018,7 +4071,11 @@ export default { preTrial: { title: 'Iniciar una prueba gratuita', subtitle: 'Para empezar, ', - subtitleLink: 'completa la lista de configuración aquí', + subtitleLink: 'completa la lista de configuración aquí.', + }, + trialStarted: { + title: ({numOfDays}) => `Prueba gratuita: ¡${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}!`, + subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.', }, }, cardSection: { @@ -4032,6 +4089,13 @@ export default { changeCurrency: 'Cambiar moneda de pago', cardNotFound: 'No se ha añadido ninguna tarjeta de pago', retryPaymentButton: 'Reintentar el pago', + requestRefund: 'Solicitar reembolso', + requestRefundModal: { + phrase1: 'Obtener un reembolso es fácil, simplemente baja tu cuenta de categoría antes de la próxima fecha de facturación y recibirás un reembolso.', + phrase2: + 'Atención: Bajar tu cuenta de categoría significa que tu(s) espacio(s) de trabajo será(n) eliminado(s). Esta acción no se puede deshacer, pero siempre puedes crear un nuevo espacio de trabajo si cambias de opinión.', + confirm: 'Eliminar y degradar', + }, viewPaymentHistory: 'Ver historial de pagos', }, yourPlan: { diff --git a/src/languages/types.ts b/src/languages/types.ts index eb90f2d9e0b2..7ec56760c2f1 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -310,7 +310,18 @@ type ExportedToIntegrationParams = {label: string}; type ForwardedParams = {amount: string; currency: string}; -type IntegrationsMessageParams = {errorMessage: string; label: string}; +type IntegrationsMessageParams = { + label: string; + result: { + code?: number; + messages?: string[]; + title?: string; + link?: { + url: string; + text: string; + }; + }; +}; type MarkedReimbursedParams = {amount: string; currency: string}; diff --git a/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..e44774ae671b --- /dev/null +++ b/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts @@ -0,0 +1,6 @@ +type DeleteMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; +}; + +export default DeleteMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..46ceed818cb8 --- /dev/null +++ b/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts @@ -0,0 +1,7 @@ +type HoldMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; + comment: string; +}; + +export default HoldMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/OpenPolicyExpensifyCardsPageParams.ts b/src/libs/API/parameters/OpenPolicyExpensifyCardsPageParams.ts new file mode 100644 index 000000000000..c3c89857ab3b --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyExpensifyCardsPageParams.ts @@ -0,0 +1,6 @@ +type OpenPolicyExpensifyCardsPageParams = { + policyID: string; + authToken: string | null | undefined; +}; + +export default OpenPolicyExpensifyCardsPageParams; 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/RequestExpensifyCardLimitIncreaseParams.ts b/src/libs/API/parameters/RequestExpensifyCardLimitIncreaseParams.ts new file mode 100644 index 000000000000..6e118f2a1c06 --- /dev/null +++ b/src/libs/API/parameters/RequestExpensifyCardLimitIncreaseParams.ts @@ -0,0 +1,6 @@ +type RequestExpensifyCardLimitIncreaseParams = { + authToken: string | null | undefined; + settlementBankAccountID: string; +}; + +export default RequestExpensifyCardLimitIncreaseParams; diff --git a/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..a32b57731999 --- /dev/null +++ b/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts @@ -0,0 +1,6 @@ +type UnholdMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; +}; + +export default UnholdMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f032edf96e36..2f203a4cfd9a 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -233,5 +233,10 @@ export type {default as UpdateSubscriptionAutoRenewParams} from './UpdateSubscri export type {default as UpdateSubscriptionAddNewUsersAutomaticallyParams} from './UpdateSubscriptionAddNewUsersAutomaticallyParams'; export type {default as GenerateSpotnanaTokenParams} from './GenerateSpotnanaTokenParams'; export type {default as UpdateSubscriptionSizeParams} from './UpdateSubscriptionSizeParams'; +export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyRequestOnSearchParams'; +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 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 1d6456f3df47..c5d5f1ad1e6e 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -229,7 +229,12 @@ const WRITE_COMMANDS = { UPDATE_SUBSCRIPTION_AUTO_RENEW: 'UpdateSubscriptionAutoRenew', UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY: 'UpdateSubscriptionAddNewUsersAutomatically', UPDATE_SUBSCRIPTION_SIZE: 'UpdateSubscriptionSize', + DELETE_MONEY_REQUEST_ON_SEARCH: 'DeleteMoneyRequestOnSearch', + HOLD_MONEY_REQUEST_ON_SEARCH: 'HoldMoneyRequestOnSearch', + UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', + REQUEST_REFUND: 'User_RefundPurchase', UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary', + UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION: 'UpdateNetSuiteSyncTaxConfiguration', UPDATE_NETSUITE_EXPORTER: 'UpdateNetSuiteExporter', UPDATE_NETSUITE_EXPORT_DATE: 'UpdateNetSuiteExportDate', UPDATE_NETSUITE_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'UpdateNetSuiteReimbursableExpensesExportDestination', @@ -245,6 +250,7 @@ const WRITE_COMMANDS = { UPDATE_NETSUITE_TAX_POSTING_ACCOUNT: 'UpdateNetSuiteTaxPostingAccount', UPDATE_NETSUITE_ALLOW_FOREIGN_CURRENCY: 'UpdateNetSuiteAllowForeignCurrency', UPDATE_NETSUITE_EXPORT_TO_NEXT_OPEN_PERIOD: 'UpdateNetSuiteExportToNextOpenPeriod', + REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE: 'RequestExpensifyCardLimitIncrease', CONNECT_POLICY_TO_SAGE_INTACCT: 'ConnectPolicyToSageIntacct', } as const; @@ -449,6 +455,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; [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.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams; [WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams; @@ -475,10 +482,17 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_AUTO_RENEW]: Parameters.UpdateSubscriptionAutoRenewParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams; + + [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; + [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; + [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.UnholdMoneyRequestOnSearchParams; + + [WRITE_COMMANDS.REQUEST_REFUND]: null; [WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT]: Parameters.ConnectPolicyToSageIntacctParams; // Netsuite parameters [WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY]: Parameters.UpdateNetSuiteSubsidiaryParams; + [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>; [WRITE_COMMANDS.UPDATE_NETSUITE_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: Parameters.UpdateNetSuiteGenericTypeParams<'value', ValueOf>; @@ -532,6 +546,7 @@ const READ_COMMANDS = { OPEN_POLICY_CATEGORIES_PAGE: 'OpenPolicyCategoriesPage', OPEN_POLICY_TAGS_PAGE: 'OpenPolicyTagsPage', OPEN_POLICY_TAXES_PAGE: 'OpenPolicyTaxesPage', + OPEN_POLICY_EXPENSIFY_CARDS_PAGE: 'OpenPolicyExpensifyCardsPage', OPEN_WORKSPACE_INVITE_PAGE: 'OpenWorkspaceInvitePage', OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', @@ -586,6 +601,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE]: Parameters.OpenPolicyDistanceRatesPageParams; [READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams; [READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams; + [READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; [READ_COMMANDS.SEARCH]: Parameters.SearchParams; [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: null; }; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 106debd0a7e5..6f80a8a20a6b 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -139,6 +139,10 @@ function hasDetectedFraud(cardList: Record): boolean { return Object.values(cardList).some((card) => card.fraud !== CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE); } +function getMCardNumberString(cardNumber: string): string { + return cardNumber.replace(/\s/g, ''); +} + export { isExpensifyCard, isCorporateCard, @@ -150,4 +154,5 @@ export { getCardDescription, findPhysicalCard, hasDetectedFraud, + getMCardNumberString, }; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 4bf7e208590a..ba296522ccef 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -4,7 +4,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx, {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import OptionsListContextProvider from '@components/OptionListContextProvider'; -import useLastAccessedReportID from '@hooks/useLastAccessedReportID'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -83,7 +82,7 @@ function shouldOpenOnAdminRoom() { return url ? new URL(url).searchParams.get('openOnAdminRoom') === 'true' : false; } -function getCentralPaneScreenInitialParams(screenName: CentralPaneName, lastAccessedReportID?: string): Partial> { +function getCentralPaneScreenInitialParams(screenName: CentralPaneName): Partial> { if (screenName === SCREENS.SEARCH.CENTRAL_PANE) { return {sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, sortOrder: CONST.SEARCH.SORT_ORDER.DESC}; } @@ -91,7 +90,6 @@ function getCentralPaneScreenInitialParams(screenName: CentralPaneName, lastAcce if (screenName === SCREENS.REPORT) { return { openOnAdminRoom: shouldOpenOnAdminRoom() ? true : undefined, - reportID: lastAccessedReportID, }; } @@ -198,7 +196,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie const StyleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); const {shouldUseNarrowLayout} = useOnboardingLayout(); - const lastAccessedReportID = useLastAccessedReportID(shouldOpenOnAdminRoom()); const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles, StyleUtils); const onboardingModalScreenOptions = useMemo(() => screenOptions.onboardingModalNavigator(shouldUseNarrowLayout), [screenOptions, shouldUseNarrowLayout]); const onboardingScreenOptions = useMemo( @@ -474,7 +471,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 417bc4e8b983..e0fb17f882d3 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -320,6 +320,7 @@ 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_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]: () => require('../../../../pages/workspace/accounting/netsuite/export/NetSuitePreferredExporterSelectPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx index 748d92b49a1c..16e8404f5fe9 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx @@ -19,6 +19,7 @@ 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, @@ -31,7 +32,6 @@ 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 32b5eb15812d..1ebbdb5aa0df 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_IMPORT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PREFERRED_EXPORTER_SELECT, SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_DATE_SELECT, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 08274ab4c143..01b467fb53de 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_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT]: { path: ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.route, }, @@ -827,6 +828,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.CARD]: { path: ROUTES.WORKSPACE_CARD.route, }, + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: { + path: ROUTES.WORKSPACE_EXPENSIFY_CARD.route, + }, [SCREENS.WORKSPACE.WORKFLOWS]: { path: ROUTES.WORKSPACE_WORKFLOWS.route, }, @@ -863,9 +867,6 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.REPORT_FIELDS]: { path: ROUTES.WORKSPACE_REPORT_FIELDS.route, }, - [SCREENS.WORKSPACE.EXPENSIFY_CARD]: { - path: ROUTES.WORKSPACE_EXPENSIFY_CARD.route, - }, [SCREENS.WORKSPACE.DISTANCE_RATES]: { path: ROUTES.WORKSPACE_DISTANCE_RATES.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 26c14a50ec0a..0a2809d97208 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -399,6 +399,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT]: { policyID: string; }; @@ -853,6 +856,9 @@ type FullScreenNavigatorParamList = { [SCREENS.WORKSPACE.CARD]: { policyID: string; }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: { + policyID: string; + }; [SCREENS.WORKSPACE.WORKFLOWS]: { policyID: string; }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 619b8bb03f07..7c495e625e10 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/Permissions.ts b/src/libs/Permissions.ts index fb4c99b3d465..faea5965fee4 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -44,6 +44,10 @@ function canUseNetSuiteIntegration(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NETSUITE_ON_NEW_EXPENSIFY) || canUseAllBetas(betas); } +function canUseSageIntacctIntegration(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.INTACCT_ON_NEW_EXPENSIFY) || canUseAllBetas(betas); +} + function canUseReportFieldsFeature(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.REPORT_FIELDS_FEATURE) || canUseAllBetas(betas); } @@ -74,6 +78,7 @@ export default { canUseWorkflowsDelayedSubmission, canUseSpotnanaTravel, canUseNetSuiteIntegration, + canUseSageIntacctIntegration, canUseReportFieldsFeature, canUseWorkspaceFeeds, canUseNetSuiteUSATax, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 24e123def45e..5bd496ab9d39 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -158,7 +158,7 @@ const isPolicyAdmin = (policy: OnyxInputOrEntry, currentUserLogin?: stri (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.ADMIN; /** - * Checks if the current user is an user of the policy. + * Checks if the current user is of the role "user" on the policy. */ const isPolicyUser = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.USER; diff --git a/src/libs/ReportActionItemEventHandler/index.android.ts b/src/libs/ReportActionItemEventHandler/index.android.ts new file mode 100644 index 000000000000..ba24fceb9899 --- /dev/null +++ b/src/libs/ReportActionItemEventHandler/index.android.ts @@ -0,0 +1,14 @@ +import {InteractionManager} from 'react-native'; +import type ReportActionItemEventHandler from './types'; + +const reportActionItemEventHandler: ReportActionItemEventHandler = { + handleComposerLayoutChange: (reportScrollManager, index) => () => { + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + reportScrollManager.scrollToIndex(index, true); + }); + }); + }, +}; + +export default reportActionItemEventHandler; diff --git a/src/libs/ReportActionItemEventHandler/index.ts b/src/libs/ReportActionItemEventHandler/index.ts new file mode 100644 index 000000000000..87d79a8d3ad0 --- /dev/null +++ b/src/libs/ReportActionItemEventHandler/index.ts @@ -0,0 +1,7 @@ +import type ReportActionItemEventHandler from './types'; + +const reportActionItemEventHandler: ReportActionItemEventHandler = { + handleComposerLayoutChange: () => () => {}, +}; + +export default reportActionItemEventHandler; diff --git a/src/libs/ReportActionItemEventHandler/types.ts b/src/libs/ReportActionItemEventHandler/types.ts new file mode 100644 index 000000000000..810c3ec02373 --- /dev/null +++ b/src/libs/ReportActionItemEventHandler/types.ts @@ -0,0 +1,8 @@ +import type {LayoutChangeEvent} from 'react-native'; +import type ReportScrollManagerData from '@hooks/useReportScrollManager/types'; + +type ReportActionItemEventHandler = { + handleComposerLayoutChange: (reportScrollManager: ReportScrollManagerData, index: number) => (event: LayoutChangeEvent) => void; +}; + +export default ReportActionItemEventHandler; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index c8599d785b22..65aaf4c9de0a 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1256,8 +1256,9 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD case CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION: return Localize.translateLocal('report.actions.type.exportedToIntegration', {label: originalMessage.label}); case CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE: { - const {errorMessage, label} = originalMessage; - return Localize.translateLocal('report.actions.type.integrationsMessage', {errorMessage, label}); + const {result, label} = originalMessage; + const errorMessage = result?.messages?.join(', ') ?? ''; + return Localize.translateLocal('report.actions.type.integrationsMessage', errorMessage, label); } case CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT: return Localize.translateLocal('report.actions.type.managerAttachReceipt'); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index cf47864a779e..e16c5c7abe56 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; @@ -1887,7 +1897,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 +2037,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 +2117,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 +2591,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, @@ -5244,7 +5274,7 @@ function isEmptyReport(report: OnyxEntry): boolean { if (!report) { return true; } - const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(report.reportID); + const lastVisibleMessage = getLastVisibleMessage(report.reportID); return !report.lastMessageText && !report.lastMessageTranslationKey && !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey; } @@ -5409,6 +5439,8 @@ function shouldReportBeInOptionList({ excludeEmptyChats, doesReportHaveViolations, includeSelfDM = false, + login, + includeDomainEmail = false, }: { report: OnyxEntry; currentReportId: string; @@ -5418,6 +5450,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 +5555,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 +5599,7 @@ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollec isChatThread(report) || isTaskReport(report) || isMoneyRequestReport(report) || + isInvoiceReport(report) || isChatRoom(report) || isPolicyExpenseChat(report) || (isGroupChat(report) && !shouldIncludeGroupChats) @@ -7041,7 +7081,7 @@ function canReportBeMentionedWithinPolicy(report: OnyxEntry, policyID: s return false; } - return isChatRoom(report) && !isThread(report); + return isChatRoom(report) && !isInvoiceRoom(report) && !isThread(report); } function shouldShowMerchantColumn(transactions: Transaction[]) { @@ -7342,6 +7382,7 @@ export { isChatUsedForOnboarding, getChatUsedForOnboarding, findPolicyExpenseChatByPolicyID, + isIndividualInvoiceRoom, }; export type { diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 460a686766a7..5a7f514a7196 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,7 +1,7 @@ import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -79,10 +79,15 @@ function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean { const currentYear = new Date().getFullYear(); -function isReportListItemType(item: TransactionListItemType | ReportListItemType): item is ReportListItemType { +function isReportListItemType(item: ListItem): item is ReportListItemType { return 'transactions' in item; } +function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { + const transactionListItem = item as TransactionListItemType; + return transactionListItem.transactionID !== undefined; +} + function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | OnyxTypes.SearchResults['data']): boolean { if (Array.isArray(data)) { return data.some((item: TransactionListItemType | ReportListItemType) => { @@ -138,9 +143,9 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata formattedMerchant, date, shouldShowMerchant, - shouldShowCategory: metadata?.columnsToShow.shouldShowCategoryColumn, - shouldShowTag: metadata?.columnsToShow.shouldShowTagColumn, - shouldShowTax: metadata?.columnsToShow.shouldShowTaxColumn, + shouldShowCategory: metadata?.columnsToShow?.shouldShowCategoryColumn, + shouldShowTag: metadata?.columnsToShow?.shouldShowTagColumn, + shouldShowTax: metadata?.columnsToShow?.shouldShowTaxColumn, keyForList: transactionItem.transactionID, shouldShowYear: doesDataContainAPastYearTransaction, }; @@ -185,9 +190,9 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx formattedMerchant, date, shouldShowMerchant, - shouldShowCategory: metadata?.columnsToShow.shouldShowCategoryColumn, - shouldShowTag: metadata?.columnsToShow.shouldShowTagColumn, - shouldShowTax: metadata?.columnsToShow.shouldShowTaxColumn, + shouldShowCategory: metadata?.columnsToShow?.shouldShowCategoryColumn, + shouldShowTag: metadata?.columnsToShow?.shouldShowTagColumn, + shouldShowTax: metadata?.columnsToShow?.shouldShowTaxColumn, keyForList: transactionItem.transactionID, shouldShowYear: doesDataContainAPastYearTransaction, }; @@ -278,5 +283,5 @@ function getSearchParams() { return topmostCentralPaneRoute?.params as AuthScreensParamList['Search_Central_Pane']; } -export {getListItem, getQueryHash, getSections, getSortedSections, getShouldShowMerchant, getSearchType, getSearchParams, shouldShowYear}; +export {getListItem, getQueryHash, getSections, getSortedSections, getShouldShowMerchant, getSearchType, getSearchParams, shouldShowYear, isReportListItemType, isTransactionListItemType}; export type {SearchColumnType, SortOrder}; diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index c8ce7a455906..8569a3f03128 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -5,6 +5,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {BillingGraceEndPeriod, BillingStatus, Fund, FundList, Policy, StripeCustomerID} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import * as PolicyUtils from './PolicyUtils'; const PAYMENT_STATUS = { POLICY_OWNER_WITH_AMOUNT_OWED: 'policy_owner_with_amount_owed', @@ -21,6 +22,14 @@ const PAYMENT_STATUS = { GENERIC_API_ERROR: 'generic_api_error', } as const; +let currentUserAccountID = -1; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + currentUserAccountID = value?.accountID ?? -1; + }, +}); + let amountOwed: OnyxEntry; Onyx.connect({ key: ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED, @@ -401,6 +410,8 @@ function doesUserHavePaymentCardAdded(): boolean { function shouldRestrictUserBillableActions(policyID: string): boolean { const currentDate = new Date(); + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + // This logic will be executed if the user is a workspace's non-owner (normal user or admin). // We should restrict the workspace's non-owner actions if it's member of a workspace where the owner is // past due and is past its grace period end. @@ -409,10 +420,9 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { if (userBillingGracePeriodEnd && isAfter(currentDate, fromUnixTime(userBillingGracePeriodEnd.value))) { // Extracts the owner account ID from the collection member key. - const ownerAccountID = entryKey.slice(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END.length); + const ownerAccountID = Number(entryKey.slice(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END.length)); - const ownerPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - if (String(ownerPolicy?.ownerAccountID ?? -1) === ownerAccountID) { + if (PolicyUtils.isPolicyOwner(policy, ownerAccountID)) { return true; } } @@ -420,7 +430,13 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { // If it reached here it means that the user is actually the workspace's owner. // We should restrict the workspace's owner actions if it's past its grace period end date and it's owing some amount. - if (ownerBillingGraceEndPeriod && amountOwed !== undefined && amountOwed > 0 && isAfter(currentDate, fromUnixTime(ownerBillingGraceEndPeriod))) { + if ( + PolicyUtils.isPolicyOwner(policy, currentUserAccountID) && + ownerBillingGraceEndPeriod && + amountOwed !== undefined && + amountOwed > 0 && + isAfter(currentDate, fromUnixTime(ownerBillingGraceEndPeriod)) + ) { return true; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index a4a53346aa9c..42381d9008a7 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -44,6 +44,7 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import type {IOUAction, IOUType} from '@src/CONST'; @@ -290,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 */ @@ -2441,7 +2448,7 @@ function calculateAmountForUpdatedWaypoint( ) { let updatedAmount: number = CONST.IOU.DEFAULT_AMOUNT; let updatedMerchant = Localize.translateLocal('iou.fieldPending'); - if (!isEmptyObject(transactionChanges?.routes)) { + if (!isEmptyObject(transactionChanges?.routes?.route0?.geometry)) { const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; const mileageRates = DistanceRequestUtils.getMileageRates(policy, true); const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; @@ -3626,6 +3633,9 @@ function trackExpense( const moneyRequestReportID = isMoneyRequestReport ? report.reportID : ''; const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); + // Pass an open receipt so the distance expense will show a map with the route optimistically + const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN} : receipt; + const { createdWorkspaceParams, iouReport, @@ -3648,7 +3658,7 @@ function trackExpense( currency, created, merchant, - receipt, + trackedReceipt, category, tag, taxCode, @@ -3694,7 +3704,7 @@ function trackExpense( taxCode, taxAmount, billable, - receipt, + trackedReceipt, createdWorkspaceParams, ); break; @@ -3725,7 +3735,7 @@ function trackExpense( taxCode, taxAmount, billable, - receipt, + trackedReceipt, createdWorkspaceParams, ); break; @@ -3744,8 +3754,8 @@ function trackExpense( createdChatReportActionID: createdChatReportActionID ?? '-1', createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction?.reportActionID, - receipt, - receiptState: receipt?.state, + receipt: trackedReceipt, + receiptState: trackedReceipt?.state, category, tag, taxCode, @@ -5934,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) { @@ -5973,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, @@ -6218,6 +6245,11 @@ function hasIOUToApproveOrPay(chatReport: OnyxEntry, excludedI } function approveMoneyRequest(expenseReport: OnyxEntry, full?: boolean) { + if (expenseReport?.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(expenseReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(expenseReport.policyID)); + return; + } + const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`] ?? null; let total = expenseReport?.total ?? 0; const hasHeldExpenses = ReportUtils.hasHeldExpenses(expenseReport?.reportID); @@ -6351,6 +6383,11 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: } function submitReport(expenseReport: OnyxTypes.Report) { + if (expenseReport.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(expenseReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(expenseReport.policyID)); + return; + } + const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; const parentReport = getReportOrDraftReport(expenseReport.parentReportID); const policy = PolicyUtils.getPolicy(expenseReport.policyID); @@ -6581,6 +6618,11 @@ function cancelPayment(expenseReport: OnyxTypes.Report, chatReport: OnyxTypes.Re } function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, full = true) { + if (chatReport.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(chatReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(chatReport.policyID)); + return; + } + const recipient = {accountID: iouReport.ownerAccountID}; const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentType, full); @@ -6592,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/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index c5096717ecfc..d4713e580b64 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -162,7 +162,7 @@ function addPaymentCard(params: PaymentCardParams) { const cardYear = CardUtils.getYearFromExpirationDateString(params.expirationDate); const parameters: AddPaymentCardParams = { - cardNumber: params.cardNumber, + cardNumber: CardUtils.getMCardNumberString(params.cardNumber), cardYear, cardMonth, cardCVV: params.securityCode, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 1bb53fbfa002..d075f8653d79 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -18,12 +18,14 @@ import type { EnablePolicyWorkflowsParams, LeavePolicyParams, OpenDraftWorkspaceRequestParams, + OpenPolicyExpensifyCardsPageParams, OpenPolicyMoreFeaturesPageParams, OpenPolicyTaxesPageParams, OpenPolicyWorkflowsPageParams, OpenWorkspaceInvitePageParams, OpenWorkspaceParams, OpenWorkspaceReimburseViewParams, + RequestExpensifyCardLimitIncreaseParams, SetWorkspaceApprovalModeParams, SetWorkspaceAutoReportingFrequencyParams, SetWorkspaceAutoReportingMonthlyOffsetParams, @@ -185,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]; } @@ -1927,6 +1929,17 @@ function openPolicyTaxesPage(policyID: string) { API.read(READ_COMMANDS.OPEN_POLICY_TAXES_PAGE, params); } +function openPolicyExpensifyCardsPage(policyID: string) { + const authToken = NetworkStore.getAuthToken(); + + const params: OpenPolicyExpensifyCardsPageParams = { + policyID, + authToken, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE, params); +} + function openWorkspaceInvitePage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceInvitePage invalid params', {policyID, clientMemberEmails}); @@ -1947,6 +1960,17 @@ function openDraftWorkspaceRequest(policyID: string) { API.read(READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST, params); } +function requestExpensifyCardLimitIncrease(settlementBankAccountID: string) { + const authToken = NetworkStore.getAuthToken(); + + const params: RequestExpensifyCardLimitIncreaseParams = { + authToken, + settlementBankAccountID, + }; + + API.write(WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE, params); +} + function setWorkspaceInviteMessageDraft(policyID: string, message: string | null) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`, message); } @@ -3043,6 +3067,8 @@ export { buildPolicyData, enableExpensifyCard, createPolicyExpenseChats, + openPolicyExpensifyCardsPage, + requestExpensifyCardLimitIncrease, getPoliciesConnectedToSageIntacct, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index d05f77f9c7ac..31e801deeea4 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -783,7 +783,9 @@ function openReport( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - errorFields: null, + errorFields: { + notFound: null, + }, }, }, { diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index ec45298c3910..70f7d2d5b7e0 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; import type {SearchParams} from '@libs/API/parameters'; -import {READ_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchTransaction} from '@src/types/onyx/SearchResults'; import * as Report from './Report'; @@ -15,7 +15,7 @@ Onyx.connect({ }, }); -function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParams) { +function getOnyxLoadingData(hash: number): {optimisticData: OnyxUpdate[]; finallyData: OnyxUpdate[]} { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -40,6 +40,12 @@ function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParam }, ]; + return {optimisticData, finallyData}; +} + +function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParams) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.read(READ_COMMANDS.SEARCH, {hash, query, offset, policyIDs, sortBy, sortOrder}, {optimisticData, finallyData}); } @@ -61,4 +67,19 @@ function createTransactionThread(hash: number, transactionID: string, reportID: Onyx.merge(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, onyxUpdate); } -export {search, createTransactionThread}; +function holdMoneyRequestOnSearch(hash: number, transactionIDList: string[], comment: string) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList, comment}, {optimisticData, finallyData}); +} + +function unholdMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData}); +} + +function deleteMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData}); +} + +export {search, createTransactionThread, deleteMoneyRequestOnSearch, holdMoneyRequestOnSearch, unholdMoneyRequestOnSearch}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index fbeed3cd72e9..7acc79485f0c 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1022,6 +1022,10 @@ function dismissTrackTrainingModal() { }); } +function requestRefund() { + API.write(WRITE_COMMANDS.REQUEST_REFUND, null); +} + export { clearFocusModeNotification, closeAccount, @@ -1053,4 +1057,5 @@ export { clearCustomStatus, updateDraftCustomStatus, clearDraftCustomStatus, + requestRefund, }; diff --git a/src/libs/actions/connections/NetSuiteCommands.ts b/src/libs/actions/connections/NetSuiteCommands.ts index 7f7baca1548e..4d1a6617c253 100644 --- a/src/libs/actions/connections/NetSuiteCommands.ts +++ b/src/libs/actions/connections/NetSuiteCommands.ts @@ -177,6 +177,94 @@ function updateNetSuiteSubsidiary(policyID: string, newSubsidiary: SubsidiaryPar API.write(WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY, params, onyxData); } +function updateNetSuiteSyncTaxConfiguration(policyID: string, isSyncTaxEnabled: boolean) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + syncTax: isSyncTaxEnabled, + }, + // TODO: Fixing in the PR for Import Mapping https://github.com/Expensify/App/pull/44743 + // pendingFields: { + // syncTax: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + // }, + errorFields: { + syncTax: null, + }, + }, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + syncTax: isSyncTaxEnabled, + }, + // TODO: Fixing in the PR for Import Mapping https://github.com/Expensify/App/pull/44743 + // pendingFields: { + // syncTax: null + // }, + errorFields: { + syncTax: null, + }, + }, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + syncTax: !isSyncTaxEnabled, + }, + // pendingFields: { + // syncTax: null, + // }, + errorFields: { + syncTax: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + }, + ], + }; + + const params = { + policyID, + enabled: isSyncTaxEnabled, + }; + API.write(WRITE_COMMANDS.UPDATE_NETSUITE_SYNC_TAX_CONFIGURATION, params, onyxData); +} + function updateNetSuiteExporter(policyID: string, exporter: string, oldExporter: string) { const onyxData = updateNetSuiteOnyxData(policyID, CONST.NETSUITE_CONFIG.EXPORTER, exporter, oldExporter); @@ -345,6 +433,7 @@ function updateNetSuiteExportToNextOpenPeriod(policyID: string, value: boolean, export { updateNetSuiteSubsidiary, + updateNetSuiteSyncTaxConfiguration, updateNetSuiteExporter, updateNetSuiteExportDate, updateNetSuiteReimbursableExpensesExportDestination, 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/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index dcbad36d1eda..b04e56f288e9 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -119,12 +119,15 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD const isInvoiceReport = useMemo(() => ReportUtils.isInvoiceReport(report), [report]); const isInvoiceRoom = useMemo(() => ReportUtils.isInvoiceRoom(report), [report]); const isTaskReport = useMemo(() => ReportUtils.isTaskReport(report), [report]); + const isSelfDM = useMemo(() => ReportUtils.isSelfDM(report), [report]); + const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report); const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(report, parentReportAction); const canEditReportDescription = useMemo(() => ReportUtils.canEditReportDescription(report, policy), [report, policy]); const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== ''); const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; - const isSingleTransactionView = isMoneyRequest || ReportUtils.isTrackExpenseReport(report); + const isSingleTransactionView = isMoneyRequest || isTrackExpenseReport; + const isSelfDMTrackExpenseReport = isTrackExpenseReport && ReportUtils.isSelfDM(parentReport); const shouldDisableRename = useMemo(() => ReportUtils.shouldDisableRename(report), [report]); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report); @@ -158,8 +161,6 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD }, [isInvoiceReport, isMoneyRequestReport, isSingleTransactionView]); const isPrivateNotesFetchTriggered = report?.isLoadingPrivateNotes !== undefined; - const isSelfDM = useMemo(() => ReportUtils.isSelfDM(report), [report]); - const requestParentReportAction = useMemo(() => { // 2. MoneyReport case if (caseID === CASES.MONEY_REPORT) { @@ -190,8 +191,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED && !ReportUtils.isClosedReport(report) && canModifyTask; - const canDeleteRequest = - isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(transactionThreadReport)) && !isDeletedParentAction; + const canDeleteRequest = isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || isSelfDMTrackExpenseReport) && !isDeletedParentAction; const shouldShowDeleteButton = shouldShowTaskDeleteButton || canDeleteRequest; useEffect(() => { diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx index efc9c36de51a..b8880f372809 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx @@ -10,7 +10,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Report from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import variables from '@styles/variables'; @@ -28,7 +27,7 @@ function WorkspaceAdminRestrictedAction({policyID}: WorkspaceAdminRestrictedActi const openAdminsReport = useCallback(() => { const reportID = `${PolicyUtils.getPolicy(policyID)?.chatReportIDAdmins}` ?? '-1'; - Report.openReport(reportID); + Navigation.closeRHPFlow(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); }, [policyID]); diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx index fd6d0fc717b8..52156c05a873 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx @@ -20,6 +20,7 @@ function WorkspaceOwnerRestrictedAction() { const styles = useThemeStyles(); const addPaymentCard = () => { + Navigation.closeRHPFlow(); Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION); }; diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx index 33c159446398..4d2aabd8774e 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx @@ -10,7 +10,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Report from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; @@ -28,7 +27,7 @@ function WorkspaceUserRestrictedAction({policyID}: WorkspaceUserRestrictedAction const openPolicyExpenseReport = useCallback(() => { const reportID = ReportUtils.findPolicyExpenseChatByPolicyID(policyID)?.reportID ?? '-1'; - Report.openReport(reportID); + Navigation.closeRHPFlow(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); }, [policyID]); diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 395b3244f980..e564f139950b 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -185,7 +185,11 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { } const pendingChatMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); const isAdmin = !!(policy && policy.employeeList && details.login && policy.employeeList[details.login]?.role === CONST.POLICY.ROLE.ADMIN); - const isDisabled = (isPolicyExpenseChat && isAdmin) || accountID === session?.accountID || pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const isDisabled = + (isPolicyExpenseChat && isAdmin) || + accountID === session?.accountID || + pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + details.accountID === report.ownerAccountID; result.push({ keyForList: String(accountID), diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7890e53f1b3c..6e734fd835d2 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,11 +1,8 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; -import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; @@ -14,12 +11,10 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {SearchQuery} from '@src/types/onyx/SearchResults'; -import type IconAsset from '@src/types/utils/IconAsset'; type SearchPageProps = StackScreenProps; function SearchPage({route}: SearchPageProps) { - const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); const styles = useThemeStyles(); @@ -28,13 +23,6 @@ function SearchPage({route}: SearchPageProps) { const query = rawQuery as SearchQuery; const isValidQuery = Object.values(CONST.SEARCH.TAB).includes(query); - const headerContent: {[key in SearchQuery]: {icon: IconAsset; title: string}} = { - all: {icon: Illustrations.MoneyReceipts, title: translate('common.expenses')}, - shared: {icon: Illustrations.SendMoney, title: translate('common.shared')}, - drafts: {icon: Illustrations.Pencil, title: translate('common.drafts')}, - finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')}, - }; - const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL)); // On small screens this page is not displayed, the configuration is in the file: src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx @@ -55,11 +43,6 @@ function SearchPage({route}: SearchPageProps) { onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - + {ReportUtils.isChatUsedForOnboarding(report) && SubscriptionUtils.isUserOnFreeTrial() && ( + + )} {isTaskReport && !shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && } {canJoin && !shouldUseNarrowLayout && joinButton} {shouldShowThreeDotsButton && ( diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index fd9a244f210b..0119c3e39871 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -19,13 +19,16 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import getIconForAction from '@libs/getIconForAction'; +import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as IOU from '@userActions/IOU'; import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; type MoneyRequestOptions = Record, PopoverMenuItem>; @@ -125,31 +128,40 @@ function AttachmentPickerWithMenuItems({ * Returns the list of IOU Options */ const moneyRequestOptions = useMemo(() => { + const selectOption = (onSelected: () => void, shouldRestrictAction: boolean) => { + if (shouldRestrictAction && policy && SubscriptionUtils.shouldRestrictUserBillableActions(policy.id)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + + onSelected(); + }; + const options: MoneyRequestOptions = { [CONST.IOU.TYPE.SPLIT]: { icon: Expensicons.Transfer, text: translate('iou.splitExpense'), - onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, report?.reportID ?? '-1'), + onSelected: () => selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, report?.reportID ?? '-1'), true), }, [CONST.IOU.TYPE.SUBMIT]: { icon: getIconForAction(CONST.IOU.TYPE.REQUEST), text: translate('iou.submitExpense'), - onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, report?.reportID ?? '-1'), + onSelected: () => selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, report?.reportID ?? '-1'), true), }, [CONST.IOU.TYPE.PAY]: { icon: getIconForAction(CONST.IOU.TYPE.SEND), text: translate('iou.paySomeone', {name: ReportUtils.getPayeeName(report)}), - onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, report?.reportID ?? '-1'), + onSelected: () => selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, report?.reportID ?? '-1'), false), }, [CONST.IOU.TYPE.TRACK]: { icon: getIconForAction(CONST.IOU.TYPE.TRACK), text: translate('iou.trackExpense'), - onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, report?.reportID ?? '-1'), + onSelected: () => selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, report?.reportID ?? '-1'), true), }, [CONST.IOU.TYPE.INVOICE]: { icon: Expensicons.InvoiceGeneric, text: translate('workspace.invoices.sendInvoice'), - onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.INVOICE, report?.reportID ?? '-1'), + onSelected: () => selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.INVOICE, report?.reportID ?? '-1'), false), }, }; 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/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index d3c8ca3af8de..1f16d4331e44 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -1,8 +1,8 @@ import lodashDebounce from 'lodash/debounce'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {InteractionManager, Keyboard, View} from 'react-native'; import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; +import {InteractionManager, Keyboard, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import Composer from '@components/Composer'; @@ -29,6 +29,7 @@ import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; import {parseHtmlToMarkdown} from '@libs/OnyxAwareParser'; import onyxSubscribe from '@libs/onyxSubscribe'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import reportActionItemEventHandler from '@libs/ReportActionItemEventHandler'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware'; @@ -435,6 +436,7 @@ function ReportActionItemMessageEdit( } setShouldShowComposeInputKeyboardAware(true); }} + onLayout={reportActionItemEventHandler.handleComposerLayoutChange(reportScrollManager, index)} selection={selection} onSelectionChange={(e) => setSelection(e.nativeEvent.selection)} isGroupPolicyReport={isGroupPolicyReport} 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/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index ff5cfa05b57b..a870948b959a 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -23,6 +23,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as App from '@userActions/App'; import * as IOU from '@userActions/IOU'; import * as Policy from '@userActions/Policy/Policy'; @@ -228,41 +229,54 @@ function FloatingActionButtonAndPopover( }, [personalDetails, quickActionReport, quickAction?.action, quickActionAvatars]); const navigateToQuickAction = () => { + const selectOption = (onSelected: () => void, shouldRestrictAction: boolean) => { + if (shouldRestrictAction && quickActionReport?.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(quickActionReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(quickActionReport.policyID)); + return; + } + + onSelected(); + }; + const isValidReport = !(isEmptyObject(quickActionReport) || ReportUtils.isArchivedRoom(quickActionReport)); const quickActionReportID = isValidReport ? quickActionReport?.reportID ?? '-1' : ReportUtils.generateReportID(); + switch (quickAction?.action) { case CONST.QUICK_ACTIONS.REQUEST_MANUAL: - IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), true); return; case CONST.QUICK_ACTIONS.REQUEST_SCAN: - IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true), true); return; case CONST.QUICK_ACTIONS.REQUEST_DISTANCE: - IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true), true); return; case CONST.QUICK_ACTIONS.SPLIT_MANUAL: - IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), true); return; case CONST.QUICK_ACTIONS.SPLIT_SCAN: - IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true), true); return; case CONST.QUICK_ACTIONS.SPLIT_DISTANCE: - IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true), true); return; case CONST.QUICK_ACTIONS.SEND_MONEY: - IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), false); return; case CONST.QUICK_ACTIONS.ASSIGN_TASK: - Task.clearOutTaskInfoAndNavigate(isValidReport ? quickActionReportID : '', isValidReport ? quickActionReport : undefined, quickAction.targetAccountID ?? -1, true); + selectOption( + () => Task.clearOutTaskInfoAndNavigate(isValidReport ? quickActionReportID : '', isValidReport ? quickActionReport : undefined, quickAction.targetAccountID ?? -1, true), + false, + ); break; case CONST.QUICK_ACTIONS.TRACK_MANUAL: - IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), false); break; case CONST.QUICK_ACTIONS.TRACK_SCAN: - IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true), false); break; case CONST.QUICK_ACTIONS.TRACK_DISTANCE: - IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true), false); break; default: } diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 58e69485c1b3..2ead96854e3a 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -19,13 +19,16 @@ import usePermissions from '@hooks/usePermissions'; import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as Policy from '@userActions/Policy/Policy'; import * as Report from '@userActions/Report'; import type {IOUAction, IOURequestType, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {Participant} from '@src/types/onyx/IOU'; type MoneyRequestParticipantsSelectorProps = { @@ -380,12 +383,18 @@ function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onF ]); const onSelectRow = useCallback( - (item: Participant) => { + (option: Participant) => { + if (option.isPolicyExpenseChat && option.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(option.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(option.policyID)); + return; + } + if (isIOUSplit) { - addParticipantToSelection(item); + addParticipantToSelection(option); return; } - addSingleParticipant(item); + + addSingleParticipant(option); }, [isIOUSplit, addParticipantToSelection, addSingleParticipant], ); diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index b1c599458ceb..65acd3660f7b 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -30,6 +30,7 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import shouldShowSubscriptionsMenu from '@libs/shouldShowSubscriptionsMenu'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as UserUtils from '@libs/UserUtils'; import {hasGlobalWorkspaceSettingsRBR} from '@libs/WorkspacesSettingsUtils'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; @@ -90,6 +91,8 @@ type MenuData = { title?: string; shouldShowRightIcon?: boolean; iconRight?: IconAsset; + badgeText?: string; + badgeStyle?: ViewStyle; }; type Menu = {sectionStyle: StyleProp; sectionTranslationKey: TranslationPaths; items: MenuData[]}; @@ -209,6 +212,8 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, link: () => Link.buildOldDotURL(CONST.OLDDOT_URLS.ADMIN_POLICIES_URL), + badgeText: SubscriptionUtils.isUserOnFreeTrial() ? translate('subscription.badge.freeTrial', {numOfDays: SubscriptionUtils.calculateRemainingFreeTrialDays()}) : undefined, + badgeStyle: SubscriptionUtils.isUserOnFreeTrial() ? styles.badgeSuccess : undefined, }); } @@ -217,7 +222,7 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa sectionTranslationKey: 'common.workspaces', items, }; - }, [policies, styles.workspaceSettingsSectionContainer]); + }, [policies, styles.badgeSuccess, styles.workspaceSettingsSectionContainer, translate]); /** * Retuns a list of menu items data for general section @@ -316,7 +321,8 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa } })} iconStyles={item.iconStyles} - badgeText={getWalletBalance(isPaymentItem)} + badgeText={item.badgeText ?? getWalletBalance(isPaymentItem)} + badgeStyle={item.badgeStyle} fallbackIcon={item.fallbackIcon} brickRoadIndicator={item.brickRoadIndicator} floatRightAvatars={item.floatRightAvatars} diff --git a/src/pages/settings/Profile/DisplayNamePage.tsx b/src/pages/settings/Profile/DisplayNamePage.tsx index e338fc16b0ee..90f7ca3abbd6 100644 --- a/src/pages/settings/Profile/DisplayNamePage.tsx +++ b/src/pages/settings/Profile/DisplayNamePage.tsx @@ -50,6 +50,8 @@ function DisplayNamePage({isLoadingApp = true, currentUserPersonalDetails}: Disp ErrorUtils.addErrorMessage(errors, 'firstName', translate('personalDetails.error.hasInvalidCharacter')); } else if (values.firstName.length > CONST.TITLE_CHARACTER_LIMIT) { ErrorUtils.addErrorMessage(errors, 'firstName', translate('common.error.characterLimitExceedCounter', {length: values.firstName.length, limit: CONST.TITLE_CHARACTER_LIMIT})); + } else if (values.firstName.length === 0) { + ErrorUtils.addErrorMessage(errors, 'firstName', translate('personalDetails.error.requiredFirstName')); } if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { ErrorUtils.addErrorMessage(errors, 'firstName', translate('personalDetails.error.containsReservedWord')); diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/TrialStartedBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/TrialStartedBillingBanner.tsx new file mode 100644 index 000000000000..7f4dce39d274 --- /dev/null +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/TrialStartedBillingBanner.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import BillingBanner from './BillingBanner'; + +function TrialStartedBillingBanner() { + const {translate} = useLocalize(); + + return ( + + ); +} + +TrialStartedBillingBanner.displayName = 'TrialStartedBillingBanner'; + +export default TrialStartedBillingBanner; diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index 006baf3a4c0f..e873569e4583 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -1,38 +1,52 @@ -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import ConfirmModal from '@components/ConfirmModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import Section from '@components/Section'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +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 CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import PreTrialBillingBanner from './BillingBanner/PreTrialBillingBanner'; import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner'; +import TrialStartedBillingBanner from './BillingBanner/TrialStartedBillingBanner'; import CardSectionActions from './CardSectionActions'; import CardSectionDataEmpty from './CardSectionDataEmpty'; import CardSectionUtils from './utils'; function CardSection() { + const [isRequestRefundModalVisible, setIsRequestRefundModalVisible] = useState(false); const {translate, preferredLocale} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); + const subscriptionPlan = useSubscriptionPlan(); + const [network] = useOnyx(ONYXKEYS.NETWORK); 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]); + const requestRefund = useCallback(() => { + User.requestRefund(); + setIsRequestRefundModalVisible(false); + Navigation.resetToHome(); + }, []); + const billingStatus = CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData?.cardNumber ?? ''); const nextPaymentDate = !isEmptyObject(privateSubscription) ? CardSectionUtils.getNextBillingDate() : undefined; @@ -42,6 +56,8 @@ function CardSection() { let BillingBanner: React.ReactNode | undefined; if (CardSectionUtils.shouldShowPreTrialBillingBanner()) { BillingBanner = ; + } else if (SubscriptionUtils.isUserOnFreeTrial()) { + BillingBanner = ; } else if (billingStatus) { BillingBanner = ( - - {!isEmptyObject(defaultCard?.accountData) && ( - <> + <> +
+ + {!isEmptyObject(defaultCard?.accountData) && ( + - - - )} + )} + + {isEmptyObject(defaultCard?.accountData) && } - - {!!account?.hasPurchases && ( - Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL))} - hoverAndPressStyle={styles.hoveredComponentBG} + {!!account?.hasPurchases && ( + Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL))} + hoverAndPressStyle={styles.hoveredComponentBG} + /> + )} + {!!(subscriptionPlan && account?.isEligibleForRefund) && ( + setIsRequestRefundModalVisible(true)} + /> + )} +
+ + {account?.isEligibleForRefund && ( + setIsRequestRefundModalVisible(false)} + prompt={ + <> + {translate('subscription.cardSection.requestRefundModal.phrase1')} + {translate('subscription.cardSection.requestRefundModal.phrase2')} + + } + confirmText={translate('subscription.cardSection.requestRefundModal.confirm')} + cancelText={translate('common.cancel')} + danger /> )} - + ); } diff --git a/src/pages/settings/Subscription/PaymentCard/index.tsx b/src/pages/settings/Subscription/PaymentCard/index.tsx index 179590f48907..abaf50ca0af5 100644 --- a/src/pages/settings/Subscription/PaymentCard/index.tsx +++ b/src/pages/settings/Subscription/PaymentCard/index.tsx @@ -56,7 +56,7 @@ function AddPaymentCard() { const addPaymentCard = useCallback((values: FormOnyxValues) => { const cardData = { - cardNumber: values.cardNumber, + cardNumber: CardUtils.getMCardNumberString(values.cardNumber), cardMonth: CardUtils.getMonthFromExpirationDateString(values.expirationDate), cardYear: CardUtils.getYearFromExpirationDateString(values.expirationDate), cardCVV: values.securityCode, diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 2481d91e178f..2c966eebeea4 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -85,14 +85,16 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const {translate} = useLocalize(); const {canUseReportFieldsFeature, canUseWorkspaceFeeds} = usePermissions(); const hasAccountingConnection = !!policy?.areConnectionsEnabled && !isEmptyObject(policy?.connections); - const isSyncTaxEnabled = !!policy?.connections?.quickbooksOnline?.config?.syncTax || !!policy?.connections?.xero?.config?.importTaxRates; + const isSyncTaxEnabled = + !!policy?.connections?.quickbooksOnline?.config?.syncTax || + !!policy?.connections?.xero?.config?.importTaxRates || + !!policy?.connections?.netsuite?.options?.config?.syncOptions?.syncTax; const policyID = policy?.id ?? ''; // @ts-expect-error a new props will be added during feed api implementation const workspaceAccountID = (policy?.workspaceAccountID as string) ?? ''; - // @ts-expect-error onyx key will be available after this PR https://github.com/Expensify/App/pull/44469 - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARDS_LIST}${workspaceAccountID}_Expensify Card`); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); // Uncomment this line for testing disabled toggle feature - for c+ - // const [cardsList = mockedCardsList] = useOnyx(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARDS_LIST}${workspaceAccountID}_Expensify Card`); + // const [cardsList = mockedCardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); const [isOrganizeWarningModalOpen, setIsOrganizeWarningModalOpen] = useState(false); const [isIntegrateWarningModalOpen, setIsIntegrateWarningModalOpen] = useState(false); diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx index 5a2462a45cf9..98276fbff4b3 100644 --- a/src/pages/workspace/WorkspacesListRow.tsx +++ b/src/pages/workspace/WorkspacesListRow.tsx @@ -142,9 +142,9 @@ function WorkspacesListRow({ const isDeleted = style && Array.isArray(style) ? style.includes(styles.offlineFeedback.deleted) : false; const ThreeDotMenuOrPendingIcon = ( - + {isJoinRequestPending && ( - + )} {!isJoinRequestPending && ( - + diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 4ccc67c2aba4..968c42828f1f 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -113,8 +113,8 @@ function accountingIntegrationData( integrationToDisconnect={integrationToDisconnect} /> ), - onImportPagePress: () => {}, - onExportPagePress: () => {}, + onImportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT.getRoute(policyID)), + onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.getRoute(policyID)), onAdvancedPagePress: () => {}, }; case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: @@ -129,7 +129,7 @@ function accountingIntegrationData( /> ), onImportPagePress: () => {}, - onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.getRoute(policyID)), + onExportPagePress: () => {}, onAdvancedPagePress: () => {}, }; default: @@ -142,7 +142,7 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting const styles = useThemeStyles(); const {translate, datetimeToRelative: getDatetimeToRelative} = useLocalize(); const {isOffline} = useNetwork(); - const {canUseNetSuiteIntegration} = usePermissions(); + const {canUseNetSuiteIntegration, canUseSageIntacctIntegration} = usePermissions(); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); const [threeDotsMenuPosition, setThreeDotsMenuPosition] = useState({horizontal: 0, vertical: 0}); const [isDisconnectModalOpen, setIsDisconnectModalOpen] = useState(false); @@ -157,8 +157,10 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting differenceInMinutes(new Date(), lastSyncProgressDate) < CONST.POLICY.CONNECTIONS.SYNC_STAGE_TIMEOUT_MINUTES; const accountingIntegrations = Object.values(CONST.POLICY.CONNECTIONS.NAME).filter( - (name) => !((name === CONST.POLICY.CONNECTIONS.NAME.NETSUITE || name === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT) && !canUseNetSuiteIntegration), + (name) => + !((name === CONST.POLICY.CONNECTIONS.NAME.NETSUITE && !canUseNetSuiteIntegration) || (name === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT && !canUseSageIntacctIntegration)), ); + const connectedIntegration = accountingIntegrations.find((integration) => !!policy?.connections?.[integration]) ?? connectionSyncProgress?.connectionName; const policyID = policy?.id ?? '-1'; const successfulDate = getIntegrationLastSuccessfulDate(connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined); diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportPage.tsx new file mode 100644 index 000000000000..8c9e4bbf947f --- /dev/null +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportPage.tsx @@ -0,0 +1,114 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateNetSuiteSyncTaxConfiguration} from '@libs/actions/connections/NetSuiteCommands'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import {canUseTaxNetSuite} from '@libs/PolicyUtils'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import * as Policy from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; + +function NetSuiteImportPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {canUseNetSuiteUSATax} = usePermissions(); + + const policyID = policy?.id ?? '-1'; + const config = policy?.connections?.netsuite?.options.config; + const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; + const selectedSubsidiary = useMemo(() => (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); + + return ( + + + {}} + /> + + + + {CONST.NETSUITE_CONFIG.IMPORT_FIELDS.map((importField) => ( + Policy.clearNetSuiteErrorField(policyID, importField)} + > + { + // TODO: Navigation will be handled in future PRs + }} + brickRoadIndicator={config?.errorFields?.[importField] ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + + ))} + + + {canUseTaxNetSuite(canUseNetSuiteUSATax, selectedSubsidiary?.country) && ( + + { + updateNetSuiteSyncTaxConfiguration(policyID, isEnabled); + }} + errors={ErrorUtils.getLatestErrorField(config ?? {}, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.SYNC_TAX)} + onCloseError={() => Policy.clearNetSuiteErrorField(policyID, CONST.NETSUITE_CONFIG.SYNC_OPTIONS.SYNC_TAX)} + /> + + )} + + + {CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.map((importField) => ( + Policy.clearNetSuiteErrorField(policyID, importField)} + > + { + // TODO: Navigation will be handled in future PRs + }} + brickRoadIndicator={config?.errorFields?.[importField] ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + + ))} + + + ); +} + +NetSuiteImportPage.displayName = 'NetSuiteImportPage'; +export default withPolicyConnections(NetSuiteImportPage); diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx new file mode 100644 index 000000000000..0ec5b2fc18fa --- /dev/null +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import {View} from 'react-native'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import WorkspaceCardsListLabel from './WorkspaceCardsListLabel'; + +// TODO: remove when Onyx data is available +const mockedSettings = { + currentBalance: 5000, + remainingLimit: 3000, + cashBack: 2000, +}; + +function WorkspaceCardListHeader() { + const {shouldUseNarrowLayout, isMediumScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const isLessThanMediumScreen = isMediumScreenWidth || isSmallScreenWidth; + + // TODO: uncomment the code line below to use cardSettings data from Onyx when it's supported + // const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS}${policyID}`); + const cardSettings = mockedSettings; + + return ( + + + + + + + + + + + + + {translate('workspace.expensifyCard.name')} + + + + + {translate('workspace.expensifyCard.lastFour')} + + + + + {translate('workspace.expensifyCard.limit')} + + + + + ); +} + +WorkspaceCardListHeader.displayName = 'WorkspaceCardListHeader'; + +export default WorkspaceCardListHeader; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx new file mode 100644 index 000000000000..92d814604e57 --- /dev/null +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx @@ -0,0 +1,82 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; +import Avatar from '@components/Avatar'; +import Badge from '@components/Badge'; +import Text from '@components/Text'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {getDefaultAvatarURL} from '@libs/UserUtils'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; + +type WorkspacesListRowProps = { + /** Additional styles applied to the row */ + style: StyleProp; + + /** The last four digits of the card */ + lastFourPAN: string; + + /** Card name */ + name: string; + + /** Cardholder personal details */ + cardholder: PersonalDetails; + + /** Card limit */ + limit: number; + + /** Policy currency */ + currency: string; +}; + +function WorkspaceCardListRow({style, limit, cardholder, lastFourPAN, name, currency}: WorkspacesListRowProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + + const cardholderName = useMemo(() => PersonalDetailsUtils.getDisplayNameOrDefault(cardholder), [cardholder]); + + return ( + + + + + + {cardholderName} + + + {name} + + + + + + {lastFourPAN} + + + + + + + ); +} + +WorkspaceCardListRow.displayName = 'WorkspaceCardListRow'; + +export default WorkspaceCardListRow; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx new file mode 100644 index 000000000000..59a6b168d0bc --- /dev/null +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -0,0 +1,133 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import Button from '@components/Button'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import Popover from '@components/Popover'; +import {PressableWithFeedback} from '@components/Pressable'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import getClickedTargetLocation from '@libs/getClickedTargetLocation'; +import type {FullScreenNavigatorParamList} from '@navigation/types'; +import variables from '@styles/variables'; +import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; + +type WorkspaceCardsListLabelProps = { + /** Label type */ + type: ValueOf; + + /** Label value */ + value: number; + + /** Additional style props */ + style?: StyleProp; +}; + +function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelProps) { + const route = useRoute>(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`); + const styles = useThemeStyles(); + const {windowWidth} = useWindowDimensions(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const theme = useTheme(); + const {translate} = useLocalize(); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [isVisible, setVisible] = useState(false); + const [anchorPosition, setAnchorPosition] = useState({top: 0, left: 0}); + const anchorRef = useRef(null); + + const policyCurrency = useMemo(() => policy?.outputCurrency ?? CONST.CURRENCY.USD, [policy]); + // TODO: instead of the first bankAccount on the list get settlementBankAccountID from the private_expensifyCardSettings NVP and check if that is connected via Plaid. + const isConnectedWithPlaid = useMemo(() => !!Object.values(bankAccountList ?? {})[0]?.accountData?.additionalData?.plaidAccountID, [bankAccountList]); + + useEffect(() => { + if (!anchorRef.current || !isVisible) { + return; + } + + const position = getClickedTargetLocation(anchorRef.current); + const BOTTOM_MARGIN_OFFSET = 3; + + setAnchorPosition({ + top: position.top + position.height + BOTTOM_MARGIN_OFFSET, + left: position.left, + }); + }, [isVisible, windowWidth]); + + const requestLimitIncrease = () => { + // TODO: uncomment when RequestExpensifyCardLimitIncrease API call is supported + // Policy.requestExpensifyCardLimitIncrease(settlementBankAccountID); + setVisible(false); + Report.navigateToConciergeChat(); + }; + + return ( + + + {translate(`workspace.expensifyCard.${type}`)} + setVisible(true)} + > + + + + + {CurrencyUtils.convertToDisplayString(value, policyCurrency)} + + setVisible(false)} + isVisible={isVisible} + outerStyle={!shouldUseNarrowLayout ? styles.pr5 : undefined} + innerContainerStyle={!shouldUseNarrowLayout ? {maxWidth: variables.modalContentMaxWidth} : undefined} + anchorRef={anchorRef} + anchorPosition={anchorPosition} + > + + + {translate(`workspace.expensifyCard.${type}`)} + + {translate(`workspace.expensifyCard.${type}Description`)} + + {!isConnectedWithPlaid && type === CONST.WORKSPACE_CARDS_LIST_LABEL_TYPE.REMAINING_LIMIT && ( + +