diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 8db2901a6116..8bfcbbeb779e 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1,20 +1,17 @@ import {useIsFocused} from '@react-navigation/native'; -import {format} from 'date-fns'; -import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; +import lodashIsEqual from 'lodash/isEqual'; +import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; import type {TextStyle} from 'react-native'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import {MouseProvider} from '@hooks/useMouseContext'; -import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -26,13 +23,9 @@ import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import {getCustomUnitRate, isTaxTrackingEnabled} from '@libs/PolicyUtils'; -import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; -import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; -import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import * as IOU from '@userActions/IOU'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; @@ -46,22 +39,14 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {SplitShares} from '@src/types/onyx/Transaction'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import type {DropdownOption} from './ButtonWithDropdownMenu/types'; -import ConfirmedRoute from './ConfirmedRoute'; -import ConfirmModal from './ConfirmModal'; import FormHelpMessage from './FormHelpMessage'; -import MenuItem from './MenuItem'; -import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import MoneyRequestAmountInput from './MoneyRequestAmountInput'; -import PDFThumbnail from './PDFThumbnail'; +import MoneyRequestConfirmationListFooter from './MoneyRequestConfirmationListFooter'; import {PressableWithFeedback} from './Pressable'; -import PressableWithoutFocus from './Pressable/PressableWithoutFocus'; -import ReceiptEmptyState from './ReceiptEmptyState'; -import ReceiptImage from './ReceiptImage'; import SelectionList from './SelectionList'; import type {SectionListDataType} from './SelectionList/types'; import UserListItem from './SelectionList/UserListItem'; import SettlementButton from './SettlementButton'; -import ShowMoreButton from './ShowMoreButton'; import Text from './Text'; type MoneyRequestConfirmationListOnyxProps = { @@ -89,9 +74,6 @@ type MoneyRequestConfirmationListOnyxProps = { /** Last selected distance rates */ lastSelectedDistanceRates: OnyxEntry>; - /** The list of all policies */ - allPolicies: OnyxCollection; - /** List of currencies */ currencyList: OnyxEntry; }; @@ -219,20 +201,18 @@ function MoneyRequestConfirmationList({ reportActionID, defaultMileageRate, lastSelectedDistanceRates, - allPolicies, action = CONST.IOU.ACTION.CREATE, currencyList, shouldDisplayReceipt = false, }: MoneyRequestConfirmationListProps) { const policy = policyReal ?? policyDraft; const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; - const theme = useTheme(); + const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate, toLocaleDigit} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {canUseP2PDistanceRequests, canUseViolations} = usePermissions(iouType); - const {isOffline} = useNetwork(); + const {canUseP2PDistanceRequests} = usePermissions(iouType); const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; @@ -267,44 +247,15 @@ function MoneyRequestConfirmationList({ const currency = (mileageRate as MileageRate)?.currency ?? policyCurrency; - const taxRates = policy?.taxRates ?? null; - // A flag for showing the categories field const shouldShowCategories = isPolicyExpenseChat && (!!iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); - // A flag and a toggler for showing the rest of the form fields - const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); - - // Do not hide fields in case of paying someone - const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; - - // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item - const shouldShowDate = (shouldShowSmartScanFields || isDistanceRequest) && !isTypeSend; const shouldShowMerchant = shouldShowSmartScanFields && !isDistanceRequest && !isTypeSend; const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); - const senderWorkspace = useMemo(() => { - const senderWorkspaceParticipant = selectedParticipantsProp.find((participant) => participant.isSender); - return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${senderWorkspaceParticipant?.policyID}`]; - }, [allPolicies, selectedParticipantsProp]); - - const canUpdateSenderWorkspace = useMemo(() => { - const isInvoiceRoomParticipant = selectedParticipantsProp.some((participant) => participant.isInvoiceRoom); - - return PolicyUtils.canSendInvoice(allPolicies) && !!transaction?.isFromGlobalCreate && !isInvoiceRoomParticipant; - }, [allPolicies, selectedParticipantsProp, transaction?.isFromGlobalCreate]); - - const canModifyTaxFields = !isReadOnly && !isDistanceRequest; - - // A flag for showing the tags field - // TODO: remove the !isTypeInvoice from this condition after BE supports tags for invoices: https://github.com/Expensify/App/issues/41281 - const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists) && !isTypeInvoice, [isPolicyExpenseChat, policyTagLists, isTypeInvoice]); - const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest) && !isTypeInvoice; - // A flag for showing the billable field - const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); const hasRoute = TransactionUtils.hasRoute(transaction, isDistanceRequest); const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; @@ -314,8 +265,6 @@ function MoneyRequestConfirmationList({ shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0) : iouAmount, isDistanceRequest ? currency : iouCurrencyCode, ); - const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); - const taxRateTitle = TransactionUtils.getTaxName(policy, transaction); const previousTransactionAmount = usePrevious(transaction?.amount); const previousTransactionCurrency = usePrevious(transaction?.currency); @@ -326,14 +275,6 @@ function MoneyRequestConfirmationList({ const [didConfirm, setDidConfirm] = useState(false); const [didConfirmSplit, setDidConfirmSplit] = useState(false); - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [invalidAttachmentPromt, setInvalidAttachmentPromt] = useState(translate('attachmentPicker.protectedPDFNotSupported')); - - const navigateBack = useCallback( - () => Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)), - [iouType, reportID, transactionID], - ); - const shouldDisplayFieldError: boolean = useMemo(() => { if (!isEditingSplitBill) { return false; @@ -344,7 +285,6 @@ function MoneyRequestConfirmationList({ const isMerchantEmpty = useMemo(() => !iouMerchant || TransactionUtils.isMerchantMissing(transaction), [transaction, iouMerchant]); const isMerchantRequired = (isPolicyExpenseChat || isTypeInvoice) && (!isScanRequest || isEditingSplitBill) && shouldShowMerchant; - const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; const isCategoryRequired = !!policy?.requiresCategory; @@ -846,437 +786,51 @@ function MoneyRequestConfirmationList({ translate, ]); - // An intermediate structure that helps us classify the fields as "primary" and "supplementary". - // The primary fields are always shown to the user, while an extra action is needed to reveal the supplementary ones. - const classifiedFields = [ - { - item: ( - { - if (isDistanceRequest) { - return; - } - - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); - }} - style={[styles.moneyRequestMenuItem, styles.mt2]} - titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} - /> - ), - shouldShow: shouldShowSmartScanFields, - isSupplementary: false, - }, - { - item: ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID), - ); - }} - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!isReadOnly} - numberOfLinesTitle={2} - /> - ), - shouldShow: true, - isSupplementary: false, - }, - { - item: ( - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - disabled={didConfirm} - // todo: handle edit for transaction while moving from track expense - interactive={!isReadOnly && !isMovingTransactionFromTrackExpense} - /> - ), - shouldShow: isDistanceRequest && !canUseP2PDistanceRequests, - isSupplementary: false, - }, - { - item: ( - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - disabled={didConfirm} - interactive={!isReadOnly} - /> - ), - shouldShow: isDistanceRequest && canUseP2PDistanceRequests, - isSupplementary: false, - }, - { - item: ( - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - disabled={didConfirm} - interactive={!!rate && !isReadOnly && isPolicyExpenseChat} - /> - ), - shouldShow: isDistanceRequest && canUseP2PDistanceRequests, - isSupplementary: false, - }, - { - item: ( - { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={shouldDisplayMerchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={shouldDisplayMerchantError ? translate('common.error.fieldRequired') : ''} - rightLabel={isMerchantRequired && !shouldDisplayMerchantError ? translate('common.required') : ''} - numberOfLinesTitle={2} - /> - ), - shouldShow: shouldShowMerchant, - isSupplementary: !isMerchantRequired, - }, - { - item: ( - { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID)); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} - /> - ), - shouldShow: shouldShowDate, - isSupplementary: true, - }, - { - item: ( - - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID)) - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!isReadOnly} - rightLabel={isCategoryRequired && canUseViolations ? translate('common.required') : ''} - /> - ), - shouldShow: shouldShowCategories, - isSupplementary: action === CONST.IOU.ACTION.CATEGORIZE ? false : !isCategoryRequired, - }, - ...policyTagLists.map(({name, required}, index) => { - const isTagRequired = required ?? false; - return { - item: ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(action, iouType, index, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID), - ) - } - style={[styles.moneyRequestMenuItem]} - disabled={didConfirm} - interactive={!isReadOnly} - rightLabel={isTagRequired && canUseViolations ? translate('common.required') : ''} - /> - ), - shouldShow: shouldShowTags, - isSupplementary: !isTagRequired, - }; - }), - { - item: ( - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - disabled={didConfirm} - interactive={canModifyTaxFields} - /> - ), - shouldShow: shouldShowTax, - isSupplementary: true, - }, - { - item: ( - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - disabled={didConfirm} - interactive={canModifyTaxFields} - /> - ), - shouldShow: shouldShowTax, - isSupplementary: true, - }, - { - item: ( - - onToggleBillable?.(isOn)} - isActive={iouIsBillable} - disabled={isReadOnly} - titleStyle={!iouIsBillable && {color: theme.textSupporting}} - wrapperStyle={styles.flex1} - /> - - ), - shouldShow: shouldShowBillable, - isSupplementary: true, - }, - ]; - - const primaryFields = classifiedFields.filter((classifiedField) => classifiedField.shouldShow && !classifiedField.isSupplementary).map((primaryField) => primaryField.item); - - const supplementaryFields = classifiedFields - .filter((classifiedField) => classifiedField.shouldShow && classifiedField.isSupplementary) - .map((supplementaryField) => supplementaryField.item); - - const { - image: receiptImage, - thumbnail: receiptThumbnail, - isThumbnail, - fileExtension, - isLocalFile, - } = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); - - const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); - const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); - - const receiptThumbnailContent = useMemo( - () => ( - - {isLocalFile && Str.isPDF(receiptFilename) ? ( - Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID ?? '', transactionID ?? ''))} - accessibilityRole={CONST.ROLE.BUTTON} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - disabled={!shouldDisplayReceipt} - disabledStyle={styles.cursorDefault} - > - { - setIsAttachmentInvalid(true); - setInvalidAttachmentPromt(translate('attachmentPicker.protectedPDFNotSupported')); - }} - onLoadError={() => { - setInvalidAttachmentPromt(translate('attachmentPicker.errorWhileSelectingCorruptedAttachment')); - setIsAttachmentInvalid(true); - }} - /> - - ) : ( - Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID ?? '', transactionID ?? ''))} - disabled={!shouldDisplayReceipt || isThumbnail} - accessibilityRole={CONST.ROLE.BUTTON} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - disabledStyle={styles.cursorDefault} - > - - - )} - - ), - [ - isLocalFile, - receiptFilename, - resolvedThumbnail, - styles.moneyRequestImage, - isAttachmentInvalid, - isThumbnail, - resolvedReceiptImage, - receiptThumbnail, - fileExtension, - isDistanceRequest, - reportID, - transactionID, - translate, - styles.cursorDefault, - shouldDisplayReceipt, - ], - ); - - const listFooterContent = useMemo( - () => ( - <> - {isTypeInvoice && ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID ?? '-1', reportID, Navigation.getActiveRouteWithoutParams()), - ); - }} - style={styles.moneyRequestMenuItem} - labelStyle={styles.mt2} - titleStyle={styles.flex1} - disabled={didConfirm} - /> - )} - {isDistanceRequest && ( - - - - )} - {!isDistanceRequest && - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (receiptImage || receiptThumbnail - ? receiptThumbnailContent - : // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - PolicyUtils.isPaidGroupPolicy(policy) && - !isDistanceRequest && - iouType === CONST.IOU.TYPE.SUBMIT && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - /> - ))} - {primaryFields} - {!shouldShowAllFields && ( - - )} - {shouldShowAllFields && supplementaryFields} - - - ), - [ - canUpdateSenderWorkspace, - didConfirm, - iouType, - isAttachmentInvalid, - isDistanceRequest, - isReadOnly, - isTypeInvoice, - navigateBack, - policy, - primaryFields, - receiptImage, - receiptThumbnail, - receiptThumbnailContent, - reportID, - senderWorkspace?.avatarURL, - senderWorkspace?.name, - senderWorkspace?.id, - shouldShowAllFields, - styles.confirmationListMapItem, - styles.flex1, - styles.mb2, - styles.mb5, - styles.moneyRequestMenuItem, - styles.mt1, - styles.mt2, - supplementaryFields, - transaction, - transactionID, - translate, - invalidAttachmentPromt, - ], + const listFooterContent = ( + ); return ( @@ -1326,10 +880,43 @@ export default withOnyx + lodashIsEqual(prevProps.transaction, nextProps.transaction) && + prevProps.onSendMoney === nextProps.onSendMoney && + prevProps.onConfirm === nextProps.onConfirm && + prevProps.iouType === nextProps.iouType && + prevProps.iouAmount === nextProps.iouAmount && + prevProps.isDistanceRequest === nextProps.isDistanceRequest && + prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && + prevProps.iouCategory === nextProps.iouCategory && + prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields && + prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && + prevProps.iouCurrencyCode === nextProps.iouCurrencyCode && + prevProps.iouMerchant === nextProps.iouMerchant && + lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) && + lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) && + prevProps.isReadOnly === nextProps.isReadOnly && + prevProps.bankAccountRoute === nextProps.bankAccountRoute && + prevProps.policyID === nextProps.policyID && + prevProps.reportID === nextProps.reportID && + prevProps.receiptPath === nextProps.receiptPath && + prevProps.iouComment === nextProps.iouComment && + prevProps.receiptFilename === nextProps.receiptFilename && + prevProps.iouCreated === nextProps.iouCreated && + prevProps.iouIsBillable === nextProps.iouIsBillable && + prevProps.onToggleBillable === nextProps.onToggleBillable && + prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed && + prevProps.reportActionID === nextProps.reportActionID && + lodashIsEqual(prevProps.defaultMileageRate, nextProps.defaultMileageRate) && + lodashIsEqual(prevProps.lastSelectedDistanceRates, nextProps.lastSelectedDistanceRates) && + lodashIsEqual(prevProps.action, nextProps.action) && + lodashIsEqual(prevProps.currencyList, nextProps.currencyList) && + prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt, + ), +); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx new file mode 100644 index 000000000000..cda43938a18f --- /dev/null +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -0,0 +1,711 @@ +import {format} from 'date-fns'; +import {Str} from 'expensify-common'; +import lodashIsEqual from 'lodash/isEqual'; +import React, {memo, useMemo, useReducer, useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReceiptUtils from '@libs/ReceiptUtils'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import CONST from '@src/CONST'; +import type {IOUAction, IOUType} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; +import type {Unit} from '@src/types/onyx/Policy'; +import ConfirmedRoute from './ConfirmedRoute'; +import ConfirmModal from './ConfirmModal'; +import MenuItem from './MenuItem'; +import MenuItemWithTopDescription from './MenuItemWithTopDescription'; +import PDFThumbnail from './PDFThumbnail'; +import PressableWithoutFocus from './Pressable/PressableWithoutFocus'; +import ReceiptEmptyState from './ReceiptEmptyState'; +import ReceiptImage from './ReceiptImage'; +import ShowMoreButton from './ShowMoreButton'; + +type MoneyRequestConfirmationListFooterProps = { + /** The action to perform */ + action: IOUAction; + + /** Flag indicating if P2P distance requests can be used */ + canUseP2PDistanceRequests: boolean | undefined; + + /** The currency of the transaction */ + currency: string; + + /** Flag indicating if the confirmation is done */ + didConfirm: boolean; + + /** The distance of the transaction */ + distance: number; + + /** The formatted amount of the transaction */ + formattedAmount: string; + + /** The error message for the form */ + formError: string; + + /** Flag indicating if there is a route */ + hasRoute: boolean; + + /** The category of the IOU */ + iouCategory: string; + + /** The comment of the IOU */ + iouComment: string | undefined; + + /** The creation date of the IOU */ + iouCreated: string | undefined; + + /** The currency code of the IOU */ + iouCurrencyCode: string | undefined; + + /** Flag indicating if the IOU is billable */ + iouIsBillable: boolean; + + /** The merchant of the IOU */ + iouMerchant: string | undefined; + + /** The type of the IOU */ + iouType: Exclude; + + /** Flag indicating if the category is required */ + isCategoryRequired: boolean; + + /** Flag indicating if it is a distance request */ + isDistanceRequest: boolean; + + /** Flag indicating if it is editing a split bill */ + isEditingSplitBill: boolean | undefined; + + /** Flag indicating if the merchant is empty */ + isMerchantEmpty: boolean; + + /** Flag indicating if the merchant is required */ + isMerchantRequired: boolean | undefined; + + /** Flag indicating if the transaction is moved from track expense */ + isMovingTransactionFromTrackExpense: boolean; + + /** Flag indicating if it is a policy expense chat */ + isPolicyExpenseChat: boolean; + + /** Flag indicating if it is read-only */ + isReadOnly: boolean; + + /** Flag indicating if it is an invoice type */ + isTypeInvoice: boolean; + + /** Function to toggle billable */ + onToggleBillable?: (isOn: boolean) => void; + + /** The policy */ + policy: OnyxEntry; + + /** The policy tag lists */ + policyTagLists: Array>; + + /** The rate of the transaction */ + rate: number | undefined; + + /** The filename of the receipt */ + receiptFilename: string; + + /** The path of the receipt */ + receiptPath: string; + + /** The report action ID */ + reportActionID: string | undefined; + + /** The report ID */ + reportID: string; + + /** The selected participants */ + selectedParticipants: Participant[]; + + /** Flag indicating if the field error should be displayed */ + shouldDisplayFieldError: boolean; + + /** Flag indicating if the receipt should be displayed */ + shouldDisplayReceipt: boolean; + + /** Flag indicating if the categories should be shown */ + shouldShowCategories: boolean; + + /** Flag indicating if the merchant should be shown */ + shouldShowMerchant: boolean; + + /** Flag indicating if the smart scan fields should be shown */ + shouldShowSmartScanFields: boolean; + + /** Flag indicating if the tax should be shown */ + shouldShowTax: boolean; + + /** The transaction */ + transaction: OnyxEntry; + + /** The transaction ID */ + transactionID: string; + + /** The unit */ + unit: Unit | undefined; +}; + +function MoneyRequestConfirmationListFooter({ + action, + canUseP2PDistanceRequests, + currency, + didConfirm, + distance, + formattedAmount, + formError, + hasRoute, + iouCategory, + iouComment, + iouCreated, + iouCurrencyCode, + iouIsBillable, + iouMerchant, + iouType, + isCategoryRequired, + isDistanceRequest, + isEditingSplitBill, + isMerchantEmpty, + isMerchantRequired, + isMovingTransactionFromTrackExpense, + isPolicyExpenseChat, + isReadOnly, + isTypeInvoice, + onToggleBillable, + policy, + policyTagLists, + rate, + receiptFilename, + receiptPath, + reportActionID, + reportID, + selectedParticipants, + shouldDisplayFieldError, + shouldDisplayReceipt, + shouldShowCategories, + shouldShowMerchant, + shouldShowSmartScanFields, + shouldShowTax, + transaction, + transactionID, + unit, +}: MoneyRequestConfirmationListFooterProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate, toLocaleDigit} = useLocalize(); + const {isOffline} = useNetwork(); + const {canUseViolations} = usePermissions(iouType); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + + // A flag and a toggler for showing the rest of the form fields + const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); + + const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [invalidAttachmentPrompt, setInvalidAttachmentPrompt] = useState(translate('attachmentPicker.protectedPDFNotSupported')); + + // A flag for showing the tags field + // TODO: remove the !isTypeInvoice from this condition after BE supports tags for invoices: https://github.com/Expensify/App/issues/41281 + const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists) && !isTypeInvoice, [isPolicyExpenseChat, isTypeInvoice, policyTagLists]); + + const senderWorkspace = useMemo(() => { + const senderWorkspaceParticipant = selectedParticipants.find((participant) => participant.isSender); + return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${senderWorkspaceParticipant?.policyID}`]; + }, [allPolicies, selectedParticipants]); + + const canUpdateSenderWorkspace = useMemo(() => { + const isInvoiceRoomParticipant = selectedParticipants.some((participant) => participant.isInvoiceRoom); + + return PolicyUtils.canSendInvoice(allPolicies) && !!transaction?.isFromGlobalCreate && !isInvoiceRoomParticipant; + }, [allPolicies, selectedParticipants, transaction?.isFromGlobalCreate]); + + const isTypeSend = iouType === CONST.IOU.TYPE.PAY; + const taxRates = policy?.taxRates ?? null; + // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item + const shouldShowDate = (shouldShowSmartScanFields || isDistanceRequest) && !isTypeSend; + // Determines whether the tax fields can be modified. + // The tax fields can only be modified if the component is not in read-only mode + // and it is not a distance request. + const canModifyTaxFields = !isReadOnly && !isDistanceRequest; + // A flag for showing the billable field + const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; + // Do not hide fields in case of paying someone + const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; + // Calculate the formatted tax amount based on the transaction's tax amount and the IOU currency code + const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); + // Get the tax rate title based on the policy and transaction + const taxRateTitle = TransactionUtils.getTaxName(policy, transaction); + // Determine if the merchant error should be displayed + const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; + // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") + const shouldShowReceiptEmptyState = iouType === CONST.IOU.TYPE.SUBMIT && PolicyUtils.isPaidGroupPolicy(policy); + const { + image: receiptImage, + thumbnail: receiptThumbnail, + isThumbnail, + fileExtension, + isLocalFile, + } = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); + const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); + const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); + + // An intermediate structure that helps us classify the fields as "primary" and "supplementary". + // The primary fields are always shown to the user, while an extra action is needed to reveal the supplementary ones. + const classifiedFields = [ + { + item: ( + { + if (isDistanceRequest) { + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + style={[styles.moneyRequestMenuItem, styles.mt2]} + titleStyle={styles.moneyRequestConfirmationAmount} + disabled={didConfirm} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} + /> + ), + shouldShow: shouldShowSmartScanFields, + isSupplementary: false, + }, + { + item: ( + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID), + ); + }} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + disabled={didConfirm} + interactive={!isReadOnly} + numberOfLinesTitle={2} + /> + ), + shouldShow: true, + isSupplementary: false, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + // todo: handle edit for transaction while moving from track expense + interactive={!isReadOnly && !isMovingTransactionFromTrackExpense} + /> + ), + shouldShow: isDistanceRequest && !canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={!isReadOnly} + /> + ), + shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={!!rate && !isReadOnly && isPolicyExpenseChat} + /> + ), + shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={shouldDisplayMerchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={shouldDisplayMerchantError ? translate('common.error.fieldRequired') : ''} + rightLabel={isMerchantRequired && !shouldDisplayMerchantError ? translate('common.required') : ''} + numberOfLinesTitle={2} + /> + ), + shouldShow: shouldShowMerchant, + isSupplementary: !isMerchantRequired, + }, + { + item: ( + { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID)); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} + /> + ), + shouldShow: shouldShowDate, + isSupplementary: true, + }, + { + item: ( + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID)) + } + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + disabled={didConfirm} + interactive={!isReadOnly} + rightLabel={isCategoryRequired && canUseViolations ? translate('common.required') : ''} + /> + ), + shouldShow: shouldShowCategories, + isSupplementary: action === CONST.IOU.ACTION.CATEGORIZE ? false : !isCategoryRequired, + }, + ...policyTagLists.map(({name, required}, index) => { + const isTagRequired = required ?? false; + return { + item: ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(action, iouType, index, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID), + ) + } + style={[styles.moneyRequestMenuItem]} + disabled={didConfirm} + interactive={!isReadOnly} + rightLabel={isTagRequired && canUseViolations ? translate('common.required') : ''} + /> + ), + shouldShow: shouldShowTags, + isSupplementary: !isTagRequired, + }; + }), + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={canModifyTaxFields} + /> + ), + shouldShow: shouldShowTax, + isSupplementary: true, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={canModifyTaxFields} + /> + ), + shouldShow: shouldShowTax, + isSupplementary: true, + }, + { + item: ( + + onToggleBillable?.(isOn)} + isActive={iouIsBillable} + disabled={isReadOnly} + titleStyle={!iouIsBillable && {color: theme.textSupporting}} + wrapperStyle={styles.flex1} + /> + + ), + shouldShow: shouldShowBillable, + isSupplementary: true, + }, + ]; + + const primaryFields: JSX.Element[] = []; + const supplementaryFields: JSX.Element[] = []; + + classifiedFields.forEach((field) => { + if (field.shouldShow && !field.isSupplementary) { + primaryFields.push(field.item); + } else if (field.shouldShow && field.isSupplementary) { + supplementaryFields.push(field.item); + } + }); + + const receiptThumbnailContent = useMemo( + () => ( + + {isLocalFile && Str.isPDF(receiptFilename) ? ( + Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID ?? '', transactionID ?? ''))} + accessibilityRole={CONST.ROLE.BUTTON} + accessibilityLabel={translate('accessibilityHints.viewAttachment')} + disabled={!shouldDisplayReceipt} + disabledStyle={styles.cursorDefault} + > + { + setIsAttachmentInvalid(true); + setInvalidAttachmentPrompt(translate('attachmentPicker.protectedPDFNotSupported')); + }} + onLoadError={() => { + setInvalidAttachmentPrompt(translate('attachmentPicker.errorWhileSelectingCorruptedAttachment')); + setIsAttachmentInvalid(true); + }} + /> + + ) : ( + Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID ?? '', transactionID ?? ''))} + disabled={!shouldDisplayReceipt || isThumbnail} + accessibilityRole={CONST.ROLE.BUTTON} + accessibilityLabel={translate('accessibilityHints.viewAttachment')} + disabledStyle={styles.cursorDefault} + > + + + )} + + ), + [ + styles.moneyRequestImage, + styles.cursorDefault, + isLocalFile, + receiptFilename, + translate, + shouldDisplayReceipt, + resolvedReceiptImage, + isAttachmentInvalid, + isThumbnail, + resolvedThumbnail, + receiptThumbnail, + fileExtension, + isDistanceRequest, + reportID, + transactionID, + ], + ); + + const navigateBack = () => { + Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); + }; + + return ( + <> + {isTypeInvoice && ( + { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID ?? '-1', reportID, Navigation.getActiveRouteWithoutParams())); + }} + style={styles.moneyRequestMenuItem} + labelStyle={styles.mt2} + titleStyle={styles.flex1} + disabled={didConfirm} + /> + )} + {isDistanceRequest && ( + + + + )} + {!isDistanceRequest && + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (receiptImage || receiptThumbnail + ? receiptThumbnailContent + : shouldShowReceiptEmptyState && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + /> + ))} + {primaryFields} + {!shouldShowAllFields && ( + + )} + {shouldShowAllFields && supplementaryFields} + + + ); +} + +MoneyRequestConfirmationListFooter.displayName = 'MoneyRequestConfirmationListFooter'; + +export default memo( + MoneyRequestConfirmationListFooter, + (prevProps, nextProps) => + lodashIsEqual(prevProps.action, nextProps.action) && + prevProps.canUseP2PDistanceRequests === nextProps.canUseP2PDistanceRequests && + prevProps.currency === nextProps.currency && + prevProps.didConfirm === nextProps.didConfirm && + prevProps.distance === nextProps.distance && + prevProps.formattedAmount === nextProps.formattedAmount && + prevProps.formError === nextProps.formError && + prevProps.hasRoute === nextProps.hasRoute && + prevProps.iouCategory === nextProps.iouCategory && + prevProps.iouComment === nextProps.iouComment && + prevProps.iouCreated === nextProps.iouCreated && + prevProps.iouCurrencyCode === nextProps.iouCurrencyCode && + prevProps.iouIsBillable === nextProps.iouIsBillable && + prevProps.iouMerchant === nextProps.iouMerchant && + prevProps.iouType === nextProps.iouType && + prevProps.isCategoryRequired === nextProps.isCategoryRequired && + prevProps.isDistanceRequest === nextProps.isDistanceRequest && + prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && + prevProps.isMerchantEmpty === nextProps.isMerchantEmpty && + prevProps.isMerchantRequired === nextProps.isMerchantRequired && + prevProps.isMovingTransactionFromTrackExpense === nextProps.isMovingTransactionFromTrackExpense && + prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && + prevProps.isReadOnly === nextProps.isReadOnly && + prevProps.isTypeInvoice === nextProps.isTypeInvoice && + prevProps.onToggleBillable === nextProps.onToggleBillable && + lodashIsEqual(prevProps.policy, nextProps.policy) && + lodashIsEqual(prevProps.policyTagLists, nextProps.policyTagLists) && + prevProps.rate === nextProps.rate && + prevProps.receiptFilename === nextProps.receiptFilename && + prevProps.receiptPath === nextProps.receiptPath && + prevProps.reportActionID === nextProps.reportActionID && + prevProps.reportID === nextProps.reportID && + lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) && + prevProps.shouldDisplayFieldError === nextProps.shouldDisplayFieldError && + prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt && + prevProps.shouldShowCategories === nextProps.shouldShowCategories && + prevProps.shouldShowMerchant === nextProps.shouldShowMerchant && + prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields && + prevProps.shouldShowTax === nextProps.shouldShowTax && + lodashIsEqual(prevProps.transaction, nextProps.transaction) && + prevProps.transactionID === nextProps.transactionID && + prevProps.unit === nextProps.unit, +); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index d5dafa58cd9e..ea03c9ae3b06 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -535,9 +535,12 @@ function IOURequestStepConfirmation({ [transaction?.amount, transaction?.comment, transaction?.currency, participants, currentUserPersonalDetails.accountID, report], ); - const setBillable = (billable: boolean) => { - IOU.setMoneyRequestBillable(transactionID, billable); - }; + const setBillable = useCallback( + (billable: boolean) => { + IOU.setMoneyRequestBillable(transactionID, billable); + }, + [transactionID], + ); return (