diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 9db2b8c1d869..d722c48968aa 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,143 +1,30 @@ import {useRoute} from '@react-navigation/native'; -import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; -import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; -import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; -import truncate from 'lodash/truncate'; -import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; -import {InteractionManager, View} from 'react-native'; -import type {ValueOf} from 'type-fest'; -import useActiveAdminPolicies from '@hooks/useActiveAdminPolicies'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useConfirmPendingRTERAndProceed from '@hooks/useConfirmPendingRTERAndProceed'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; -import useDeleteTransactions from '@hooks/useDeleteTransactions'; -import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; -import useExportAgainModal from '@hooks/useExportAgainModal'; -import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import React, {useEffect} from 'react'; +import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; -import usePaymentAnimations from '@hooks/usePaymentAnimations'; -import usePaymentOptions from '@hooks/usePaymentOptions'; -import usePermissions from '@hooks/usePermissions'; -import usePolicy from '@hooks/usePolicy'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; -import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; -import useStrictPolicyRules from '@hooks/useStrictPolicyRules'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import useTransactionViolations from '@hooks/useTransactionViolations'; -import {duplicateReport as duplicateReportAction, duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; -import {openOldDotLink} from '@libs/actions/Link'; -import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import {deleteAppReport, exportReportToCSV, exportReportToPDF, exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; -import {getExportTemplates, queueExportSearchWithTemplate, search} from '@libs/actions/Search'; -import initSplitExpense from '@libs/actions/SplitExpenses'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getExistingTransactionID} from '@libs/IOUUtils'; -import Log from '@libs/Log'; -import {getAllNonDeletedTransactions, getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; -import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; -import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; -import {getConnectedIntegration, getValidConnectedIntegration, isPolicyAccessible, sortPoliciesByName} from '@libs/PolicyUtils'; -import { - getFilteredReportActionsForReportView, - getIOUActionForTransactionID, - getOneTransactionThreadReportID, - getOriginalMessage, - hasRequestFromCurrentAccount, - isMoneyRequestAction, -} from '@libs/ReportActionsUtils'; -import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; -import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; -import { - canEditFieldOfMoneyRequest, - canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - changeMoneyRequestHoldStatus, - generateReportID, - getAddExpenseDropdownOptions, - getIntegrationIcon, - getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, - getNextApproverAccountID, - getPolicyExpenseChat, - hasHeldExpenses as hasHeldExpensesReportUtils, - hasUpdatedTotal, - hasViolations as hasViolationsReportUtils, - isAllowedToApproveExpenseReport, - isCurrentUserSubmitter, - isDM, - isExported as isExportedUtils, - isInvoiceReport as isInvoiceReportUtil, - isIOUReport as isIOUReportUtil, - isOpenReport, - isReportOwner, - isSelfDM, - navigateOnDeleteExpense, - navigateToDetailsPage, - shouldBlockSubmitDueToStrictPolicyRules, -} from '@libs/ReportUtils'; -import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; -import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import { - getChildTransactions, - getOriginalTransactionWithSplitInfo, - hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils, - hasCustomUnitOutOfPolicyViolation as hasCustomUnitOutOfPolicyViolationTransactionUtils, - isDistanceRequest, - isExpensifyCardTransaction, - isPending, - isPerDiemRequest, - isTransactionPendingDelete, -} from '@libs/TransactionUtils'; -import {startMoneyRequest} from '@userActions/IOU'; -import {getNavigationUrlOnMoneyRequestDelete} from '@userActions/IOU/DeleteMoneyRequest'; -import {cancelPayment, payInvoice, payMoneyRequest} from '@userActions/IOU/PayMoneyRequest'; -import {approveMoneyRequest, canApproveIOU, canIOUBePaid as canIOUBePaidAction, reopenReport, retractReport, submitReport, unapproveExpenseReport} from '@userActions/IOU/ReportWorkflow'; -import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; -import {markPendingRTERTransactionsAsCash} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; -import type {ButtonWithDropdownMenuRef, DropdownOption} from './ButtonWithDropdownMenu/types'; -import {useDelegateNoAccessActions, useDelegateNoAccessState} from './DelegateNoAccessModalProvider'; import HeaderLoadingBar from './HeaderLoadingBar'; import HeaderWithBackButton from './HeaderWithBackButton'; -import {KYCWallContext} from './KYCWall/KYCWallContext'; -import {useLockedAccountActions, useLockedAccountState} from './LockedAccountModalProvider'; -import {ModalActions} from './Modal/Global/ModalContext'; -import MoneyReportHeaderKYCDropdown from './MoneyReportHeaderKYCDropdown'; +import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import MoneyReportHeaderModals from './MoneyReportHeaderModals'; -import {useMoneyReportHeaderModals} from './MoneyReportHeaderModalsContext'; import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; -import MoneyReportHeaderPrimaryAction from './MoneyReportHeaderPrimaryAction'; -import {usePersonalDetails} from './OnyxListItemProvider'; -import type {PopoverMenuItem} from './PopoverMenu'; -import BulkDuplicateHandler from './Search/BulkDuplicateHandler'; -import {useSearchActionsContext, useSearchStateContext} from './Search/SearchContext'; -import type {PaymentActionParams} from './SettlementButton/types'; -import Text from './Text'; +import {PaymentAnimationsProvider} from './PaymentAnimationsContext'; +import {useSearchActionsContext} from './Search/SearchContext'; type MoneyReportHeaderProps = { /** The reportID of the report currently being looked at */ @@ -153,20 +40,21 @@ type MoneyReportHeaderProps = { function MoneyReportHeader({reportID, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) { return ( - + + + ); } function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) { + const {clearSelectedTransactions} = useSearchActionsContext(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDProp}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); - const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use a correct layout for the hold expense modal https://github.com/Expensify/App/pull/47990#issuecomment-2362382026 // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -178,1627 +66,26 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt | PlatformStackRouteProp | PlatformStackRouteProp >(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {login: currentUserLogin, accountID, email} = currentUserPersonalDetails; - const personalDetails = usePersonalDetails(); - const defaultExpensePolicy = useDefaultExpensePolicy(); - const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`); const {isOffline} = useNetwork(); - const allReportTransactions = useReportTransactionsCollection(reportIDProp); - const nonDeletedTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); - const visibleTransactionsForThreadID = nonDeletedTransactions?.filter((t) => isOffline || t.pendingAction !== 'delete'); - const reportTransactionIDs = visibleTransactionsForThreadID?.map((t) => t.transactionID); - const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); - const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${moneyRequestReport?.reportID}`); - const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, { - selector: isUserValidatedSelector, - }); - const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); - const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); - const [session] = useOnyx(ONYXKEYS.SESSION); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - - const activePolicy = usePolicy(activePolicyID); - const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); - const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); - const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); - const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`); - const {getCurrencyDecimals} = useCurrencyListActions(); - - const expensifyIcons = useMemoizedLazyExpensifyIcons([ - 'Building', - 'Buildings', - 'Plus', - 'Cash', - 'Stopwatch', - 'Send', - 'Clear', - 'ThumbsUp', - 'CircularArrowBackwards', - 'ArrowSplit', - 'ArrowCollapse', - 'Workflows', - 'Trashcan', - 'ArrowRight', - 'ThumbsDown', - 'Table', - 'Info', - 'Export', - 'Download', - 'XeroSquare', - 'QBOSquare', - 'NetSuiteSquare', - 'IntacctSquare', - 'QBDSquare', - 'CertiniaSquare', - 'GustoSquare', - 'Feed', - 'Location', - 'ReceiptPlus', - 'ExpenseCopy', - 'Checkmark', - 'ReportCopy', - 'Printer', - 'DocumentMerge', - ]); - const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); - const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${moneyRequestReport?.reportID}`); - const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, { - selector: validTransactionDraftsSelector, - }); - const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); - const {translate, localeCompare} = useLocalize(); - const exportTemplates = useMemo( - () => getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy), - [integrationsExportTemplates, csvExportLayouts, policy, translate], - ); - const {areStrictPolicyRulesEnabled} = useStrictPolicyRules(); - const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); - - const requestParentReportAction = useMemo(() => { - if (!reportActions || !transactionThreadReport?.parentReportActionID) { - return null; - } - return reportActions.find((action): action is OnyxTypes.ReportAction => action.reportActionID === transactionThreadReport.parentReportActionID); - }, [reportActions, transactionThreadReport?.parentReportActionID]); + const {translate} = useLocalize(); - const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(requestParentReportAction); - - const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); - - const {transactions, nonPendingDeleteTransactions} = useMemo(() => { - const all: OnyxTypes.Transaction[] = []; - const filtered: OnyxTypes.Transaction[] = []; - for (const transaction of Object.values(reportTransactions)) { - all.push(transaction); - if (!isTransactionPendingDelete(transaction)) { - filtered.push(transaction); - } - } - return {transactions: all, nonPendingDeleteTransactions: filtered}; - }, [reportTransactions]); + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); - // When prevent self-approval is enabled & the current user is submitter AND they're submitting to themselves, we need to show the optimistic next step - // We should always show this optimistic message for policies with preventSelfApproval - // to avoid any flicker during transitions between online/offline states - const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport); - const isSubmitterSameAsNextApprover = - isReportOwner(moneyRequestReport) && (nextApproverAccountID === moneyRequestReport?.ownerAccountID || moneyRequestReport?.managerID === moneyRequestReport?.ownerAccountID); - const isBlockSubmitDueToPreventSelfApproval = isSubmitterSameAsNextApprover && policy?.preventSelfApproval; - const isBlockSubmitDueToStrictPolicyRules = useMemo(() => { - return shouldBlockSubmitDueToStrictPolicyRules(moneyRequestReport?.reportID, violations, areStrictPolicyRulesEnabled, accountID, email ?? '', transactions); - }, [moneyRequestReport?.reportID, violations, areStrictPolicyRulesEnabled, accountID, email, transactions]); - const shouldBlockSubmit = isBlockSubmitDueToStrictPolicyRules || isBlockSubmitDueToPreventSelfApproval; + const transactions = Object.values(reportTransactions); - const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); - - const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); - const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); - - const [invoiceReceiverPolicy] = useOnyx( - `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, - {}, - ); - - const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactions.map((t) => t.transactionID)); - const {deleteTransactions} = useDeleteTransactions({ - report: chatReport, - reportActions, - policy, - }); - const isExported = useMemo(() => isExportedUtils(reportActions, moneyRequestReport), [reportActions, moneyRequestReport]); - // wrapped in useMemo to improve performance because this is an operation on array - const integrationNameFromExportMessage = useMemo(() => { - if (!isExported) { - return null; - } - return getIntegrationNameFromExportMessageUtils(reportActions); - }, [isExported, reportActions]); - - const transactionViolations = useTransactionViolations(transaction?.transactionID); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); - const currentTransaction = transactions.at(0); - const [originalIOUTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(currentTransaction?.comment?.originalTransactionID)}`); - const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); - const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const {isBetaEnabled} = usePermissions(); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const isBulkSubmitApprovePayBetaEnabled = isBetaEnabled(CONST.BETAS.BULK_SUBMIT_APPROVE_PAY); - const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); - const hasCustomUnitOutOfPolicyViolation = hasCustomUnitOutOfPolicyViolationTransactionUtils(transactionViolations); - const isPerDiemRequestOnNonDefaultWorkspace = isPerDiemRequest(transaction) && defaultExpensePolicy?.id !== policy?.id; - - const {showConfirmModal} = useConfirmModal(); - const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); - const {showOfflineModal, showDownloadErrorModal} = useMoneyReportHeaderModals(); - - const {isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, startAnimation, stopAnimation, startApprovedAnimation, startSubmittingAnimation} = - usePaymentAnimations(); const styles = useThemeStyles(); - const theme = useTheme(); - const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); - const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const hasMultipleSplits = useMemo(() => { - if (!transaction?.comment?.originalTransactionID) { - return false; - } - const children = getChildTransactions(allTransactions, allReports, transaction.comment.originalTransactionID); - return children.length > 1; - }, [allTransactions, allReports, transaction?.comment?.originalTransactionID]); - const isReportOpen = isOpenReport(moneyRequestReport); - const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); - - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); - const dropdownMenuRef = useRef(null); - const wasDuplicateReportTriggered = useRef(false); - - const handleOptionsMenuHide = useCallback(() => { - wasDuplicateReportTriggered.current = false; - }, []); - - useEffect(() => { - if (!isDuplicateReportActive || !wasDuplicateReportTriggered.current) { - return; - } - wasDuplicateReportTriggered.current = false; - dropdownMenuRef.current?.setIsMenuVisible(false); - }, [isDuplicateReportActive]); - - const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); - const connectedIntegration = getValidConnectedIntegration(policy); - const connectedIntegrationFallback = getConnectedIntegration(policy); - const hasOnlyPendingTransactions = useMemo(() => { - return !!transactions && transactions.length > 0 && transactions.every((t) => isExpensifyCardTransaction(t) && isPending(t)); - }, [transactions]); - const transactionIDs = useMemo(() => transactions?.map((t) => t.transactionID) ?? [], [transactions]); - const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); - const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); - const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasSeenTourSelector, - }); - - // Check if any transactions have pending RTER violations (for showing the submit confirmation modal) - const hasAnyPendingRTERViolation = useMemo( - () => hasAnyPendingRTERViolationTransactionUtils(transactions, allTransactionViolations, email ?? '', accountID, moneyRequestReport, policy), - [transactions, allTransactionViolations, email, accountID, moneyRequestReport, policy], - ); - - const isArchivedReport = useReportIsArchived(moneyRequestReport?.reportID); - const isChatReportArchived = useReportIsArchived(chatReport?.reportID); - - const canMoveSingleExpense = useMemo(() => { - if (nonPendingDeleteTransactions.length !== 1) { - return false; - } - - const transactionToMove = nonPendingDeleteTransactions.at(0); - if (!transactionToMove) { - return false; - } - - const iouReportAction = getIOUActionForTransactionID(reportActions, transactionToMove.transactionID); - const canMoveExpense = canEditFieldOfMoneyRequest({ - reportAction: iouReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, - isChatReportArchived, - outstandingReportsByPolicyID, - transaction: transactionToMove, - }); - - const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(moneyRequestReport, isChatReportArchived); - - return canMoveExpense && canUserPerformWriteAction; - }, [nonPendingDeleteTransactions, reportActions, isChatReportArchived, outstandingReportsByPolicyID, moneyRequestReport]); - - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${moneyRequestReport?.reportID}`); - const getCanIOUBePaid = useCallback( - (onlyShowPayElsewhere = false) => - canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, onlyShowPayElsewhere, undefined, invoiceReceiverPolicy), - [moneyRequestReport, chatReport, policy, bankAccountList, transaction, invoiceReceiverPolicy], - ); - - const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const isDistanceExpenseUnsupportedForDuplicating = !!( - isDistanceRequest(transaction) && - (isArchivedReport || isChatReportArchived || (activePolicyExpenseChat && (isDM(chatReport) || isSelfDM(chatReport)))) - ); - - const shouldDuplicateCloseModalOnSelect = - isDistanceExpenseUnsupportedForDuplicating || - isPerDiemRequestOnNonDefaultWorkspace || - hasCustomUnitOutOfPolicyViolation || - activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID; - - const handleDuplicateReset = useCallback(() => { - if (shouldDuplicateCloseModalOnSelect) { - return; - } - dropdownMenuRef.current?.setIsMenuVisible(false); - }, [shouldDuplicateCloseModalOnSelect]); - - const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(handleDuplicateReset); - - const {selectedTransactionIDs, currentSearchQueryJSON, currentSearchKey, currentSearchHash, currentSearchResults} = useSearchStateContext(); - const {removeTransaction, clearSelectedTransactions} = useSearchActionsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; - - const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); - - const showExportProgressModal = useCallback(() => { - return showConfirmModal({ - title: translate('export.exportInProgress'), - prompt: translate('export.conciergeWillSend'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }, [showConfirmModal, translate]); - - const beginExportWithTemplate = useCallback( - (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => { - if (isOffline) { - showOfflineModal(); - return; - } - - if (!moneyRequestReport) { - return; - } - - showExportProgressModal().then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - clearSelectedTransactions(undefined, true); - }); - queueExportSearchWithTemplate({ - templateName, - templateType, - jsonQuery: '{}', - reportIDList: [moneyRequestReport.reportID], - transactionIDList, - policyID, - }); - }, - [isOffline, moneyRequestReport, showExportProgressModal, clearSelectedTransactions, showOfflineModal], - ); - - const isOnSearch = route.name.toLowerCase().startsWith('search'); - const { - options: originalSelectedTransactionsOptions, - handleDeleteTransactions, - handleDeleteTransactionsWithNavigation, - isDuplicateOptionVisible, - setDuplicateHandler, - allTransactions: allTransactionsForDuplicate, - allReports: allReportsForDuplicate, - } = useSelectedTransactionsActions({ - report: moneyRequestReport, - reportActions, - allTransactionsLength: transactions.length, - session, - onExportFailed: showDownloadErrorModal, - onExportOffline: showOfflineModal, - policy, - beginExportWithTemplate: (templateName, templateType, transactionIDList, policyID) => beginExportWithTemplate(templateName, templateType, transactionIDList, policyID), - isOnSearch, - }); - - const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); - const onlyShowPayElsewhere = useMemo(() => { - return !canIOUBePaid && getCanIOUBePaid(true); - }, [canIOUBePaid, getCanIOUBePaid]); - - const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; - - const shouldShowApproveButton = useMemo( - () => (canApproveIOU(moneyRequestReport, policy, reportMetadata, transactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning, - [moneyRequestReport, policy, reportMetadata, transactions, hasOnlyPendingTransactions, isApprovedAnimationRunning], - ); - - const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); - - const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyRequestReport?.reportID); - const {isDelegateAccessRestricted} = useDelegateNoAccessState(); - const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {isAccountLocked} = useLockedAccountState(); - const {showLockedAccountModal} = useLockedAccountActions(); - const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); - const kycWallRef = useContext(KYCWallContext); - const [betas] = useOnyx(ONYXKEYS.BETAS); const isReportInRHP = route.name !== SCREENS.REPORT; const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; - const isReportSubmitter = isCurrentUserSubmitter(chatIOUReport); - const isChatReportDM = isDM(chatReport); - - const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); - const isSelectionModePaymentRef = useRef(false); - const confirmPayment = useCallback( - ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { - if (!type || !chatReport) { - return; - } - const isFromSelectionMode = isSelectionModePaymentRef.current; - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - } else if (isAnyTransactionOnHold) { - openHoldMenu({ - requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, - paymentType: type, - methodID, - onConfirm: () => { - if (isFromSelectionMode) { - clearSelectedTransactions(true); - return; - } - startAnimation(); - }, - }).then(() => { - isSelectionModePaymentRef.current = false; - }); - } else if (isInvoiceReport) { - if (!isFromSelectionMode) { - startAnimation(); - } - payInvoice({ - paymentMethodType: type, - chatReport, - invoiceReport: moneyRequestReport, - invoiceReportCurrentNextStepDeprecated: nextStep, - introSelected, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - payAsBusiness, - existingB2BInvoiceReport, - methodID, - paymentMethod, - activePolicy, - betas, - isSelfTourViewed, - }); - if (isFromSelectionMode) { - clearSelectedTransactions(true); - } - } else { - if (!isFromSelectionMode) { - startAnimation(); - } - payMoneyRequest({ - paymentType: type, - chatReport, - iouReport: moneyRequestReport, - introSelected, - iouReportCurrentNextStepDeprecated: nextStep, - currentUserAccountID: accountID, - activePolicy, - policy, - betas, - isSelfTourViewed, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - onPaid: () => { - if (isFromSelectionMode) { - return; - } - startAnimation(); - }, - }); - if (currentSearchQueryJSON && !isOffline) { - search({ - searchKey: currentSearchKey, - shouldCalculateTotals, - offset: 0, - queryJSON: currentSearchQueryJSON, - isOffline, - isLoading: !!currentSearchResults?.search?.isLoading, - }); - } - if (isFromSelectionMode) { - clearSelectedTransactions(true); - } - } - }, - [ - chatReport, - isDelegateAccessRestricted, - isAnyTransactionOnHold, - isInvoiceReport, - showDelegateNoAccessModal, - openHoldMenu, - startAnimation, - moneyRequestReport, - nextStep, - introSelected, - accountID, - email, - existingB2BInvoiceReport, - activePolicy, - policy, - currentSearchQueryJSON, - isOffline, - currentSearchKey, - shouldCalculateTotals, - currentSearchResults?.search?.isLoading, - betas, - isSelfTourViewed, - userBillingGracePeriodEnds, - clearSelectedTransactions, - amountOwed, - ownerBillingGracePeriodEnd, - ], - ); - - useEffect(() => { - if (selectedTransactionIDs.length !== 0) { - return; - } - isSelectionModePaymentRef.current = false; - }, [selectedTransactionIDs.length]); - - const confirmApproval = useCallback( - (skipAnimation = false) => { - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - } else if (isAnyTransactionOnHold) { - openHoldMenu({ - requestType: CONST.IOU.REPORT_ACTION_TYPE.APPROVE, - onConfirm: () => { - if (skipAnimation) { - clearSelectedTransactions(true); - return; - } - startApprovedAnimation(); - }, - }); - } else { - if (!skipAnimation) { - startApprovedAnimation(); - } - approveMoneyRequest({ - expenseReport: moneyRequestReport, - expenseReportPolicy: policy, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - expenseReportCurrentNextStepDeprecated: nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - full: true, - onApproved: () => { - if (skipAnimation) { - return; - } - startApprovedAnimation(); - }, - delegateEmail, - }); - if (skipAnimation) { - clearSelectedTransactions(true); - } - } - }, - [ - policy, - isDelegateAccessRestricted, - showDelegateNoAccessModal, - isAnyTransactionOnHold, - openHoldMenu, - startApprovedAnimation, - moneyRequestReport, - accountID, - email, - hasViolations, - isASAPSubmitBetaEnabled, - nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - clearSelectedTransactions, - ownerBillingGracePeriodEnd, - delegateEmail, - ], - ); - - const handleMarkPendingRTERTransactionsAsCash = useCallback(() => { - markPendingRTERTransactionsAsCash(transactions, allTransactionViolations, reportActions); - }, [transactions, allTransactionViolations, reportActions]); - - const confirmPendingRTERAndProceed = useConfirmPendingRTERAndProceed(hasAnyPendingRTERViolation, handleMarkPendingRTERTransactionsAsCash); - - const handleSubmitReport = useCallback( - (skipAnimation = false) => { - if (!moneyRequestReport || shouldBlockSubmit) { - return; - } - - const doSubmit = () => { - submitReport({ - expenseReport: moneyRequestReport, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - expenseReportCurrentNextStepDeprecated: nextStep, - userBillingGracePeriodEnds, - amountOwed, - onSubmitted: () => { - if (skipAnimation) { - return; - } - startSubmittingAnimation(); - }, - ownerBillingGracePeriodEnd, - delegateEmail, - }); - if (currentSearchQueryJSON && !isOffline) { - search({ - searchKey: currentSearchKey, - shouldCalculateTotals, - offset: 0, - queryJSON: currentSearchQueryJSON, - isOffline, - isLoading: !!currentSearchResults?.search?.isLoading, - }); - } - if (skipAnimation) { - clearSelectedTransactions(true); - } - }; - confirmPendingRTERAndProceed(doSubmit); - }, - [ - moneyRequestReport, - shouldBlockSubmit, - policy, - startSubmittingAnimation, - accountID, - email, - hasViolations, - isASAPSubmitBetaEnabled, - nextStep, - userBillingGracePeriodEnds, - amountOwed, - currentSearchQueryJSON, - isOffline, - currentSearchKey, - shouldCalculateTotals, - currentSearchResults?.search?.isLoading, - clearSelectedTransactions, - confirmPendingRTERAndProceed, - ownerBillingGracePeriodEnd, - delegateEmail, - ], - ); - - const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); - const targetPolicyTags = useMemo( - () => (defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {}), - [defaultExpensePolicy, allPolicyTags], - ); - - const duplicateExpenseTransaction = useCallback( - (transactionList: OnyxTypes.Transaction[]) => { - if (!transactionList.length) { - return; - } - - const optimisticChatReportID = generateReportID(); - const optimisticIOUReportID = generateReportID(); - const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; - - for (const item of transactionList) { - const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); - const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - - duplicateTransactionAction({ - transaction: item, - optimisticChatReportID, - optimisticIOUReportID, - isASAPSubmitBetaEnabled, - introSelected, - activePolicyID, - quickAction, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - isSelfTourViewed, - customUnitPolicyID: policy?.id, - targetPolicy: defaultExpensePolicy ?? undefined, - targetPolicyCategories: activePolicyCategories, - targetReport: activePolicyExpenseChat, - existingTransactionDraft, - draftTransactionIDs, - betas, - personalDetails, - recentWaypoints, - targetPolicyTags, - }); - } - }, - [ - activePolicyExpenseChat, - activePolicyID, - allPolicyCategories, - transactionDrafts, - defaultExpensePolicy, - draftTransactionIDs, - introSelected, - isASAPSubmitBetaEnabled, - quickAction, - policyRecentlyUsedCurrencies, - policy?.id, - isSelfTourViewed, - betas, - personalDetails, - recentWaypoints, - targetPolicyTags, - ], - ); - - const primaryAction = useMemo(() => { - return getReportPrimaryAction({ - currentUserLogin: currentUserLogin ?? '', - currentUserAccountID: accountID, - report: moneyRequestReport, - chatReport, - reportTransactions: nonPendingDeleteTransactions, - violations, - bankAccountList, - policy, - reportNameValuePairs, - reportActions, - reportMetadata, - isChatReportArchived, - invoiceReceiverPolicy, - isPaidAnimationRunning, - isApprovedAnimationRunning, - isSubmittingAnimationRunning, - }); - }, [ - isPaidAnimationRunning, - isApprovedAnimationRunning, - isSubmittingAnimationRunning, - moneyRequestReport, - chatReport, - nonPendingDeleteTransactions, - violations, - policy, - reportNameValuePairs, - reportActions, - reportMetadata, - isChatReportArchived, - invoiceReceiverPolicy, - currentUserLogin, - accountID, - bankAccountList, - ]); - - const getAmount = (actionType: ValueOf) => ({ - formattedAmount: getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, actionType, nonPendingDeleteTransactions), - }); - - const {formattedAmount: totalAmount} = getAmount(CONST.REPORT.PRIMARY_ACTIONS.PAY); - - const paymentButtonOptions = usePaymentOptions({ - currency: moneyRequestReport?.currency, - iouReport: moneyRequestReport, - chatReportID: chatReport?.reportID, - formattedAmount: totalAmount, - policyID: moneyRequestReport?.policyID, - onPress: confirmPayment, - shouldHidePaymentOptions: !shouldShowPayButton, - shouldShowApproveButton, - shouldDisableApproveButton, - onlyShowPayElsewhere, - }); - - const activeAdminPolicies = useActiveAdminPolicies(); - - const workspacePolicyOptions = useMemo(() => { - if (!isIOUReportUtil(moneyRequestReport)) { - return []; - } - - const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); - if (!hasPersonalPaymentOption || !activeAdminPolicies.length) { - return []; - } - - const canUseBusinessBankAccount = moneyRequestReport?.reportID && !hasRequestFromCurrentAccount(moneyRequestReport, accountID ?? CONST.DEFAULT_NUMBER_ID); - if (!canUseBusinessBankAccount) { - return []; - } - - return sortPoliciesByName(activeAdminPolicies, localeCompare); - }, [moneyRequestReport, paymentButtonOptions, activeAdminPolicies, accountID, localeCompare]); - - const buildPaymentSubMenuItems = useCallback( - (onWorkspaceSelected: (workspacePolicy: OnyxTypes.Policy) => void): PopoverMenuItem[] => { - if (!workspacePolicyOptions.length) { - return Object.values(paymentButtonOptions); - } - - const result: PopoverMenuItem[] = []; - for (const opt of Object.values(paymentButtonOptions)) { - result.push(opt); - if (opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - for (const wp of workspacePolicyOptions) { - result.push({ - text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), - icon: expensifyIcons.Building, - onSelected: () => onWorkspaceSelected(wp), - }); - } - } - } - - return result; - }, - [workspacePolicyOptions, paymentButtonOptions, translate, expensifyIcons.Building], - ); - - const addExpenseDropdownOptions = useMemo( - () => - getAddExpenseDropdownOptions({ - translate, - icons: expensifyIcons, - iouReportID: moneyRequestReport?.reportID, - policy, - userBillingGracePeriodEnds, - draftTransactionIDs, - amountOwed, - ownerBillingGracePeriodEnd, - lastDistanceExpenseType, - }), - [moneyRequestReport?.reportID, policy, userBillingGracePeriodEnds, amountOwed, lastDistanceExpenseType, expensifyIcons, translate, ownerBillingGracePeriodEnd, draftTransactionIDs], - ); - - const exportSubmenuOptions: Record> = useMemo(() => { - const options: Record> = { - [CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV]: { - text: translate('export.basicExport'), - icon: expensifyIcons.Table, - value: CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, - onSelected: () => { - if (!moneyRequestReport) { - return; - } - if (isOffline) { - showOfflineModal(); - return; - } - exportReportToCSV( - { - reportID: moneyRequestReport.reportID, - transactionIDList: transactionIDs, - }, - showDownloadErrorModal, - translate, - ); - }, - }, - [CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION]: { - text: translate('workspace.common.exportIntegrationSelected', { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - connectionName: connectedIntegrationFallback!, - }), - icon: (() => { - return getIntegrationIcon(connectedIntegration ?? connectedIntegrationFallback, expensifyIcons); - })(), - displayInDefaultIconColor: true, - additionalIconStyles: styles.integrationIcon, - value: CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, - onSelected: () => { - if (!connectedIntegration || !moneyRequestReport) { - return; - } - if (isExported) { - triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION); - return; - } - exportToIntegration(moneyRequestReport?.reportID, connectedIntegration); - }, - }, - [CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED]: { - text: translate('workspace.common.markAsExported'), - icon: (() => { - return getIntegrationIcon(connectedIntegration ?? connectedIntegrationFallback, expensifyIcons); - })(), - additionalIconStyles: styles.integrationIcon, - displayInDefaultIconColor: true, - value: CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, - onSelected: () => { - if (!connectedIntegration || !moneyRequestReport) { - return; - } - if (isExported) { - triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED); - return; - } - markAsManuallyExported([moneyRequestReport?.reportID ?? CONST.DEFAULT_NUMBER_ID], connectedIntegration); - }, - }, - }; - - for (const template of exportTemplates) { - options[template.name] = { - text: template.name, - icon: expensifyIcons.Table, - value: template.templateName, - description: template.description, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, - onSelected: () => beginExportWithTemplate(template.templateName, template.type, transactionIDs, template.policyID), - }; - } - - return options; - }, [ - translate, - expensifyIcons, - connectedIntegrationFallback, - styles.integrationIcon, - moneyRequestReport, - isOffline, - transactionIDs, - connectedIntegration, - isExported, - exportTemplates, - beginExportWithTemplate, - triggerExportOrConfirm, - showOfflineModal, - showDownloadErrorModal, - ]); - - const primaryActionComponent = ( - triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} - /> - ); - - const secondaryActions = useMemo(() => { - if (!moneyRequestReport) { - return []; - } - return getSecondaryReportActions({ - currentUserLogin: currentUserLogin ?? '', - currentUserAccountID: accountID, - report: moneyRequestReport, - chatReport, - reportTransactions: nonPendingDeleteTransactions, - originalTransaction: originalIOUTransaction, - violations, - bankAccountList, - policy, - reportNameValuePairs, - reportActions, - reportMetadata, - policies, - outstandingReportsByPolicyID, - isChatReportArchived, - }); - }, [ - moneyRequestReport, - currentUserLogin, - accountID, - chatReport, - nonPendingDeleteTransactions, - originalIOUTransaction, - violations, - policy, - reportNameValuePairs, - reportActions, - reportMetadata, - policies, - isChatReportArchived, - bankAccountList, - outstandingReportsByPolicyID, - ]); - - const secondaryExportActions = useMemo(() => { - if (!moneyRequestReport) { - return []; - } - return getSecondaryExportReportActions(accountID, email ?? '', moneyRequestReport, bankAccountList, policy, exportTemplates); - }, [moneyRequestReport, accountID, email, policy, exportTemplates, bankAccountList]); - - const hasSubmitAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT || secondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT); - const hasApproveAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.APPROVE || secondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE); - const hasPayAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.PAY || secondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.PAY); - - const checkForNecessaryAction = useCallback( - (paymentMethodType?: PaymentMethodType) => { - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return true; - } - if (isAccountLocked) { - showLockedAccountModal(); - return true; - } - if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - handleUnvalidatedAccount(moneyRequestReport); - return true; - } - - return false; - }, - [isDelegateAccessRestricted, showDelegateNoAccessModal, isAccountLocked, showLockedAccountModal, isUserValidated, moneyRequestReport], - ); - - const selectionModeReportLevelActions = useMemo(() => { - if (!isBulkSubmitApprovePayBetaEnabled) { - return []; - } - const actions: Array & Pick> = []; - if (hasSubmitAction && !shouldBlockSubmit) { - actions.push({ - text: translate('common.submit'), - icon: expensifyIcons.Send, - value: CONST.REPORT.PRIMARY_ACTIONS.SUBMIT, - onSelected: () => handleSubmitReport(true), - }); - } - if (hasApproveAction && !isBlockSubmitDueToPreventSelfApproval) { - actions.push({ - text: translate('iou.approve'), - icon: expensifyIcons.ThumbsUp, - value: CONST.REPORT.PRIMARY_ACTIONS.APPROVE, - onSelected: () => { - isSelectionModePaymentRef.current = true; - confirmApproval(true); - }, - }); - } - if (hasPayAction && !(isOffline && !canAllowSettlement)) { - actions.push({ - text: translate('iou.settlePayment', totalAmount), - icon: expensifyIcons.Cash, - value: CONST.REPORT.PRIMARY_ACTIONS.PAY, - rightIcon: expensifyIcons.ArrowRight, - backButtonText: translate('iou.settlePayment', totalAmount), - subMenuItems: buildPaymentSubMenuItems((wp) => { - isSelectionModePaymentRef.current = true; - if (checkForNecessaryAction()) { - return; - } - kycWallRef.current?.continueAction?.({policy: wp}); - }), - onSelected: () => { - isSelectionModePaymentRef.current = true; - }, - }); - } - return actions; - }, [ - isBulkSubmitApprovePayBetaEnabled, - hasSubmitAction, - shouldBlockSubmit, - hasApproveAction, - isBlockSubmitDueToPreventSelfApproval, - hasPayAction, - isOffline, - canAllowSettlement, - translate, - handleSubmitReport, - confirmApproval, - totalAmount, - buildPaymentSubMenuItems, - checkForNecessaryAction, - expensifyIcons.ArrowRight, - expensifyIcons.Cash, - expensifyIcons.Send, - expensifyIcons.ThumbsUp, - kycWallRef, - ]); - - const connectedIntegrationName = connectedIntegration - ? translate('workspace.accounting.connectionName', { - connectionName: connectedIntegration, - }) - : ''; - const unapproveWarningText = useMemo( - () => ( - - {translate('iou.headsUp')} {translate('iou.unapproveWithIntegrationWarning', connectedIntegrationName)} - - ), - [connectedIntegrationName, styles.noWrap, styles.textStrong, translate], - ); - - const reopenExportedReportWarningText = ( - - {translate('iou.headsUp')} - - {translate('iou.reopenExportedReportConfirmation', { - connectionName: integrationNameFromExportMessage ?? '', - })} - - - ); - - const secondaryActionsImplementation: Record< - ValueOf, - DropdownOption> & Pick - > = { - [CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS]: { - value: CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS, - text: translate('iou.viewDetails'), - icon: expensifyIcons.Info, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.VIEW_DETAILS, - onSelected: () => { - navigateToDetailsPage(moneyRequestReport, Navigation.getReportRHPActiveRoute()); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.EXPORT]: { - value: CONST.REPORT.SECONDARY_ACTIONS.EXPORT, - text: translate('common.export'), - backButtonText: translate('common.export'), - icon: expensifyIcons.Export, - rightIcon: expensifyIcons.ArrowRight, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT, - subMenuItems: secondaryExportActions.map((action) => exportSubmenuOptions[action as string]), - }, - [CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF]: { - value: CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF, - text: translate('common.downloadAsPDF'), - icon: expensifyIcons.Download, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DOWNLOAD_PDF, - onSelected: () => { - if (!moneyRequestReport?.reportID) { - return; - } - openPDFDownload(); - exportReportToPDF({reportID: moneyRequestReport.reportID}); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.PRINT]: { - value: CONST.REPORT.SECONDARY_ACTIONS.PRINT, - text: translate('common.print'), - icon: expensifyIcons.Printer, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.PRINT, - onSelected: () => { - if (!moneyRequestReport) { - return; - } - openOldDotLink(CONST.OLDDOT_URLS.PRINTABLE_REPORT(moneyRequestReport.reportID)); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.SUBMIT]: { - value: CONST.REPORT.SECONDARY_ACTIONS.SUBMIT, - text: translate('common.submit'), - icon: expensifyIcons.Send, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SUBMIT, - onSelected: () => { - if (!moneyRequestReport) { - return; - } - - confirmPendingRTERAndProceed(() => { - submitReport({ - expenseReport: moneyRequestReport, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - expenseReportCurrentNextStepDeprecated: nextStep, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - delegateEmail, - }); - }); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.APPROVE]: { - text: translate('iou.approve'), - icon: expensifyIcons.ThumbsUp, - value: CONST.REPORT.SECONDARY_ACTIONS.APPROVE, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.APPROVE, - onSelected: confirmApproval, - }, - [CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE]: { - text: translate('iou.unapprove'), - icon: expensifyIcons.CircularArrowBackwards, - value: CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.UNAPPROVE, - onSelected: async () => { - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return; - } - - if (isExported) { - const result = await showConfirmModal({ - title: translate('iou.unapproveReport'), - prompt: unapproveWarningText, - confirmText: translate('iou.unapproveReport'), - cancelText: translate('common.cancel'), - danger: true, - }); - - if (result.action !== ModalActions.CONFIRM) { - return; - } - unapproveExpenseReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail); - return; - } - - unapproveExpenseReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT]: { - text: translate('iou.cancelPayment'), - icon: expensifyIcons.Clear, - value: CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.CANCEL_PAYMENT, - onSelected: async () => { - const result = await showConfirmModal({ - title: translate('iou.cancelPayment'), - prompt: translate('iou.cancelPaymentConfirmation'), - confirmText: translate('iou.cancelPayment'), - cancelText: translate('common.dismiss'), - danger: true, - }); - - if (result.action !== ModalActions.CONFIRM || !chatReport) { - return; - } - cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.HOLD]: { - text: translate('iou.hold'), - icon: expensifyIcons.Stopwatch, - value: CONST.REPORT.SECONDARY_ACTIONS.HOLD, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.HOLD, - onSelected: () => { - if (!requestParentReportAction) { - throw new Error('Parent action does not exist'); - } - - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return; - } - - const isDismissed = isReportSubmitter ? dismissedHoldUseExplanation : dismissedRejectUseExplanation; - - if (isDismissed || isChatReportDM) { - changeMoneyRequestHoldStatus(requestParentReportAction, transaction, isOffline); - } else if (isReportSubmitter) { - openHoldEducational(); - } else { - openRejectModal(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD); - } - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD]: { - text: translate('iou.unhold'), - icon: expensifyIcons.Stopwatch, - value: CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REMOVE_HOLD, - onSelected: () => { - if (!requestParentReportAction) { - throw new Error('Parent action does not exist'); - } + // eslint-disable-next-line no-restricted-syntax -- backTo is a legacy route param, preserving existing behavior + const backTo = (route.params as {backTo?: Route} | undefined)?.backTo; - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return; - } - - changeMoneyRequestHoldStatus(requestParentReportAction, transaction, isOffline); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.SPLIT]: { - text: shouldShowSplitIndicator ? translate('iou.editSplits') : translate('iou.split'), - icon: expensifyIcons.ArrowSplit, - value: CONST.REPORT.SECONDARY_ACTIONS.SPLIT, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SPLIT, - onSelected: () => { - if (Number(transactions?.length) !== 1) { - return; - } - - initSplitExpense(currentTransaction, policy); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.MERGE]: { - text: translate('common.merge'), - icon: expensifyIcons.ArrowCollapse, - value: CONST.REPORT.SECONDARY_ACTIONS.MERGE, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.MERGE, - onSelected: () => { - if (!currentTransaction) { - return; - } - - setupMergeTransactionDataAndNavigate(currentTransaction.transactionID, [currentTransaction], localeCompare, getCurrencyDecimals); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE]: { - text: isDuplicateActive ? translate('common.duplicateExpense') : translate('common.duplicated'), - icon: isDuplicateActive ? expensifyIcons.ExpenseCopy : expensifyIcons.Checkmark, - iconFill: isDuplicateActive ? undefined : theme.icon, - value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE, - onSelected: () => { - if (hasCustomUnitOutOfPolicyViolation) { - showConfirmModal({ - title: translate('common.duplicateExpense'), - prompt: translate('iou.correctRateError'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - - if (isDistanceExpenseUnsupportedForDuplicating) { - showConfirmModal({ - title: translate('common.duplicateExpense'), - prompt: translate('iou.cannotDuplicateDistanceExpense'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - - if (isPerDiemRequestOnNonDefaultWorkspace) { - showConfirmModal({ - title: translate('common.duplicateExpense'), - prompt: translate('iou.duplicateNonDefaultWorkspacePerDiemError'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - - if (!isDuplicateActive || !transaction) { - return; - } - - temporarilyDisableDuplicateAction(); - - duplicateExpenseTransaction([transaction]); - }, - shouldCloseModalOnSelect: shouldDuplicateCloseModalOnSelect, - }, - [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT]: { - text: isDuplicateReportActive ? translate('common.duplicateReport') : translate('common.duplicated'), - icon: isDuplicateReportActive ? expensifyIcons.ReportCopy : expensifyIcons.Checkmark, - iconFill: isDuplicateReportActive ? undefined : theme.icon, - value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DUPLICATE_REPORT, - shouldShow: !!defaultExpensePolicy, - shouldCloseModalOnSelect: false, - onSelected: () => { - if (!isDuplicateReportActive) { - return; - } - - temporarilyDisableDuplicateReportAction(); - wasDuplicateReportTriggered.current = true; - - const isSourcePolicyValid = !!policy && isPolicyAccessible(policy, currentUserLogin ?? ''); - const targetPolicyForDuplicate = isSourcePolicyValid ? policy : defaultExpensePolicy; - const targetChatForDuplicate = isSourcePolicyValid ? chatReport : activePolicyExpenseChat; - const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicyForDuplicate?.id}`] ?? {}; - - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - duplicateReportAction({ - sourceReport: moneyRequestReport, - sourceReportTransactions: nonPendingDeleteTransactions, - sourceReportName: moneyRequestReport?.reportName ?? '', - targetPolicy: targetPolicyForDuplicate ?? undefined, - targetPolicyCategories: activePolicyCategories, - targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyForDuplicate?.id}`] ?? {}, - parentChatReport: targetChatForDuplicate, - ownerPersonalDetails: currentUserPersonalDetails, - isASAPSubmitBetaEnabled, - betas, - personalDetails, - quickAction, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - draftTransactionIDs, - isSelfTourViewed, - transactionViolations: allTransactionViolations, - translate, - recentWaypoints: recentWaypoints ?? [], - }); - }); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE]: { - text: translate('iou.changeWorkspace'), - icon: expensifyIcons.Buildings, - value: CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.CHANGE_WORKSPACE, - shouldShow: transactions.length === 0 || nonPendingDeleteTransactions.length > 0, - onSelected: () => { - if (!moneyRequestReport) { - return; - } - Navigation.navigate(ROUTES.REPORT_WITH_ID_CHANGE_WORKSPACE.getRoute(moneyRequestReport.reportID, Navigation.getActiveRoute())); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE]: { - text: translate('iou.moveExpenses'), - icon: expensifyIcons.DocumentMerge, - value: CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.MOVE_EXPENSE, - shouldShow: canMoveSingleExpense, - onSelected: () => { - if (!moneyRequestReport || nonPendingDeleteTransactions.length !== 1) { - return; - } - const transactionToMove = nonPendingDeleteTransactions.at(0); - if (!transactionToMove?.transactionID) { - return; - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_EDIT_REPORT.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.SUBMIT, - moneyRequestReport.reportID, - true, - Navigation.getActiveRoute(), - transactionToMove.transactionID, - ), - ); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.CHANGE_APPROVER]: { - text: translate('iou.changeApprover.title'), - icon: expensifyIcons.Workflows, - value: CONST.REPORT.SECONDARY_ACTIONS.CHANGE_APPROVER, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.CHANGE_APPROVER, - onSelected: () => { - if (!moneyRequestReport) { - Log.warn('Change approver secondary action triggered without moneyRequestReport data.'); - return; - } - Navigation.navigate(ROUTES.REPORT_CHANGE_APPROVER.getRoute(moneyRequestReport.reportID, Navigation.getActiveRoute())); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.DELETE]: { - text: translate('common.delete'), - icon: expensifyIcons.Trashcan, - value: CONST.REPORT.SECONDARY_ACTIONS.DELETE, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DELETE, - onSelected: async () => { - const transactionCount = Object.keys(transactions).length; - - if (transactionCount === 1) { - const result = await showConfirmModal({ - title: translate('iou.deleteExpense', {count: 1}), - prompt: translate('iou.deleteConfirmation', {count: 1}), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - danger: true, - }); - - if (result.action !== ModalActions.CONFIRM) { - return; - } - if (transactionThreadReportID) { - if (!requestParentReportAction || !transaction?.transactionID) { - throw new Error('Missing data!'); - } - const goBackRoute = getNavigationUrlOnMoneyRequestDelete( - transaction.transactionID, - requestParentReportAction, - iouReport, - chatIOUReport, - isChatIOUReportArchived, - false, - ); - const deleteNavigateBackUrl = goBackRoute ?? route.params?.backTo ?? Navigation.getActiveRoute(); - setDeleteTransactionNavigateBackUrl(deleteNavigateBackUrl); - if (goBackRoute) { - navigateOnDeleteExpense(goBackRoute); - } - // it's deleting transaction but not the report which leads to bug (that is actually also on staging) - // Money request should be deleted when interactions are done, to not show the not found page before navigating to goBackRoute - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - deleteTransactions([transaction.transactionID], duplicateTransactions, duplicateTransactionViolations, isReportInSearch ? currentSearchHash : undefined, false); - removeTransaction(transaction.transactionID); - }); - } - return; - } - - const result = await showConfirmModal({ - title: translate('iou.deleteReport', {count: 1}), - prompt: translate('iou.deleteReportConfirmation', {count: 1}), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - danger: true, - }); - if (result.action !== ModalActions.CONFIRM) { - return; - } - const backToRoute = route.params?.backTo ?? (chatReport?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID) : undefined); - const deleteNavigateBackUrl = backToRoute ?? Navigation.getActiveRoute(); - setDeleteTransactionNavigateBackUrl(deleteNavigateBackUrl); - - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.goBack(backToRoute); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - deleteAppReport({ - report: moneyRequestReport, - selfDMReport, - currentUserEmailParam: email ?? '', - currentUserAccountIDParam: accountID, - reportTransactions, - allTransactionViolations, - bankAccountList, - hash: currentSearchHash, - }); - }); - }); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { - text: translate('iou.retract'), - icon: expensifyIcons.CircularArrowBackwards, - value: CONST.REPORT.SECONDARY_ACTIONS.RETRACT, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.RETRACT, - onSelected: async () => { - if (isExported) { - const result = await showConfirmModal({ - title: translate('iou.reopenReport'), - prompt: reopenExportedReportWarningText, - confirmText: translate('iou.reopenReport'), - cancelText: translate('common.cancel'), - danger: true, - }); - - if (result.action !== ModalActions.CONFIRM) { - return; - } - retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail); - return; - } - retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.REOPEN]: { - text: translate('iou.retract'), - icon: expensifyIcons.CircularArrowBackwards, - value: CONST.REPORT.SECONDARY_ACTIONS.REOPEN, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REOPEN, - onSelected: async () => { - if (isExported) { - const result = await showConfirmModal({ - title: translate('iou.reopenReport'), - prompt: reopenExportedReportWarningText, - confirmText: translate('iou.reopenReport'), - cancelText: translate('common.cancel'), - danger: true, - }); - - if (result.action !== ModalActions.CONFIRM) { - return; - } - reopenReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, chatReport); - return; - } - reopenReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, chatReport); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.REJECT]: { - text: translate('common.reject'), - icon: expensifyIcons.ThumbsDown, - value: CONST.REPORT.SECONDARY_ACTIONS.REJECT, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REJECT, - onSelected: () => { - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return; - } - - if (moneyRequestReport?.reportID) { - Navigation.navigate(ROUTES.REJECT_EXPENSE_REPORT.getRoute(moneyRequestReport.reportID)); - } - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE]: { - text: translate('iou.addExpense'), - backButtonText: translate('iou.addExpense'), - icon: expensifyIcons.Plus, - rightIcon: expensifyIcons.ArrowRight, - value: CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE, - subMenuItems: addExpenseDropdownOptions, - onSelected: () => { - if (!moneyRequestReport?.reportID) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, policy)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, moneyRequestReport?.reportID, draftTransactionIDs); - }, - }, - [CONST.REPORT.SECONDARY_ACTIONS.PAY]: { - text: translate('iou.settlePayment', totalAmount), - icon: expensifyIcons.Cash, - rightIcon: expensifyIcons.ArrowRight, - value: CONST.REPORT.SECONDARY_ACTIONS.PAY, - backButtonText: translate('iou.settlePayment', totalAmount), - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.PAY, - subMenuItems: buildPaymentSubMenuItems((wp) => { - kycWallRef.current?.continueAction?.({policy: wp}); - }), - }, - }; - const applicableSecondaryActions = secondaryActions - .map((action) => secondaryActionsImplementation[action]) - .filter((action) => action?.shouldShow !== false && action?.value !== primaryAction); - useEffect(() => { - if (!transactionThreadReportID) { - return; - } - clearSelectedTransactions(true); - // We don't need to run the effect on change of clearSelectedTransactions since it can cause the infinite loop. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [transactionThreadReportID]); + const primaryAction = useReportPrimaryAction(reportIDProp); const shouldShowBackButton = shouldDisplayBackButton || shouldUseNarrowLayout; @@ -1810,191 +97,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt }; }, []); - const showDeleteModal = useCallback(() => { - showConfirmModal({ - title: translate('iou.deleteExpense', { - count: selectedTransactionIDs.length, - }), - prompt: translate('iou.deleteConfirmation', { - count: selectedTransactionIDs.length, - }), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - danger: true, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - if (transactions.filter((trans) => trans.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length === selectedTransactionIDs.length) { - const backToRoute = route.params?.backTo ?? (chatReport?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID) : undefined); - handleDeleteTransactionsWithNavigation(backToRoute); - } else { - handleDeleteTransactions(); - } - }); - }, [ - showConfirmModal, - translate, - selectedTransactionIDs.length, - transactions, - handleDeleteTransactions, - handleDeleteTransactionsWithNavigation, - route.params?.backTo, - chatReport?.reportID, - ]); - - const allExpensesSelected = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === nonPendingDeleteTransactions.length; - - const selectedTransactionsOptions = useMemo(() => { - const mappedOptions = originalSelectedTransactionsOptions.map((option) => { - if (option.value === CONST.REPORT.SECONDARY_ACTIONS.DELETE) { - return { - ...option, - onSelected: showDeleteModal, - }; - } - if (option.value === CONST.REPORT.SECONDARY_ACTIONS.REJECT) { - return { - ...option, - onSelected: () => { - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return; - } - if (dismissedRejectUseExplanation) { - option.onSelected?.(); - } else { - openRejectModal(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK); - } - }, - }; - } - return option; - }); - - if (allExpensesSelected && selectionModeReportLevelActions.length) { - return [...selectionModeReportLevelActions, ...mappedOptions]; - } - return mappedOptions; - }, [ - originalSelectedTransactionsOptions, - showDeleteModal, - dismissedRejectUseExplanation, - allExpensesSelected, - selectionModeReportLevelActions, - isDelegateAccessRestricted, - showDelegateNoAccessModal, - openRejectModal, - ]); - - const shouldShowSelectedTransactionsButton = !!selectedTransactionsOptions.length && !transactionThreadReportID; - const popoverUseScrollView = shouldPopoverUseScrollView(selectedTransactionsOptions); - - const hasActualPaymentOptions = paymentButtonOptions.some((opt) => Object.values(CONST.IOU.PAYMENT_TYPE).some((type) => type === opt.value)); - const hasPayInSelectionMode = allExpensesSelected && hasPayAction && hasActualPaymentOptions; - - const makePaymentSelectHandler = useCallback( - (fromSelectionMode: boolean) => (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { - if (fromSelectionMode) { - isSelectionModePaymentRef.current = true; - if (checkForNecessaryAction(iouPaymentType)) { - return; - } - } - selectPaymentType({ - event, - iouPaymentType, - triggerKYCFlow, - expenseReportPolicy: policy, - policy, - onPress: confirmPayment, - currentAccountID: accountID, - currentEmail: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - isUserValidated, - confirmApproval: () => confirmApproval(), - iouReport: moneyRequestReport, - iouReportNextStep: nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - delegateEmail, - }); - }, - [ - checkForNecessaryAction, - policy, - confirmPayment, - accountID, - email, - hasViolations, - isASAPSubmitBetaEnabled, - isUserValidated, - confirmApproval, - moneyRequestReport, - nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - delegateEmail, - ], - ); - - const onSelectionModePaymentSelect = useMemo(() => makePaymentSelectHandler(true), [makePaymentSelectHandler]); - - const onPaymentSelect = useMemo(() => makePaymentSelectHandler(false), [makePaymentSelectHandler]); - - const selectionModeKYCSuccess = useCallback( - (type?: PaymentMethodType) => { - isSelectionModePaymentRef.current = true; - confirmPayment({paymentType: type}); - }, - [confirmPayment], - ); - - const renderSelectionModeDropdown = useCallback( - (wrapperStyle?: StyleProp) => - hasPayInSelectionMode ? ( - - ) : ( - null} - options={selectedTransactionsOptions} - customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} - isSplitButton={false} - shouldAlwaysShowDropdownMenu - shouldPopoverUseScrollView={popoverUseScrollView} - wrapperStyle={wrapperStyle} - /> - ), - [ - hasPayInSelectionMode, - chatReport?.reportID, - moneyRequestReport, - onSelectionModePaymentSelect, - selectionModeKYCSuccess, - primaryAction, - selectedTransactionsOptions, - translate, - selectedTransactionIDs.length, - kycWallRef, - popoverUseScrollView, - ], - ); - if (isMobileSelectionModeEnabled && shouldUseNarrowLayout) { // If mobile selection mode is enabled but only one or no transactions remain, turn it off const visibleTransactions = transactions.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); @@ -2014,79 +116,41 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt } return ( - <> - {isDuplicateOptionVisible && ( - clearSelectedTransactions(true)} + + + {shouldDisplayNarrowMoreButton && ( + + )} + + {!shouldDisplayNarrowMoreButton && ( + )} - - - {shouldDisplayNarrowMoreButton && ( - - {!shouldShowSelectedTransactionsButton && primaryActionComponent} - {!!applicableSecondaryActions.length && !shouldShowSelectedTransactionsButton && ( - confirmPayment({paymentType: type})} - primaryAction={primaryAction} - applicableSecondaryActions={applicableSecondaryActions} - dropdownMenuRef={dropdownMenuRef} - onOptionsMenuHide={handleOptionsMenuHide} - ref={kycWallRef} - /> - )} - {shouldShowSelectedTransactionsButton && {renderSelectionModeDropdown()}} - - )} - - {!shouldDisplayNarrowMoreButton && - (shouldShowSelectedTransactionsButton ? ( - {renderSelectionModeDropdown(styles.w100)} - ) : ( - - {!!primaryAction && {primaryActionComponent}} - {!!applicableSecondaryActions.length && ( - confirmPayment({paymentType: type})} - primaryAction={primaryAction} - applicableSecondaryActions={applicableSecondaryActions} - dropdownMenuRef={dropdownMenuRef} - onOptionsMenuHide={handleOptionsMenuHide} - ref={kycWallRef} - /> - )} - - ))} - - - - - - + + + ); } diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx new file mode 100644 index 000000000000..f21263996f56 --- /dev/null +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -0,0 +1,397 @@ +import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import truncate from 'lodash/truncate'; +import React, {useContext, useEffect} from 'react'; +import {InteractionManager} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; +import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; +import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; +import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {useSearchStateContext} from '@components/Search/SearchContext'; +import type {PaymentActionParams} from '@components/SettlementButton/types'; +import useActiveAdminPolicies from '@hooks/useActiveAdminPolicies'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useExpenseActions from '@hooks/useExpenseActions'; +import useExportActions from '@hooks/useExportActions'; +import useHoldRejectActions from '@hooks/useHoldRejectActions'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLifecycleActions from '@hooks/useLifecycleActions'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; +import usePaymentOptions from '@hooks/usePaymentOptions'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import {search} from '@libs/actions/Search'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import getPlatform from '@libs/getPlatform'; +import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; +import {selectPaymentType} from '@libs/PaymentUtils'; +import {sortPoliciesByName} from '@libs/PolicyUtils'; +import {getFilteredReportActionsForReportView, hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; +import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; +import { + hasHeldExpenses as hasHeldExpensesReportUtils, + hasViolations as hasViolationsReportUtils, + isAllowedToApproveExpenseReport, + isInvoiceReport as isInvoiceReportUtil, + isIOUReport as isIOUReportUtil, + navigateToDetailsPage, +} from '@libs/ReportUtils'; +import {isExpensifyCardTransaction, isPending} from '@libs/TransactionUtils'; +import {payInvoice, payMoneyRequest} from '@userActions/IOU/PayMoneyRequest'; +import {canApproveIOU, canIOUBePaid as canIOUBePaidAction} from '@userActions/IOU/ReportWorkflow'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; + +type MoneyReportHeaderSecondaryActionsProps = { + reportID: string | undefined; + primaryAction: ValueOf | ''; + isReportInSearch?: boolean; + backTo?: Route; + dropdownMenuRef?: React.RefObject; +}; + +function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { + const {isPaidAnimationRunning, isApprovedAnimationRunning, startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); + const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); + + const {translate, localeCompare} = useLocalize(); + const kycWallRef = useContext(KYCWallContext); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${moneyRequestReport?.reportID}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${moneyRequestReport?.reportID}`); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${moneyRequestReport?.reportID}`); + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); + const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [invoiceReceiverPolicy] = useOnyx( + `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, + {}, + ); + + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {login: currentUserLogin, accountID, email} = currentUserPersonalDetails; + + const {isOffline} = useNetwork(); + const activePolicy = usePolicy(activePolicyID); + + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + + const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + const nonPendingDeleteTransactions = Object.values(reportTransactions).filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const allTransactions = Object.values(reportTransactions); + const singleTransaction = nonPendingDeleteTransactions.length === 1 ? nonPendingDeleteTransactions.at(0) : undefined; + const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(singleTransaction?.comment?.originalTransactionID)}`); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const isChatReportArchived = useReportIsArchived(chatReport?.reportID); + + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {currentSearchQueryJSON, currentSearchKey, currentSearchResults} = useSearchStateContext(); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + + const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); + const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyRequestReport?.reportID); + const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); + + const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { + if (!type || !chatReport) { + return; + } + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + } else if (isAnyTransactionOnHold) { + const holdMenuParams = { + requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: type, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + onConfirm: () => startAnimation(), + }; + if (getPlatform() === CONST.PLATFORM.IOS) { + // InteractionManager delays modal until current interaction completes, preventing visual glitches on iOS + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => openHoldMenu(holdMenuParams)); + } else { + openHoldMenu(holdMenuParams); + } + } else if (isInvoiceReport) { + startAnimation(); + payInvoice({ + paymentMethodType: type, + chatReport, + invoiceReport: moneyRequestReport, + invoiceReportCurrentNextStepDeprecated: nextStep, + introSelected, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + payAsBusiness, + existingB2BInvoiceReport, + methodID, + paymentMethod, + activePolicy, + betas, + isSelfTourViewed, + }); + } else { + startAnimation(); + payMoneyRequest({ + paymentType: type, + chatReport, + iouReport: moneyRequestReport, + introSelected, + iouReportCurrentNextStepDeprecated: nextStep, + currentUserAccountID: accountID, + activePolicy, + policy, + betas, + isSelfTourViewed, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + onPaid: () => { + startAnimation(); + }, + }); + if (currentSearchQueryJSON && !isOffline) { + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } + } + }; + + // Payment button derivations + const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, false, undefined, invoiceReceiverPolicy); + const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, true, undefined, invoiceReceiverPolicy); + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + const hasOnlyPendingTransactions = allTransactions.length > 0 && allTransactions.every((t) => isExpensifyCardTransaction(t) && isPending(t)); + const shouldShowApproveButton = (canApproveIOU(moneyRequestReport, policy, reportMetadata, allTransactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning; + const isApproveDisabled = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); + + const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions); + + const paymentButtonOptions = usePaymentOptions({ + currency: moneyRequestReport?.currency, + iouReport: moneyRequestReport, + chatReportID: chatReport?.reportID, + formattedAmount: totalAmount, + policyID: moneyRequestReport?.policyID, + onPress: confirmPayment, + shouldHidePaymentOptions: !shouldShowPayButton, + shouldShowApproveButton, + shouldDisableApproveButton: isApproveDisabled, + onlyShowPayElsewhere, + }); + + const activeAdminPolicies = useActiveAdminPolicies(); + + const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); + const canUseBusinessBankAccount = !!moneyRequestReport?.reportID && !hasRequestFromCurrentAccount(moneyRequestReport, accountID ?? CONST.DEFAULT_NUMBER_ID); + const workspacePolicyOptions = + isIOUReportUtil(moneyRequestReport) && hasPersonalPaymentOption && activeAdminPolicies.length && canUseBusinessBankAccount + ? sortPoliciesByName(activeAdminPolicies, localeCompare) + : []; + + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Cash', 'ArrowRight', 'Building']); + + const buildPaymentSubMenuItems = (onWorkspaceSelected: (workspacePolicy: OnyxTypes.Policy) => void): PopoverMenuItem[] => { + if (!workspacePolicyOptions.length) { + return Object.values(paymentButtonOptions); + } + const result: PopoverMenuItem[] = []; + for (const opt of Object.values(paymentButtonOptions)) { + result.push(opt); + if (opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + for (const wp of workspacePolicyOptions) { + result.push({ + text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), + icon: expensifyIcons.Building, + onSelected: () => onWorkspaceSelected(wp), + }); + } + } + } + return result; + }; + + // Domain hooks + const lifecycleActions = useLifecycleActions({ + reportID, + startApprovedAnimation, + startSubmittingAnimation, + onHoldMenuOpen: (requestType, onConfirm) => { + openHoldMenu({requestType, onConfirm: onConfirm ?? (() => startApprovedAnimation())}); + }, + }); + + const { + actions: expenseActions, + handleOptionsMenuHide, + isDuplicateReportActive, + wasDuplicateReportTriggeredRef, + } = useExpenseActions({ + reportID, + isReportInSearch, + backTo, + onDuplicateReset: () => dropdownMenuRef?.current?.setIsMenuVisible(false), + }); + + useEffect(() => { + if (!isDuplicateReportActive || !wasDuplicateReportTriggeredRef.current) { + return; + } + wasDuplicateReportTriggeredRef.current = false; + dropdownMenuRef?.current?.setIsMenuVisible(false); + }, [isDuplicateReportActive, wasDuplicateReportTriggeredRef, dropdownMenuRef]); + + const holdRejectActions = useHoldRejectActions({ + reportID, + onHoldEducationalOpen: openHoldEducational, + onRejectModalOpen: openRejectModal, + }); + + const {exportActionEntries} = useExportActions({ + reportID, + policy, + onPDFModalOpen: openPDFDownload, + }); + + // Compute list of applicable secondary action keys + const secondaryActions = moneyRequestReport + ? getSecondaryReportActions({ + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID: accountID, + report: moneyRequestReport, + chatReport, + reportTransactions: nonPendingDeleteTransactions, + originalTransaction, + violations, + bankAccountList, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, + policies, + outstandingReportsByPolicyID, + isChatReportArchived, + }) + : []; + + // Merge all action implementations + const secondaryActionsImplementation: Record = { + [CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS]: { + value: CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS, + text: translate('iou.viewDetails'), + icon: expensifyIcons.Info, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.VIEW_DETAILS, + onSelected: () => { + navigateToDetailsPage(moneyRequestReport, Navigation.getReportRHPActiveRoute()); + }, + }, + ...exportActionEntries, + ...lifecycleActions.actions, + ...expenseActions, + ...holdRejectActions, + [CONST.REPORT.SECONDARY_ACTIONS.PAY]: { + text: translate('iou.settlePayment', totalAmount), + icon: expensifyIcons.Cash, + rightIcon: expensifyIcons.ArrowRight, + value: CONST.REPORT.SECONDARY_ACTIONS.PAY, + backButtonText: translate('iou.settlePayment', totalAmount), + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.PAY, + // eslint-disable-next-line react-hooks/refs -- ref is only accessed inside the callback (event handler), not during render + subMenuItems: buildPaymentSubMenuItems((wp) => { + kycWallRef.current?.continueAction?.({policy: wp}); + }), + }, + }; + + const applicableSecondaryActions = secondaryActions + .map((action) => secondaryActionsImplementation[action]) + .filter((action) => action?.shouldShow !== false && action?.value !== primaryAction); + + const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); + + const onPaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { + selectPaymentType({ + event, + iouPaymentType, + triggerKYCFlow, + expenseReportPolicy: policy, + policy, + onPress: confirmPayment, + currentAccountID: accountID, + currentEmail: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + isUserValidated, + confirmApproval: () => lifecycleActions.confirmApproval(), + iouReport: moneyRequestReport, + iouReportNextStep: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + delegateEmail, + }); + }; + + if (!applicableSecondaryActions.length) { + return null; + } + + return ( + confirmPayment({paymentType: type})} + primaryAction={primaryAction} + applicableSecondaryActions={applicableSecondaryActions} + dropdownMenuRef={dropdownMenuRef} + onOptionsMenuHide={handleOptionsMenuHide} + ref={kycWallRef} + /> + ); +} + +export default MoneyReportHeaderSecondaryActions; +export type {MoneyReportHeaderSecondaryActionsProps}; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx new file mode 100644 index 000000000000..428f6c546c98 --- /dev/null +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -0,0 +1,532 @@ +import {useRoute} from '@react-navigation/native'; +import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import truncate from 'lodash/truncate'; +import React, {useContext, useEffect, useRef} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; +import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; +import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; +import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import type {PaymentActionParams} from '@components/SettlementButton/types'; +import useActiveAdminPolicies from '@hooks/useActiveAdminPolicies'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useExportActions from '@hooks/useExportActions'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLifecycleActions from '@hooks/useLifecycleActions'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; +import usePaymentOptions from '@hooks/usePaymentOptions'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; +import {search} from '@libs/actions/Search'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; +import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; +import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; +import {sortPoliciesByName} from '@libs/PolicyUtils'; +import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; +import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; +import {hasHeldExpenses, hasUpdatedTotal, hasViolations as hasViolationsReportUtils, isInvoiceReport as isInvoiceReportUtil, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; +import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; +import {isTransactionPendingDelete} from '@libs/TransactionUtils'; +import {payInvoice, payMoneyRequest} from '@userActions/IOU/PayMoneyRequest'; +import {canIOUBePaid as canIOUBePaidAction} from '@userActions/IOU/ReportWorkflow'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; + +const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight', 'Building'] as const; + +type MoneyReportHeaderSelectionDropdownProps = { + reportID: string | undefined; + primaryAction: ValueOf | ''; + isReportInSearch?: boolean; + wrapperStyle?: StyleProp; +}; + +function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportInSearch, wrapperStyle}: MoneyReportHeaderSelectionDropdownProps) { + const route = useRoute(); + const {startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); + const {openHoldMenu: openHoldMenuAsync, openRejectModal} = useMoneyReportHeaderModals(); + const openHoldMenu = (params: Parameters[0]) => { + openHoldMenuAsync(params); + }; + const {translate, localeCompare} = useLocalize(); + const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isBulkSubmitApprovePayBetaEnabled = isBetaEnabled(CONST.BETAS.BULK_SUBMIT_APPROVE_PAY); + const activeAdminPolicies = useActiveAdminPolicies(); + + const {selectedTransactionIDs, currentSearchQueryJSON, currentSearchKey, currentSearchResults} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); + const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); + const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const [invoiceReceiverPolicy] = useOnyx( + `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, + ); + + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const activePolicy = usePolicy(activePolicyID); + const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); + const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); + + const isChatReportArchived = useReportIsArchived(chatReport?.reportID); + const isAnyTransactionOnHold = hasHeldExpenses(moneyRequestReport?.reportID); + + const {transactionThreadReportID, reportActions} = useTransactionThreadReport(reportID); + + const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + + const allTransactionValues = Object.values(reportTransactions); + const transactions = allTransactionValues; + const nonPendingDeleteTransactions = allTransactionValues.filter((t) => !isTransactionPendingDelete(t)); + const singleTransaction = nonPendingDeleteTransactions.length === 1 ? nonPendingDeleteTransactions.at(0) : undefined; + const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(singleTransaction?.comment?.originalTransactionID)}`); + + const {accountID, email, login: currentUserLogin} = useCurrentUserPersonalDetails(); + const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); + + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); + const kycWallRef = useContext(KYCWallContext); + + const {showConfirmModal} = useConfirmModal(); + + const isSelectionModePaymentRef = useRef(false); + + useEffect(() => { + if (selectedTransactionIDs.length !== 0) { + return; + } + isSelectionModePaymentRef.current = false; + }, [selectedTransactionIDs.length]); + + const expensifyIcons = useMemoizedLazyExpensifyIcons(PAYMENT_ICONS); + + const {beginExportWithTemplate, showOfflineModal, showDownloadErrorModal} = useExportActions({ + reportID, + policy, + }); + + const {confirmApproval, handleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval} = useLifecycleActions({ + reportID, + startApprovedAnimation, + startSubmittingAnimation, + onHoldMenuOpen: (requestType) => openHoldMenu({requestType, onConfirm: () => clearSelectedTransactions(true)}), + }); + + const { + options: originalSelectedTransactionsOptions, + handleDeleteTransactions, + handleDeleteTransactionsWithNavigation, + isDuplicateOptionVisible, + setDuplicateHandler, + allTransactions: allTransactionsForDuplicate, + allReports: allReportsForDuplicate, + } = useSelectedTransactionsActions({ + report: moneyRequestReport, + reportActions, + allTransactionsLength: transactions.length, + session, + onExportFailed: showDownloadErrorModal, + onExportOffline: showOfflineModal, + policy, + beginExportWithTemplate, + isOnSearch: !!isReportInSearch, + }); + + const computedSecondaryActions = moneyRequestReport + ? getSecondaryReportActions({ + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID: accountID, + report: moneyRequestReport, + chatReport, + reportTransactions: nonPendingDeleteTransactions, + originalTransaction, + violations, + bankAccountList, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, + policies: allPolicies, + outstandingReportsByPolicyID, + isChatReportArchived, + }) + : []; + + const hasSubmitAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT || computedSecondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT); + const hasApproveAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.APPROVE || computedSecondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE); + const hasPayAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.PAY || computedSecondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.PAY); + + const checkForNecessaryAction = (paymentMethodType?: PaymentMethodType) => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return true; + } + if (isAccountLocked) { + showLockedAccountModal(); + return true; + } + if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + handleUnvalidatedAccount(moneyRequestReport); + return true; + } + return false; + }; + + const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); + const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions); + const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, false, undefined, invoiceReceiverPolicy); + const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, true, undefined, invoiceReceiverPolicy); + const isPayable = hasPayAction && canIOUBePaid; + + const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { + if (!type || !chatReport) { + return; + } + isSelectionModePaymentRef.current = true; + + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + + if (isAnyTransactionOnHold) { + openHoldMenu({ + requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: type, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + onConfirm: () => clearSelectedTransactions(true), + }); + return; + } + + if (isInvoiceReport) { + payInvoice({ + paymentMethodType: type, + chatReport, + invoiceReport: moneyRequestReport, + invoiceReportCurrentNextStepDeprecated: nextStep, + introSelected, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + payAsBusiness, + existingB2BInvoiceReport, + methodID, + paymentMethod, + activePolicy, + betas, + isSelfTourViewed, + }); + } else { + payMoneyRequest({ + paymentType: type, + chatReport, + iouReport: moneyRequestReport, + introSelected, + iouReportCurrentNextStepDeprecated: nextStep, + currentUserAccountID: accountID, + activePolicy, + policy, + betas, + isSelfTourViewed, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + onPaid: () => { + startAnimation(); + }, + }); + if (currentSearchQueryJSON && !isOffline) { + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } + } + + clearSelectedTransactions(true); + }; + + const paymentButtonOptions = usePaymentOptions({ + currency: moneyRequestReport?.currency, + iouReport: moneyRequestReport, + chatReportID: chatReport?.reportID, + formattedAmount: totalAmount, + policyID: moneyRequestReport?.policyID, + onPress: confirmPayment, + shouldHidePaymentOptions: !isPayable, + shouldShowApproveButton: false, + shouldDisableApproveButton: false, + onlyShowPayElsewhere, + }); + + const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); + const canUseBusinessBankAccount = !!moneyRequestReport?.reportID && !hasRequestFromCurrentAccount(moneyRequestReport, accountID ?? CONST.DEFAULT_NUMBER_ID); + const workspacePolicyOptions = + isIOUReportUtil(moneyRequestReport) && hasPersonalPaymentOption && activeAdminPolicies.length && canUseBusinessBankAccount + ? sortPoliciesByName(activeAdminPolicies, localeCompare) + : []; + + const buildPaymentSubMenuItems = (onWorkspaceSelected: (workspacePolicy: OnyxTypes.Policy) => void): PopoverMenuItem[] => { + if (!workspacePolicyOptions.length) { + return Object.values(paymentButtonOptions); + } + const result: PopoverMenuItem[] = []; + for (const opt of Object.values(paymentButtonOptions)) { + result.push(opt); + if (opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + for (const wp of workspacePolicyOptions) { + result.push({ + text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), + icon: expensifyIcons.Building, + onSelected: () => onWorkspaceSelected(wp), + }); + } + } + } + return result; + }; + + const showDeleteModal = () => { + showConfirmModal({ + title: translate('iou.deleteExpense', {count: selectedTransactionIDs.length}), + prompt: translate('iou.deleteConfirmation', {count: selectedTransactionIDs.length}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + const nonPendingCount = transactions.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; + if (nonPendingCount === selectedTransactionIDs.length) { + // eslint-disable-next-line no-restricted-syntax -- backTo is a legacy route param, preserving existing behavior + const backToRoute = ((route.params as {backTo?: Route} | undefined)?.backTo ?? (chatReport?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID) : undefined)) as + | Route + | undefined; + handleDeleteTransactionsWithNavigation(backToRoute); + } else { + handleDeleteTransactions(); + } + }); + }; + + const allExpensesSelected = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === nonPendingDeleteTransactions.length; + + // Ref writes below are inside onSelected callbacks that only fire on user interaction, never during render. + /* eslint-disable react-hooks/refs */ + const selectionModeReportLevelActions: Array & Pick> = !isBulkSubmitApprovePayBetaEnabled + ? [] + : [ + ...(hasSubmitAction && !shouldBlockSubmit + ? [ + { + text: translate('common.submit'), + icon: expensifyIcons.Send, + value: CONST.REPORT.PRIMARY_ACTIONS.SUBMIT, + onSelected: () => handleSubmitReport(true), + }, + ] + : []), + ...(hasApproveAction && !isBlockSubmitDueToPreventSelfApproval + ? [ + { + text: translate('iou.approve'), + icon: expensifyIcons.ThumbsUp, + value: CONST.REPORT.PRIMARY_ACTIONS.APPROVE, + onSelected: () => { + isSelectionModePaymentRef.current = true; + confirmApproval(true); + }, + }, + ] + : []), + ...(hasPayAction && !(isOffline && !canAllowSettlement) + ? [ + { + text: translate('iou.settlePayment', totalAmount), + icon: expensifyIcons.Cash, + value: CONST.REPORT.PRIMARY_ACTIONS.PAY as string, + rightIcon: expensifyIcons.ArrowRight, + backButtonText: translate('iou.settlePayment', totalAmount), + subMenuItems: buildPaymentSubMenuItems((wp) => { + isSelectionModePaymentRef.current = true; + if (checkForNecessaryAction()) { + return; + } + kycWallRef.current?.continueAction?.({policy: wp}); + }), + onSelected: () => { + isSelectionModePaymentRef.current = true; + }, + }, + ] + : []), + ]; + + const mappedOptions = originalSelectedTransactionsOptions.map((option) => { + if (option.value === CONST.REPORT.SECONDARY_ACTIONS.DELETE) { + return {...option, onSelected: showDeleteModal}; + } + if (option.value === CONST.REPORT.SECONDARY_ACTIONS.REJECT) { + return { + ...option, + onSelected: () => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + if (dismissedRejectUseExplanation) { + option.onSelected?.(); + } else { + openRejectModal(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK); + } + }, + }; + } + return option; + }); + + const selectedTransactionsOptions = allExpensesSelected && selectionModeReportLevelActions.length ? [...selectionModeReportLevelActions, ...mappedOptions] : mappedOptions; + + const popoverUseScrollView = shouldPopoverUseScrollView(selectedTransactionsOptions); + + const hasActualPaymentOptions = paymentButtonOptions.some((opt) => Object.values(CONST.IOU.PAYMENT_TYPE).some((type) => type === opt.value)); + const hasPayInSelectionMode = allExpensesSelected && hasPayAction && hasActualPaymentOptions; + + const onSelectionModePaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { + isSelectionModePaymentRef.current = true; + if (checkForNecessaryAction(iouPaymentType)) { + return; + } + selectPaymentType({ + event, + iouPaymentType, + triggerKYCFlow, + expenseReportPolicy: policy, + policy, + onPress: confirmPayment, + currentAccountID: accountID, + currentEmail: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + isUserValidated, + confirmApproval: () => confirmApproval(), + iouReport: moneyRequestReport, + iouReportNextStep: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + delegateEmail, + }); + }; + + const selectionModeKYCSuccess = (type?: PaymentMethodType) => { + isSelectionModePaymentRef.current = true; + confirmPayment({paymentType: type}); + }; + + if (!selectedTransactionsOptions.length || transactionThreadReportID) { + return null; + } + + const bulkDuplicateHandler = isDuplicateOptionVisible ? ( + clearSelectedTransactions(true)} + /> + ) : null; + + if (hasPayInSelectionMode) { + return ( + <> + {bulkDuplicateHandler} + + + ); + } + + return ( + <> + {bulkDuplicateHandler} + null} + options={selectedTransactionsOptions} + customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} + isSplitButton={false} + shouldAlwaysShowDropdownMenu + shouldPopoverUseScrollView={popoverUseScrollView} + wrapperStyle={wrapperStyle} + /> + + ); +} + +export default MoneyReportHeaderSelectionDropdown; +export type {MoneyReportHeaderSelectionDropdownProps}; diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx new file mode 100644 index 000000000000..624c8c52a45a --- /dev/null +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -0,0 +1,101 @@ +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; +import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import useExportAgainModal from '@hooks/useExportAgainModal'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; +import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; +import type {MoneyReportHeaderActionsProps} from './types'; + +/** + * Narrow the wide primaryAction union to what report-level secondary actions accept. + * TRANSACTION_PRIMARY_ACTIONS values (e.g. "keepThisOne") are irrelevant here. + */ +function narrowPrimaryAction(primaryAction: MoneyReportHeaderActionsProps['primaryAction']): ValueOf | '' { + if ((Object.values(CONST.REPORT.PRIMARY_ACTIONS) as string[]).includes(primaryAction)) { + return primaryAction as ValueOf; + } + return ''; +} + +function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, backTo}: MoneyReportHeaderActionsProps) { + const styles = useThemeStyles(); + const dropdownMenuRef = useRef(null) as React.RefObject; + + // We need isSmallScreenWidth for the hold expense modal layout https://github.com/Expensify/App/pull/47990#issuecomment-2362382026 + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const shouldDisplayNarrowVersion = shouldUseNarrowLayout || isMediumScreenWidth; + const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); + const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + + const {transactionThreadReportID} = useTransactionThreadReport(reportID); + + const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); + + const {selectedTransactionIDs} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); + const hasSelectedTransactions = !!selectedTransactionIDs.length; + const isTransactionThread = !!transactionThreadReportID; + + useEffect(() => { + if (!transactionThreadReportID) { + return; + } + + clearSelectedTransactions(true); + }, [transactionThreadReportID]); // eslint-disable-line react-hooks/exhaustive-deps + + const narrowedPrimaryAction = narrowPrimaryAction(primaryAction); + + if (hasSelectedTransactions && !isTransactionThread) { + return ( + + + + ); + } + + return ( + + {!!primaryAction && ( + + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} + /> + + )} + + + ); +} + +export default MoneyReportHeaderActions; +export type {MoneyReportHeaderActionsProps}; diff --git a/src/components/MoneyReportHeaderActions/types.ts b/src/components/MoneyReportHeaderActions/types.ts new file mode 100644 index 000000000000..605c0fb95af9 --- /dev/null +++ b/src/components/MoneyReportHeaderActions/types.ts @@ -0,0 +1,16 @@ +import type {ValueOf} from 'type-fest'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type CONST from '@src/CONST'; +import type {Route} from '@src/ROUTES'; + +type SecondaryActionEntry = DropdownOption> & Pick; + +type MoneyReportHeaderActionsProps = { + reportID: string | undefined; + primaryAction: ValueOf | ValueOf | ''; + isReportInSearch?: boolean; + backTo?: Route; +}; + +export type {SecondaryActionEntry, MoneyReportHeaderActionsProps}; diff --git a/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx index 9b8a49c0c8a7..d4f2ea916bee 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx @@ -1,5 +1,6 @@ import React from 'react'; import Button from '@components/Button'; +import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -9,10 +10,10 @@ import useConfirmApproval from './useConfirmApproval'; type ApprovePrimaryActionProps = { reportID: string | undefined; - startApprovedAnimation: () => void; }; -function ApprovePrimaryAction({reportID, startApprovedAnimation}: ApprovePrimaryActionProps) { +function ApprovePrimaryAction({reportID}: ApprovePrimaryActionProps) { + const {startApprovedAnimation} = usePaymentAnimationsContext(); const {translate} = useLocalize(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 2f38fd47c5ed..aaa9ac4ae37c 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -2,6 +2,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import React from 'react'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; +import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import {useSearchStateContext} from '@components/Search/SearchContext'; import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettlementButton'; import type {PaymentActionParams} from '@components/SettlementButton/types'; @@ -29,14 +30,10 @@ import useTransactionThreadData from './useTransactionThreadData'; type PayPrimaryActionProps = { reportID: string | undefined; chatReportID: string | undefined; - isPaidAnimationRunning: boolean; - isApprovedAnimationRunning: boolean; - stopAnimation: () => void; - startAnimation: () => void; - startApprovedAnimation: () => void; }; -function PayPrimaryAction({reportID, chatReportID, isPaidAnimationRunning, isApprovedAnimationRunning, stopAnimation, startAnimation, startApprovedAnimation}: PayPrimaryActionProps) { +function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { + const {isPaidAnimationRunning, isApprovedAnimationRunning, stopAnimation, startAnimation, startApprovedAnimation} = usePaymentAnimationsContext(); const {isOffline} = useNetwork(); const {accountID, email} = useCurrentUserPersonalDetails(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index be07b7f1487e..cc104207b041 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -1,6 +1,7 @@ import {delegateEmailSelector} from '@selectors/Account'; import React from 'react'; import AnimatedSubmitButton from '@components/AnimatedSubmitButton'; +import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import {useSearchStateContext} from '@components/Search/SearchContext'; import useConfirmPendingRTERAndProceed from '@hooks/useConfirmPendingRTERAndProceed'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -24,12 +25,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; type SubmitPrimaryActionProps = { reportID: string | undefined; - isSubmittingAnimationRunning: boolean; - stopAnimation: () => void; - startSubmittingAnimation: () => void; }; -function SubmitPrimaryAction({reportID, isSubmittingAnimationRunning, stopAnimation, startSubmittingAnimation}: SubmitPrimaryActionProps) { +function SubmitPrimaryAction({reportID}: SubmitPrimaryActionProps) { + const {isSubmittingAnimationRunning, stopAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); const {accountID, email} = useCurrentUserPersonalDetails(); diff --git a/src/components/MoneyReportHeaderPrimaryAction/index.tsx b/src/components/MoneyReportHeaderPrimaryAction/index.tsx index 16015c6e54b5..ec8535ed564a 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/index.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/index.tsx @@ -10,41 +10,17 @@ import ReviewDuplicatesPrimaryAction from './ReviewDuplicatesPrimaryAction'; import SubmitPrimaryAction from './SubmitPrimaryAction'; import type {MoneyReportHeaderPrimaryActionProps} from './types'; -function MoneyReportHeaderPrimaryAction({ - reportID, - chatReportID, - primaryAction, - isPaidAnimationRunning, - isApprovedAnimationRunning, - isSubmittingAnimationRunning, - stopAnimation, - startAnimation, - startApprovedAnimation, - startSubmittingAnimation, - onExportModalOpen, -}: MoneyReportHeaderPrimaryActionProps) { +function MoneyReportHeaderPrimaryAction({reportID, chatReportID, primaryAction, onExportModalOpen}: MoneyReportHeaderPrimaryActionProps) { if (!primaryAction) { return null; } if (primaryAction === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT) { - return ( - - ); + return ; } if (primaryAction === CONST.REPORT.PRIMARY_ACTIONS.APPROVE) { - return ( - - ); + return ; } if (primaryAction === CONST.REPORT.PRIMARY_ACTIONS.PAY) { @@ -52,11 +28,6 @@ function MoneyReportHeaderPrimaryAction({ ); } diff --git a/src/components/MoneyReportHeaderPrimaryAction/types.ts b/src/components/MoneyReportHeaderPrimaryAction/types.ts index ee1ebff9b50b..5a3ea747939f 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/types.ts +++ b/src/components/MoneyReportHeaderPrimaryAction/types.ts @@ -5,13 +5,6 @@ type MoneyReportHeaderPrimaryActionProps = { reportID: string | undefined; chatReportID: string | undefined; primaryAction: ValueOf | ValueOf | ''; - isPaidAnimationRunning: boolean; - isApprovedAnimationRunning: boolean; - isSubmittingAnimationRunning: boolean; - stopAnimation: () => void; - startAnimation: () => void; - startApprovedAnimation: () => void; - startSubmittingAnimation: () => void; onExportModalOpen: () => void; }; diff --git a/src/components/PaymentAnimationsContext.tsx b/src/components/PaymentAnimationsContext.tsx new file mode 100644 index 000000000000..d2801a5debd0 --- /dev/null +++ b/src/components/PaymentAnimationsContext.tsx @@ -0,0 +1,21 @@ +import React, {createContext, useContext} from 'react'; +import usePaymentAnimations from '@hooks/usePaymentAnimations'; + +type PaymentAnimationsContextType = ReturnType; + +const PaymentAnimationsContext = createContext(null); + +function usePaymentAnimationsContext(): PaymentAnimationsContextType { + const context = useContext(PaymentAnimationsContext); + if (!context) { + throw new Error('usePaymentAnimationsContext must be used within a PaymentAnimationsProvider'); + } + return context; +} + +function PaymentAnimationsProvider({children}: {children: React.ReactNode}) { + const animations = usePaymentAnimations(); + return {children}; +} + +export {PaymentAnimationsProvider, usePaymentAnimationsContext}; diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts new file mode 100644 index 000000000000..a36f3a4f09c4 --- /dev/null +++ b/src/hooks/useExpenseActions.ts @@ -0,0 +1,566 @@ +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; +import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; +import {useRef} from 'react'; +import {InteractionManager} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import {duplicateReport as duplicateReportAction, duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; +import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; +import {deleteAppReport} from '@libs/actions/Report'; +import initSplitExpense from '@libs/actions/SplitExpenses'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getExistingTransactionID} from '@libs/IOUUtils'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import {isPolicyAccessible} from '@libs/PolicyUtils'; +import {getIOUActionForTransactionID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + canEditFieldOfMoneyRequest, + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + generateReportID, + getAddExpenseDropdownOptions, + getPolicyExpenseChat, + isDM, + isOpenReport, + isSelfDM, + navigateOnDeleteExpense, +} from '@libs/ReportUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import { + getChildTransactions, + getOriginalTransactionWithSplitInfo, + hasCustomUnitOutOfPolicyViolation as hasCustomUnitOutOfPolicyViolationTransactionUtils, + isDistanceRequest, + isPerDiemRequest, + isTransactionPendingDelete, +} from '@libs/TransactionUtils'; +import {startMoneyRequest} from '@userActions/IOU'; +import {getNavigationUrlOnMoneyRequestDelete} from '@userActions/IOU/DeleteMoneyRequest'; +import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import useConfirmModal from './useConfirmModal'; +import {useCurrencyListActions} from './useCurrencyList'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDefaultExpensePolicy from './useDefaultExpensePolicy'; +import useDeleteTransactions from './useDeleteTransactions'; +import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; +import useGetIOUReportFromReportAction from './useGetIOUReportFromReportAction'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; +import useReportIsArchived from './useReportIsArchived'; +import useTheme from './useTheme'; +import useThrottledButtonState from './useThrottledButtonState'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; +import useTransactionThreadReport from './useTransactionThreadReport'; +import useTransactionViolations from './useTransactionViolations'; + +type UseExpenseActionsParams = { + reportID: string | undefined; + isReportInSearch?: boolean; + backTo?: Route; + onDuplicateReset?: () => void; +}; + +type UseExpenseActionsReturn = { + actions: Partial, SecondaryActionEntry>>; + addExpenseDropdownOptions: Array>; + handleOptionsMenuHide: () => void; + isDuplicateReportActive: boolean; + wasDuplicateReportTriggeredRef: React.RefObject; +}; + +function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplicateReset}: UseExpenseActionsParams): UseExpenseActionsReturn { + const theme = useTheme(); + const {translate, localeCompare} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const {getCurrencyDecimals} = useCurrencyListActions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {login: currentUserLogin, accountID, email} = currentUserPersonalDetails; + const {currentSearchHash} = useSearchStateContext(); + const {removeTransaction} = useSearchActionsContext(); + + // Report data + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + + const {transactionThreadReportID, transactionThreadReport, reportActions} = useTransactionThreadReport(reportID); + + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + + const transactions: OnyxTypes.Transaction[] = []; + const nonPendingDeleteTransactions: OnyxTypes.Transaction[] = []; + for (const transaction of Object.values(reportTransactions)) { + transactions.push(transaction); + if (!isTransactionPendingDelete(transaction)) { + nonPendingDeleteTransactions.push(transaction); + } + } + + const currentTransaction = transactions.at(0); + const requestParentReportAction = + reportActions?.find((action): action is OnyxTypes.ReportAction => action.reportActionID === transactionThreadReport?.parentReportActionID) ?? + null; + + const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); + const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); + const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(requestParentReportAction); + + // Global collections + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); + const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); + const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`); + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + + // Billing keys + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + + // Archive checks + const isArchivedReport = useReportIsArchived(moneyRequestReport?.reportID); + const isChatReportArchived = useReportIsArchived(chatReport?.reportID); + + // Default expense policy / chat + const defaultExpensePolicy = useDefaultExpensePolicy(); + const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id); + + // Duplicate detection + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactions.map((t) => t.transactionID)); + + // Delete hook — pass chatReport (as in MoneyReportHeader) not moneyRequestReport + const {deleteTransactions} = useDeleteTransactions({ + report: chatReport, + reportActions, + policy, + }); + + // Confirm modal + const {showConfirmModal} = useConfirmModal(); + + // Split indicator + const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); + const hasMultipleSplits = !!transaction?.comment?.originalTransactionID && getChildTransactions(allTransactions, allReports, transaction.comment.originalTransactionID).length > 1; + const isReportOpen = isOpenReport(moneyRequestReport); + const hasSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); + + // Duplicate report throttle + const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); + const wasDuplicateReportTriggeredRef = useRef(false); + + const handleOptionsMenuHide = () => { + wasDuplicateReportTriggeredRef.current = false; + }; + + // The dropdown ref is owned by the caller (orchestrator) — we close the menu by calling into it. + // We expose an effect trigger instead: when isDuplicateReportActive flips back to true and the flag + // is set, the caller should call dropdownMenuRef.current?.setIsMenuVisible(false). + // To keep this self-contained we return the ref so the caller can react to it. + + const singleTransaction = nonPendingDeleteTransactions.length === 1 ? nonPendingDeleteTransactions.at(0) : undefined; + const canMoveSingleExpense = + !!singleTransaction && + canEditFieldOfMoneyRequest({ + reportAction: getIOUActionForTransactionID(reportActions, singleTransaction.transactionID), + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, + isChatReportArchived, + outstandingReportsByPolicyID, + transaction: singleTransaction, + }) && + canUserPerformWriteActionReportUtils(moneyRequestReport, isChatReportArchived); + + // Duplicate expense: unsupported / shouldClose flags + const transactionViolations = useTransactionViolations(transaction?.transactionID); + const hasCustomUnitOutOfPolicyViolation = hasCustomUnitOutOfPolicyViolationTransactionUtils(transactionViolations); + const isPerDiemRequestOnNonDefaultWorkspace = isPerDiemRequest(transaction) && defaultExpensePolicy?.id !== policy?.id; + const isDistanceExpenseUnsupportedForDuplicating = !!( + isDistanceRequest(transaction) && + (isArchivedReport || isChatReportArchived || (activePolicyExpenseChat && (isDM(chatReport) || isSelfDM(chatReport)))) + ); + const shouldDuplicateCloseModalOnSelect = + isDistanceExpenseUnsupportedForDuplicating || + isPerDiemRequestOnNonDefaultWorkspace || + hasCustomUnitOutOfPolicyViolation || + activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID; + + const handleDuplicateReset = () => { + if (shouldDuplicateCloseModalOnSelect) { + return; + } + onDuplicateReset?.(); + }; + const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(handleDuplicateReset); + + const targetPolicyTags = defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {}; + + const duplicateExpenseTransaction = (transactionList: OnyxTypes.Transaction[]) => { + if (!transactionList.length) { + return; + } + const optimisticChatReportID = generateReportID(); + const optimisticIOUReportID = generateReportID(); + const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; + + for (const item of transactionList) { + const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; + + duplicateTransactionAction({ + transaction: item, + optimisticChatReportID, + optimisticIOUReportID, + isASAPSubmitBetaEnabled, + introSelected, + activePolicyID, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + isSelfTourViewed, + customUnitPolicyID: policy?.id, + targetPolicy: defaultExpensePolicy ?? undefined, + targetPolicyCategories: activePolicyCategories, + targetReport: activePolicyExpenseChat, + existingTransactionDraft, + draftTransactionIDs, + betas, + personalDetails, + recentWaypoints, + targetPolicyTags, + }); + } + }; + + const addExpenseDropdownOptions = getAddExpenseDropdownOptions({ + translate, + icons: useMemoizedLazyExpensifyIcons(['Plus', 'ReceiptPlus', 'Location', 'Feed', 'ArrowRight']), + iouReportID: moneyRequestReport?.reportID, + policy, + userBillingGracePeriodEnds, + draftTransactionIDs, + amountOwed, + ownerBillingGracePeriodEnd, + lastDistanceExpenseType, + }); + + const expensifyIcons = useMemoizedLazyExpensifyIcons([ + 'Plus', + 'ArrowSplit', + 'ArrowCollapse', + 'ExpenseCopy', + 'ReportCopy', + 'Checkmark', + 'DocumentMerge', + 'Workflows', + 'Trashcan', + 'Buildings', + 'ReceiptPlus', + 'Location', + 'Feed', + 'ArrowRight', + ]); + + const actions: Partial, SecondaryActionEntry>> = { + [CONST.REPORT.SECONDARY_ACTIONS.SPLIT]: { + text: hasSplitIndicator ? translate('iou.editSplits') : translate('iou.split'), + icon: expensifyIcons.ArrowSplit, + value: CONST.REPORT.SECONDARY_ACTIONS.SPLIT, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SPLIT, + onSelected: () => { + if (transactions.length !== 1) { + return; + } + initSplitExpense(currentTransaction, policy); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.MERGE]: { + text: translate('common.merge'), + icon: expensifyIcons.ArrowCollapse, + value: CONST.REPORT.SECONDARY_ACTIONS.MERGE, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.MERGE, + onSelected: () => { + if (!currentTransaction) { + return; + } + setupMergeTransactionDataAndNavigate(currentTransaction.transactionID, [currentTransaction], localeCompare, getCurrencyDecimals); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE]: { + text: isDuplicateActive ? translate('common.duplicateExpense') : translate('common.duplicated'), + icon: isDuplicateActive ? expensifyIcons.ExpenseCopy : expensifyIcons.Checkmark, + iconFill: isDuplicateActive ? undefined : theme.icon, + value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE, + onSelected: () => { + if (hasCustomUnitOutOfPolicyViolation) { + showConfirmModal({ + title: translate('common.duplicateExpense'), + prompt: translate('iou.correctRateError'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + + if (isDistanceExpenseUnsupportedForDuplicating) { + showConfirmModal({ + title: translate('common.duplicateExpense'), + prompt: translate('iou.cannotDuplicateDistanceExpense'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + + if (isPerDiemRequestOnNonDefaultWorkspace) { + showConfirmModal({ + title: translate('common.duplicateExpense'), + prompt: translate('iou.duplicateNonDefaultWorkspacePerDiemError'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + + if (!isDuplicateActive || !transaction) { + return; + } + + temporarilyDisableDuplicateAction(); + duplicateExpenseTransaction([transaction]); + }, + shouldCloseModalOnSelect: shouldDuplicateCloseModalOnSelect, + }, + [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT]: { + text: isDuplicateReportActive ? translate('common.duplicateReport') : translate('common.duplicated'), + icon: isDuplicateReportActive ? expensifyIcons.ReportCopy : expensifyIcons.Checkmark, + iconFill: isDuplicateReportActive ? undefined : theme.icon, + value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DUPLICATE_REPORT, + shouldShow: !!defaultExpensePolicy, + shouldCloseModalOnSelect: false, + onSelected: () => { + if (!isDuplicateReportActive) { + return; + } + + temporarilyDisableDuplicateReportAction(); + wasDuplicateReportTriggeredRef.current = true; + + const isSourcePolicyValid = !!policy && isPolicyAccessible(policy, currentUserLogin ?? ''); + const targetPolicyForDuplicate = isSourcePolicyValid ? policy : defaultExpensePolicy; + const targetChatForDuplicate = isSourcePolicyValid ? chatReport : activePolicyExpenseChat; + const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicyForDuplicate?.id}`] ?? {}; + + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + duplicateReportAction({ + sourceReport: moneyRequestReport, + sourceReportTransactions: nonPendingDeleteTransactions, + sourceReportName: moneyRequestReport?.reportName ?? '', + targetPolicy: targetPolicyForDuplicate ?? undefined, + targetPolicyCategories: activePolicyCategories, + targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyForDuplicate?.id}`] ?? {}, + parentChatReport: targetChatForDuplicate, + ownerPersonalDetails: currentUserPersonalDetails, + isASAPSubmitBetaEnabled, + betas, + personalDetails, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + draftTransactionIDs, + isSelfTourViewed, + transactionViolations: allTransactionViolations, + translate, + recentWaypoints: recentWaypoints ?? [], + }); + }); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE]: { + text: translate('iou.changeWorkspace'), + icon: expensifyIcons.Buildings, + value: CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.CHANGE_WORKSPACE, + shouldShow: transactions.length === 0 || nonPendingDeleteTransactions.length > 0, + onSelected: () => { + if (!moneyRequestReport) { + return; + } + Navigation.navigate(ROUTES.REPORT_WITH_ID_CHANGE_WORKSPACE.getRoute(moneyRequestReport.reportID, Navigation.getActiveRoute())); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE]: { + text: translate('iou.moveExpenses'), + icon: expensifyIcons.DocumentMerge, + value: CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.MOVE_EXPENSE, + shouldShow: canMoveSingleExpense, + onSelected: () => { + if (!moneyRequestReport || nonPendingDeleteTransactions.length !== 1) { + return; + } + const transactionToMove = nonPendingDeleteTransactions.at(0); + if (!transactionToMove?.transactionID) { + return; + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_EDIT_REPORT.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.SUBMIT, + moneyRequestReport.reportID, + true, + Navigation.getActiveRoute(), + transactionToMove.transactionID, + ), + ); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.CHANGE_APPROVER]: { + text: translate('iou.changeApprover.title'), + icon: expensifyIcons.Workflows, + value: CONST.REPORT.SECONDARY_ACTIONS.CHANGE_APPROVER, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.CHANGE_APPROVER, + onSelected: () => { + if (!moneyRequestReport) { + Log.warn('Change approver secondary action triggered without moneyRequestReport data.'); + return; + } + Navigation.navigate(ROUTES.REPORT_CHANGE_APPROVER.getRoute(moneyRequestReport.reportID, Navigation.getActiveRoute())); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.DELETE]: { + text: translate('common.delete'), + icon: expensifyIcons.Trashcan, + value: CONST.REPORT.SECONDARY_ACTIONS.DELETE, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DELETE, + onSelected: async () => { + const transactionCount = Object.keys(transactions).length; + + if (transactionCount === 1) { + const result = await showConfirmModal({ + title: translate('iou.deleteExpense', {count: 1}), + prompt: translate('iou.deleteConfirmation', {count: 1}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + if (transactionThreadReportID) { + if (!requestParentReportAction || !transaction?.transactionID) { + throw new Error('Missing data!'); + } + const goBackRoute = getNavigationUrlOnMoneyRequestDelete( + transaction.transactionID, + requestParentReportAction, + iouReport, + chatIOUReport, + isChatIOUReportArchived, + false, + ); + const deleteNavigateBackUrl = goBackRoute ?? backTo ?? Navigation.getActiveRoute(); + setDeleteTransactionNavigateBackUrl(deleteNavigateBackUrl); + if (goBackRoute) { + navigateOnDeleteExpense(goBackRoute); + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + deleteTransactions([transaction.transactionID], duplicateTransactions, duplicateTransactionViolations, isReportInSearch ? currentSearchHash : undefined, false); + removeTransaction(transaction.transactionID); + }); + } + return; + } + + const result = await showConfirmModal({ + title: translate('iou.deleteReport', {count: 1}), + prompt: translate('iou.deleteReportConfirmation', {count: 1}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + const backToRoute = backTo ?? (chatReport?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID) : undefined); + const deleteNavigateBackUrl = backToRoute ?? Navigation.getActiveRoute(); + setDeleteTransactionNavigateBackUrl(deleteNavigateBackUrl); + + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.goBack(backToRoute); + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + deleteAppReport({ + report: moneyRequestReport, + selfDMReport, + currentUserEmailParam: email ?? '', + currentUserAccountIDParam: accountID, + reportTransactions, + allTransactionViolations, + bankAccountList, + hash: currentSearchHash, + }); + }); + }); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE]: { + text: translate('iou.addExpense'), + backButtonText: translate('iou.addExpense'), + icon: expensifyIcons.Plus, + rightIcon: expensifyIcons.ArrowRight, + value: CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE, + subMenuItems: addExpenseDropdownOptions, + onSelected: () => { + if (!moneyRequestReport?.reportID) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, policy)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, moneyRequestReport?.reportID, draftTransactionIDs); + }, + }, + }; + + return { + actions, + addExpenseDropdownOptions, + handleOptionsMenuHide, + isDuplicateReportActive, + wasDuplicateReportTriggeredRef, + }; +} + +export default useExpenseActions; +export type {UseExpenseActionsParams, UseExpenseActionsReturn}; diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts new file mode 100644 index 000000000000..7b8003a80da9 --- /dev/null +++ b/src/hooks/useExportActions.ts @@ -0,0 +1,268 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {useSearchActionsContext} from '@components/Search/SearchContext'; +import {openOldDotLink} from '@libs/actions/Link'; +import {exportReportToCSV, exportReportToPDF, exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; +import {getExportTemplates, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getConnectedIntegration, getValidConnectedIntegration} from '@libs/PolicyUtils'; +import {getFilteredReportActionsForReportView} from '@libs/ReportActionsUtils'; +import {getSecondaryExportReportActions} from '@libs/ReportSecondaryActionUtils'; +import {getIntegrationIcon, isExported as isExportedUtils} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import useConfirmModal from './useConfirmModal'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDecisionModal from './useDecisionModal'; +import useExportAgainModal from './useExportAgainModal'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePaginatedReportActions from './usePaginatedReportActions'; +import useThemeStyles from './useThemeStyles'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; + +type UseExportActionsParams = { + reportID: string | undefined; + policy?: OnyxEntry; + onPDFModalOpen?: () => void; +}; + +type UseExportActionsReturn = { + exportActionEntries: Record> & Pick>; + secondaryExportActions: Array>; + beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => void; + showOfflineModal: () => void; + showDownloadErrorModal: () => void; +}; + +function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsParams): UseExportActionsReturn { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); + const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const {login: currentUserLogin, accountID} = useCurrentUserPersonalDetails(); + + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + const transactionIDs = Object.values(reportTransactions).map((t) => t.transactionID); + + const connectedIntegration = getValidConnectedIntegration(policy); + const connectedIntegrationFallback = getConnectedIntegration(policy); + const exportTemplates = getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy); + const isExported = isExportedUtils(reportActions, moneyRequestReport); + + const {showConfirmModal} = useConfirmModal(); + const {showDecisionModal} = useDecisionModal(); + const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); + const {clearSelectedTransactions} = useSearchActionsContext(); + + const expensifyIcons = useMemoizedLazyExpensifyIcons([ + 'Table', + 'Export', + 'Download', + 'Printer', + 'XeroSquare', + 'QBOSquare', + 'NetSuiteSquare', + 'IntacctSquare', + 'QBDSquare', + 'CertiniaSquare', + 'GustoSquare', + 'ArrowRight', + ]); + + const showOfflineModal = () => { + showDecisionModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; + + const showDownloadErrorModal = () => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedDescription'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; + + const showExportProgressModal = () => { + return showConfirmModal({ + title: translate('export.exportInProgress'), + prompt: translate('export.conciergeWillSend'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + + const beginExportWithTemplate = (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => { + if (isOffline) { + showOfflineModal(); + return; + } + + if (!moneyRequestReport) { + return; + } + + showExportProgressModal().then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + clearSelectedTransactions(undefined, true); + }); + + queueExportSearchWithTemplate({ + templateName, + templateType, + jsonQuery: '{}', + reportIDList: [moneyRequestReport.reportID], + transactionIDList, + policyID, + }); + }; + + const exportSubmenuOptions: Record> = { + [CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV]: { + text: translate('export.basicExport'), + icon: expensifyIcons.Table, + value: CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, + onSelected: () => { + if (!moneyRequestReport) { + return; + } + if (isOffline) { + showOfflineModal(); + return; + } + exportReportToCSV( + { + reportID: moneyRequestReport.reportID, + transactionIDList: transactionIDs, + }, + () => { + showDownloadErrorModal(); + }, + translate, + ); + }, + }, + [CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION]: { + text: translate('workspace.common.exportIntegrationSelected', { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + connectionName: connectedIntegrationFallback!, + }), + icon: getIntegrationIcon(connectedIntegration ?? connectedIntegrationFallback, expensifyIcons), + displayInDefaultIconColor: true, + additionalIconStyles: styles.integrationIcon, + value: CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, + onSelected: () => { + if (!connectedIntegration || !moneyRequestReport) { + return; + } + if (isExported) { + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION); + return; + } + exportToIntegration(moneyRequestReport.reportID, connectedIntegration); + }, + }, + [CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED]: { + text: translate('workspace.common.markAsExported'), + icon: getIntegrationIcon(connectedIntegration ?? connectedIntegrationFallback, expensifyIcons), + additionalIconStyles: styles.integrationIcon, + displayInDefaultIconColor: true, + value: CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, + onSelected: () => { + if (!connectedIntegration || !moneyRequestReport) { + return; + } + if (isExported) { + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED); + return; + } + markAsManuallyExported([moneyRequestReport.reportID ?? CONST.DEFAULT_NUMBER_ID], connectedIntegration); + }, + }, + }; + + for (const template of exportTemplates) { + exportSubmenuOptions[template.name] = { + text: template.name, + icon: expensifyIcons.Table, + value: template.templateName, + description: template.description, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, + onSelected: () => beginExportWithTemplate(template.templateName, template.type, transactionIDs, template.policyID), + }; + } + + const secondaryExportActions = moneyRequestReport + ? getSecondaryExportReportActions(accountID, currentUserLogin ?? '', moneyRequestReport, bankAccountList, policy ?? undefined, exportTemplates) + : []; + + const exportActionEntries: Record> & Pick> = { + [CONST.REPORT.SECONDARY_ACTIONS.EXPORT]: { + value: CONST.REPORT.SECONDARY_ACTIONS.EXPORT, + text: translate('common.export'), + backButtonText: translate('common.export'), + icon: expensifyIcons.Export, + rightIcon: expensifyIcons.ArrowRight, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT, + subMenuItems: secondaryExportActions.map((action) => exportSubmenuOptions[action as string]), + }, + [CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF]: { + value: CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF, + text: translate('common.downloadAsPDF'), + icon: expensifyIcons.Download, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DOWNLOAD_PDF, + onSelected: () => { + if (!moneyRequestReport?.reportID) { + return; + } + onPDFModalOpen?.(); + exportReportToPDF({reportID: moneyRequestReport.reportID}); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.PRINT]: { + value: CONST.REPORT.SECONDARY_ACTIONS.PRINT, + text: translate('common.print'), + icon: expensifyIcons.Printer, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.PRINT, + onSelected: () => { + if (!moneyRequestReport) { + return; + } + openOldDotLink(CONST.OLDDOT_URLS.PRINTABLE_REPORT(moneyRequestReport.reportID)); + }, + }, + }; + + return { + exportActionEntries, + secondaryExportActions, + beginExportWithTemplate, + showOfflineModal, + showDownloadErrorModal, + }; +} + +export default useExportActions; +export type {UseExportActionsParams, UseExportActionsReturn}; diff --git a/src/hooks/useHoldRejectActions.ts b/src/hooks/useHoldRejectActions.ts new file mode 100644 index 000000000000..8c383634720d --- /dev/null +++ b/src/hooks/useHoldRejectActions.ts @@ -0,0 +1,123 @@ +import type {ValueOf} from 'type-fest'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; +import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {changeMoneyRequestHoldStatus, isCurrentUserSubmitter, isDM} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import useGetIOUReportFromReportAction from './useGetIOUReportFromReportAction'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import useTransactionThreadReport from './useTransactionThreadReport'; + +type UseHoldRejectActionsParams = { + reportID: string | undefined; + onHoldEducationalOpen: () => void; + onRejectModalOpen: (action: RejectModalAction) => void; +}; + +type UseHoldRejectActionsReturn = Pick< + Record, SecondaryActionEntry>, + typeof CONST.REPORT.SECONDARY_ACTIONS.HOLD | typeof CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD | typeof CONST.REPORT.SECONDARY_ACTIONS.REJECT +>; + +function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpen}: UseHoldRejectActionsParams): UseHoldRejectActionsReturn { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'ThumbsDown'] as const); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + const {transactionThreadReport} = useTransactionThreadReport(reportID); + + const [reportActionsForParent] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`, {canEvict: false}); + const requestParentReportAction = transactionThreadReport?.parentReportActionID ? reportActionsForParent?.[transactionThreadReport.parentReportActionID] : undefined; + + const {chatReport: chatIOUReport} = useGetIOUReportFromReportAction(requestParentReportAction); + + const iouTransactionID = isMoneyRequestAction(requestParentReportAction) + ? (getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) + : CONST.DEFAULT_NUMBER_ID; + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${iouTransactionID}`); + + const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); + const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); + + const isReportSubmitter = isCurrentUserSubmitter(chatIOUReport); + const isChatReportDM = isDM(chatReport); + + return { + [CONST.REPORT.SECONDARY_ACTIONS.HOLD]: { + text: translate('iou.hold'), + icon: expensifyIcons.Stopwatch, + value: CONST.REPORT.SECONDARY_ACTIONS.HOLD, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.HOLD, + onSelected: () => { + if (!requestParentReportAction) { + throw new Error('Parent action does not exist'); + } + + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + + const isDismissed = isReportSubmitter ? dismissedHoldUseExplanation : dismissedRejectUseExplanation; + + if (isDismissed || isChatReportDM) { + changeMoneyRequestHoldStatus(requestParentReportAction, transaction, isOffline); + } else if (isReportSubmitter) { + onHoldEducationalOpen(); + } else { + onRejectModalOpen(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD); + } + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD]: { + text: translate('iou.unhold'), + icon: expensifyIcons.Stopwatch, + value: CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REMOVE_HOLD, + onSelected: () => { + if (!requestParentReportAction) { + throw new Error('Parent action does not exist'); + } + + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + + changeMoneyRequestHoldStatus(requestParentReportAction, transaction, isOffline); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.REJECT]: { + text: translate('common.reject'), + icon: expensifyIcons.ThumbsDown, + value: CONST.REPORT.SECONDARY_ACTIONS.REJECT, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REJECT, + onSelected: () => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + + if (moneyRequestReport?.reportID) { + Navigation.navigate(ROUTES.REJECT_EXPENSE_REPORT.getRoute(moneyRequestReport.reportID)); + } + }, + }, + }; +} + +export default useHoldRejectActions; +export type {UseHoldRejectActionsParams, UseHoldRejectActionsReturn}; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx new file mode 100644 index 000000000000..574d24266242 --- /dev/null +++ b/src/hooks/useLifecycleActions.tsx @@ -0,0 +1,389 @@ +import {delegateEmailSelector} from '@selectors/Account'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import type {ActionHandledType} from '@components/Modal/Global/HoldMenuModalWrapper'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import Text from '@components/Text'; +import {search} from '@libs/actions/Search'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getValidConnectedIntegration} from '@libs/PolicyUtils'; +import {getFilteredReportActionsForReportView} from '@libs/ReportActionsUtils'; +import { + getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, + getNextApproverAccountID, + hasHeldExpenses as hasHeldExpensesReportUtils, + hasViolations as hasViolationsReportUtils, + isExported as isExportedUtils, + isReportOwner, + shouldBlockSubmitDueToStrictPolicyRules, +} from '@libs/ReportUtils'; +import {hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils} from '@libs/TransactionUtils'; +import {cancelPayment} from '@userActions/IOU/PayMoneyRequest'; +import {approveMoneyRequest, reopenReport, retractReport, submitReport, unapproveExpenseReport} from '@userActions/IOU/ReportWorkflow'; +import {markPendingRTERTransactionsAsCash} from '@userActions/Transaction'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useConfirmModal from './useConfirmModal'; +import useConfirmPendingRTERAndProceed from './useConfirmPendingRTERAndProceed'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePaginatedReportActions from './usePaginatedReportActions'; +import usePermissions from './usePermissions'; +import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; +import useStrictPolicyRules from './useStrictPolicyRules'; +import useThemeStyles from './useThemeStyles'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; + +type UseLifecycleActionsParams = { + reportID: string | undefined; + startApprovedAnimation: () => void; + startSubmittingAnimation: () => void; + onHoldMenuOpen: (requestType: ActionHandledType, onConfirm?: () => void) => void; +}; + +type UseLifecycleActionsResult = { + actions: Record; + confirmApproval: (skipAnimation?: boolean) => void; + handleSubmitReport: (skipAnimation?: boolean) => void; + shouldBlockSubmit: boolean; + isBlockSubmitDueToPreventSelfApproval: boolean; +}; + +/** + * Provides report lifecycle transition actions (submit, approve, unapprove, cancel payment, retract, reopen) + * and their associated guards (delegate access, hold, pending RTER, strict policy rules). + */ +function useLifecycleActions({reportID, startApprovedAnimation, startSubmittingAnimation, onHoldMenuOpen}: UseLifecycleActionsParams): UseLifecycleActionsResult { + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${moneyRequestReport?.reportID}`); + const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + + const transactions = Object.values(reportTransactions); + + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {accountID, email} = currentUserPersonalDetails; + + const {areStrictPolicyRulesEnabled} = useStrictPolicyRules(); + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {showConfirmModal} = useConfirmModal(); + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + + const {currentSearchQueryJSON, currentSearchKey, currentSearchResults} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'CircularArrowBackwards', 'Clear']); + + const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport); + const isSubmitterSameAsNextApprover = + isReportOwner(moneyRequestReport) && (nextApproverAccountID === moneyRequestReport?.ownerAccountID || moneyRequestReport?.managerID === moneyRequestReport?.ownerAccountID); + const isBlockSubmitDueToPreventSelfApproval = !!(isSubmitterSameAsNextApprover && policy?.preventSelfApproval); + + const isBlockSubmitDueToStrictPolicyRules = shouldBlockSubmitDueToStrictPolicyRules( + moneyRequestReport?.reportID, + violations, + areStrictPolicyRulesEnabled, + accountID, + email ?? '', + transactions, + ); + + const shouldBlockSubmit = isBlockSubmitDueToStrictPolicyRules || isBlockSubmitDueToPreventSelfApproval; + + const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); + + const isExported = isExportedUtils(reportActions, moneyRequestReport); + const integrationNameFromExportMessage = isExported ? getIntegrationNameFromExportMessageUtils(reportActions) : null; + + const connectedIntegration = getValidConnectedIntegration(policy); + const connectedIntegrationName = connectedIntegration + ? translate('workspace.accounting.connectionName', { + connectionName: connectedIntegration, + }) + : ''; + + const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyRequestReport?.reportID); + + const hasAnyPendingRTERViolation = hasAnyPendingRTERViolationTransactionUtils(transactions, allTransactionViolations, email ?? '', accountID, moneyRequestReport, policy); + + const handleMarkPendingRTERTransactionsAsCash = () => { + markPendingRTERTransactionsAsCash(transactions, allTransactionViolations, reportActions); + }; + + const confirmPendingRTERAndProceed = useConfirmPendingRTERAndProceed(hasAnyPendingRTERViolation, handleMarkPendingRTERTransactionsAsCash); + + const confirmApproval = (skipAnimation = false) => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + if (isAnyTransactionOnHold) { + onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.APPROVE, skipAnimation ? undefined : () => startApprovedAnimation()); + return; + } + if (!skipAnimation) { + startApprovedAnimation(); + } + approveMoneyRequest({ + expenseReport: moneyRequestReport, + expenseReportPolicy: policy, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + expenseReportCurrentNextStepDeprecated: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + full: true, + onApproved: () => { + if (skipAnimation) { + return; + } + startApprovedAnimation(); + }, + delegateEmail, + }); + if (skipAnimation) { + clearSelectedTransactions(true); + } + }; + + const handleSubmitReport = (skipAnimation = false) => { + if (!moneyRequestReport || shouldBlockSubmit) { + return; + } + + const doSubmit = () => { + submitReport({ + expenseReport: moneyRequestReport, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + expenseReportCurrentNextStepDeprecated: nextStep, + userBillingGracePeriodEnds, + amountOwed, + onSubmitted: () => { + if (skipAnimation) { + return; + } + startSubmittingAnimation(); + }, + ownerBillingGracePeriodEnd, + delegateEmail, + }); + if (currentSearchQueryJSON && !isOffline) { + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } + if (skipAnimation) { + clearSelectedTransactions(true); + } + }; + + confirmPendingRTERAndProceed(doSubmit); + }; + + const actions: Record = { + [CONST.REPORT.SECONDARY_ACTIONS.SUBMIT]: { + value: CONST.REPORT.SECONDARY_ACTIONS.SUBMIT, + text: translate('common.submit'), + icon: expensifyIcons.Send, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SUBMIT, + onSelected: () => { + if (!moneyRequestReport) { + return; + } + confirmPendingRTERAndProceed(() => { + submitReport({ + expenseReport: moneyRequestReport, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + expenseReportCurrentNextStepDeprecated: nextStep, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + delegateEmail, + }); + }); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.APPROVE]: { + value: CONST.REPORT.SECONDARY_ACTIONS.APPROVE, + text: translate('iou.approve'), + icon: expensifyIcons.ThumbsUp, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.APPROVE, + onSelected: confirmApproval, + }, + [CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE]: { + value: CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE, + text: translate('iou.unapprove'), + icon: expensifyIcons.CircularArrowBackwards, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.UNAPPROVE, + onSelected: async () => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + + if (isExported) { + const unapproveWarningText = ( + + {translate('iou.headsUp')}{' '} + {translate('iou.unapproveWithIntegrationWarning', connectedIntegrationName)} + + ); + + const result = await showConfirmModal({ + title: translate('iou.unapproveReport'), + prompt: unapproveWarningText, + confirmText: translate('iou.unapproveReport'), + cancelText: translate('common.cancel'), + danger: true, + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + } + + unapproveExpenseReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT]: { + value: CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT, + text: translate('iou.cancelPayment'), + icon: expensifyIcons.Clear, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.CANCEL_PAYMENT, + onSelected: async () => { + const result = await showConfirmModal({ + title: translate('iou.cancelPayment'), + prompt: translate('iou.cancelPaymentConfirmation'), + confirmText: translate('iou.cancelPayment'), + cancelText: translate('common.dismiss'), + danger: true, + }); + + if (result.action !== ModalActions.CONFIRM || !chatReport) { + return; + } + + cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { + value: CONST.REPORT.SECONDARY_ACTIONS.RETRACT, + text: translate('iou.retract'), + icon: expensifyIcons.CircularArrowBackwards, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.RETRACT, + onSelected: async () => { + if (isExported) { + const reopenExportedReportWarningText = ( + + {translate('iou.headsUp')} + + {translate('iou.reopenExportedReportConfirmation', { + connectionName: integrationNameFromExportMessage ?? '', + })} + + + ); + + const result = await showConfirmModal({ + title: translate('iou.reopenReport'), + prompt: reopenExportedReportWarningText, + confirmText: translate('iou.reopenReport'), + cancelText: translate('common.cancel'), + danger: true, + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + } + + retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail); + }, + }, + [CONST.REPORT.SECONDARY_ACTIONS.REOPEN]: { + value: CONST.REPORT.SECONDARY_ACTIONS.REOPEN, + text: translate('iou.retract'), + icon: expensifyIcons.CircularArrowBackwards, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REOPEN, + onSelected: async () => { + if (isExported) { + const reopenExportedReportWarningText = ( + + {translate('iou.headsUp')} + + {translate('iou.reopenExportedReportConfirmation', { + connectionName: integrationNameFromExportMessage ?? '', + })} + + + ); + + const result = await showConfirmModal({ + title: translate('iou.reopenReport'), + prompt: reopenExportedReportWarningText, + confirmText: translate('iou.reopenReport'), + cancelText: translate('common.cancel'), + danger: true, + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + } + + reopenReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, chatReport); + }, + }, + }; + + return { + actions, + confirmApproval, + handleSubmitReport, + shouldBlockSubmit, + isBlockSubmitDueToPreventSelfApproval, + }; +} + +export default useLifecycleActions; +export type {UseLifecycleActionsParams, UseLifecycleActionsResult}; diff --git a/src/hooks/useReportPrimaryAction.ts b/src/hooks/useReportPrimaryAction.ts new file mode 100644 index 000000000000..00cefb3f199d --- /dev/null +++ b/src/hooks/useReportPrimaryAction.ts @@ -0,0 +1,59 @@ +import type {ValueOf} from 'type-fest'; +import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; +import {isTransactionPendingDelete} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useOnyx from './useOnyx'; +import useReportIsArchived from './useReportIsArchived'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; +import useTransactionThreadReport from './useTransactionThreadReport'; + +function useReportPrimaryAction(reportID: string | undefined): ValueOf | '' { + const {isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning} = usePaymentAnimationsContext(); + const {login: currentUserLogin, accountID} = useCurrentUserPersonalDetails(); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); + const [invoiceReceiverPolicy] = useOnyx( + `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, + ); + + const {reportActions} = useTransactionThreadReport(reportID); + const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + + const isChatReportArchived = useReportIsArchived(chatReport?.reportID); + + if (isPaidAnimationRunning || isApprovedAnimationRunning) { + return CONST.REPORT.PRIMARY_ACTIONS.PAY; + } + if (isSubmittingAnimationRunning) { + return CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; + } + + const nonPendingDeleteTransactions = Object.values(reportTransactions).filter((t) => !isTransactionPendingDelete(t)); + + return getReportPrimaryAction({ + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID: accountID, + report: moneyRequestReport, + chatReport, + reportTransactions: nonPendingDeleteTransactions, + violations, + bankAccountList, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, + isChatReportArchived, + invoiceReceiverPolicy, + }); +} + +export default useReportPrimaryAction; diff --git a/src/hooks/useTransactionThreadReport.ts b/src/hooks/useTransactionThreadReport.ts new file mode 100644 index 000000000000..f8d64f58642b --- /dev/null +++ b/src/hooks/useTransactionThreadReport.ts @@ -0,0 +1,40 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID} from '@libs/ReportActionsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePaginatedReportActions from './usePaginatedReportActions'; +import useReportTransactionsCollection from './useReportTransactionsCollection'; + +/** + * Derives the single-transaction thread report ID and report for a money request report. + * + * This pattern is repeated across multiple hooks and components that need to know + * whether a report has a single transaction thread (and access its data). + */ +function useTransactionThreadReport(reportID: string | undefined) { + const {isOffline} = useNetwork(); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const allReportTransactions = useReportTransactionsCollection(reportID); + const nonDeletedTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); + const visibleTransactions = nonDeletedTransactions?.filter((t) => isOffline || t.pendingAction !== 'delete'); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + + const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); + + return { + transactionThreadReportID, + transactionThreadReport, + reportActions, + }; +} + +export default useTransactionThreadReport; diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index c3e039492aa4..ab1e3c9d9d7a 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -74,9 +74,6 @@ type GetReportPrimaryActionParams = { reportMetadata?: OnyxEntry; isChatReportArchived: boolean; invoiceReceiverPolicy?: Policy; - isPaidAnimationRunning?: boolean; - isApprovedAnimationRunning?: boolean; - isSubmittingAnimationRunning?: boolean; }; type IsPrimaryPayActionParams = { @@ -461,9 +458,6 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf