From edcc094f63070454e941cdca97241ec796e2230a Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 7 Apr 2026 16:15:11 +0200 Subject: [PATCH 01/38] initial refactor of secondary actions --- .../MoneyReportHeaderSecondaryActions.tsx | 395 ++++++++++++ .../MoneyReportHeaderSelectionDropdown.tsx | 425 +++++++++++++ .../MoneyReportHeaderActions/index.tsx | 271 +++++++++ .../MoneyReportHeaderActions/types.ts | 35 ++ src/hooks/useExpenseActions.ts | 563 ++++++++++++++++++ src/hooks/useExportActions.ts | 267 +++++++++ src/hooks/useHoldRejectActions.ts | 142 +++++ src/hooks/useLifecycleActions.tsx | 379 ++++++++++++ 8 files changed, 2477 insertions(+) create mode 100644 src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx create mode 100644 src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx create mode 100644 src/components/MoneyReportHeaderActions/index.tsx create mode 100644 src/components/MoneyReportHeaderActions/types.ts create mode 100644 src/hooks/useExpenseActions.ts create mode 100644 src/hooks/useExportActions.ts create mode 100644 src/hooks/useHoldRejectActions.ts create mode 100644 src/hooks/useLifecycleActions.tsx diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx new file mode 100644 index 000000000000..3ca1ace45e6e --- /dev/null +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -0,0 +1,395 @@ +import {isUserValidatedSelector} from '@selectors/Account'; +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import truncate from 'lodash/truncate'; +import React, {useContext} 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 type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; +import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; +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 useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; +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, + hasOnlyNonReimbursableTransactions, + hasViolations as hasViolationsReportUtils, + isAllowedToApproveExpenseReport, + isInvoiceReport as isInvoiceReportUtil, + isIOUReport as isIOUReportUtil, + navigateToDetailsPage, +} from '@libs/ReportUtils'; +import {canApproveIOU, canIOUBePaid as canIOUBePaidAction, payInvoice, payMoneyRequest} from '@userActions/IOU'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; + +type MoneyReportHeaderSecondaryActionsProps = { + reportID: string | undefined; + primaryAction: ValueOf | ''; + onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; + onPDFModalOpen: () => void; + onHoldEducationalOpen: () => void; + onRejectModalOpen: (action: RejectModalAction) => void; + startAnimation: () => void; + startApprovedAnimation: () => void; + startSubmittingAnimation: () => void; + dropdownMenuRef?: React.RefObject; +}; + +function MoneyReportHeaderSecondaryActions({ + reportID, + primaryAction, + onHoldMenuOpen, + onPDFModalOpen, + onHoldEducationalOpen, + onRejectModalOpen, + startAnimation, + startApprovedAnimation, + startSubmittingAnimation, + dropdownMenuRef, +}: MoneyReportHeaderSecondaryActionsProps) { + const {translate, localeCompare} = useLocalize(); + const kycWallRef = useContext(KYCWallContext); + + // Onyx 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 [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 [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 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 {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 {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment} = useNonReimbursablePaymentModal(moneyRequestReport, allTransactions); + + const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { + if (!type || !chatReport) { + return; + } + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); + return; + } + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + } else if (isAnyTransactionOnHold) { + 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(() => onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined)); + } else { + onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); + } + } 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) { + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline: false, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } + } + }; + + // Payment button derivations + const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, false, undefined, invoiceReceiverPolicy); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, allTransactions); + const onlyShowPayElsewhere = + !reportHasOnlyNonReimbursableTransactions && + !canIOUBePaid && + canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, true, undefined, invoiceReceiverPolicy); + const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere || (reportHasOnlyNonReimbursableTransactions && (moneyRequestReport?.total ?? 0) !== 0); + const shouldShowApproveButton = canApproveIOU(moneyRequestReport, policy, reportMetadata, allTransactions); + const shouldDisableApproveButton = 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, + onlyShowPayElsewhere, + }); + + const activeAdminPolicies = useActiveAdminPolicies(); + + const workspacePolicyOptions = (() => { + 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.reportID, accountID ?? CONST.DEFAULT_NUMBER_ID); + if (!canUseBusinessBankAccount) { + return []; + } + return 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) => onHoldMenuOpen(requestType), + }); + + const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID}); + + const holdRejectActions = useHoldRejectActions({ + reportID, + onHoldEducationalOpen, + onRejectModalOpen, + }); + + const {exportActionEntries} = useExportActions({ + reportID, + onPDFModalOpen, + }); + + // Compute list of applicable secondary action keys + const secondaryActions = (() => { + if (!moneyRequestReport) { + return []; + } + return getSecondaryReportActions({ + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID: accountID, + report: moneyRequestReport, + chatReport, + reportTransactions: nonPendingDeleteTransactions, + originalTransaction: undefined, + 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, + policy, + onPress: confirmPayment, + currentAccountID: accountID, + currentEmail: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + isUserValidated, + confirmApproval: () => lifecycleActions.confirmApproval(), + iouReport: moneyRequestReport, + iouReportNextStep: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + }); + }; + + 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..cbe782e89d03 --- /dev/null +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -0,0 +1,425 @@ +import {isUserValidatedSelector} from '@selectors/Account'; +import React, {useContext, 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 type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; +import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import type {PaymentActionParams} from '@components/SettlementButton/types'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useEnvironment from '@hooks/useEnvironment'; +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 usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import usePaymentOptions from '@hooks/usePaymentOptions'; +import usePermissions from '@hooks/usePermissions'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getAllNonDeletedTransactions, getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; +import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; +import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID} from '@libs/ReportActionsUtils'; +import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; +import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; +import {isTransactionPendingDelete} from '@libs/TransactionUtils'; +import {canIOUBePaid as canIOUBePaidAction, payMoneyRequest} from '@userActions/IOU'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; + +const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight'] as const; + +type MoneyReportHeaderSelectionDropdownProps = { + reportID: string | undefined; + primaryAction: ValueOf | ''; + onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; + onRejectModalOpen: (action: RejectModalAction) => void; + startApprovedAnimation: () => void; + startSubmittingAnimation: () => void; + wrapperStyle?: StyleProp; +}; + +function MoneyReportHeaderSelectionDropdown({ + reportID, + primaryAction, + onHoldMenuOpen, + onRejectModalOpen, + startApprovedAnimation, + startSubmittingAnimation, + wrapperStyle, +}: MoneyReportHeaderSelectionDropdownProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {isProduction} = useEnvironment(); + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + + const {selectedTransactionIDs} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); + + 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 [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + 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 {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + const allReportTransactions = useReportTransactionsCollection(reportID); + + const allTransactionValues = Object.values(reportTransactions); + const transactions = allTransactionValues; + const nonPendingDeleteTransactions = allTransactionValues.filter((t) => !isTransactionPendingDelete(t)); + + 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 {accountID, email, login: currentUserLogin} = useCurrentUserPersonalDetails(); + + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); + + const kycWallRef = useContext(KYCWallContext); + + const {showConfirmModal} = useConfirmModal(); + + const isSelectionModePaymentRef = useRef(false); + + const expensifyIcons = useMemoizedLazyExpensifyIcons(PAYMENT_ICONS); + + const {beginExportWithTemplate, showOfflineModal, showDownloadErrorModal} = useExportActions({ + reportID, + onPDFModalOpen: () => {}, + }); + + const {confirmApproval, handleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval} = useLifecycleActions({ + reportID, + startApprovedAnimation, + startSubmittingAnimation, + onHoldMenuOpen, + }); + + const { + options: originalSelectedTransactionsOptions, + handleDeleteTransactions, + handleDeleteTransactionsWithNavigation, + } = useSelectedTransactionsActions({ + report: moneyRequestReport, + reportActions, + allTransactionsLength: transactions.length, + session, + onExportFailed: showDownloadErrorModal, + onExportOffline: showOfflineModal, + policy, + beginExportWithTemplate, + isOnSearch: false, + }); + + const computedSecondaryActions = moneyRequestReport + ? getSecondaryReportActions({ + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID: accountID, + report: moneyRequestReport, + chatReport, + reportTransactions: nonPendingDeleteTransactions, + originalTransaction: undefined, + violations, + bankAccountList, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, + policies: allPolicies, + outstandingReportsByPolicyID, + isChatReportArchived: false, + }) + : []; + + 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 = () => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return true; + } + if (isAccountLocked) { + showLockedAccountModal(); + return true; + } + if (!isUserValidated) { + handleUnvalidatedAccount(moneyRequestReport); + return true; + } + return false; + }; + + const canAllowSettlement = !!moneyRequestReport; + + const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions); + + const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, false, undefined, invoiceReceiverPolicy); + + const shouldShowPayButton = hasPayAction && canIOUBePaid; + + const confirmPayment = ({paymentType: type, methodID}: PaymentActionParams) => { + if (!type || !chatReport) { + return; + } + isSelectionModePaymentRef.current = true; + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + payMoneyRequest({ + paymentType: type, + chatReport, + iouReport: moneyRequestReport, + introSelected, + iouReportCurrentNextStepDeprecated: nextStep, + currentUserAccountID: accountID, + activePolicy: undefined, + policy, + betas, + isSelfTourViewed: undefined, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + onPaid: () => {}, + }); + clearSelectedTransactions(true); + }; + + const paymentButtonOptions = usePaymentOptions({ + currency: moneyRequestReport?.currency, + iouReport: moneyRequestReport, + chatReportID: chatReport?.reportID, + formattedAmount: totalAmount, + policyID: moneyRequestReport?.policyID, + onPress: ({paymentType: type, methodID}: PaymentActionParams) => { + if (!type || !chatReport) { + return; + } + isSelectionModePaymentRef.current = true; + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + } else { + onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); + } + }, + shouldHidePaymentOptions: !shouldShowPayButton, + shouldShowApproveButton: false, + shouldDisableApproveButton: false, + onlyShowPayElsewhere: false, + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const buildPaymentSubMenuItems = (_onWorkspaceSelected: () => void) => { + return Object.values(paymentButtonOptions); + }; + + 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) { + const backToRoute = chatReport?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID) : 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 = (() => { + if (isProduction) { + 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(() => { + isSelectionModePaymentRef.current = true; + if (checkForNecessaryAction()) { + return; + } + kycWallRef.current?.continueAction?.({}); + }), + onSelected: () => { + isSelectionModePaymentRef.current = true; + }, + }); + } + return actions; + })(); + /* eslint-enable react-hooks/refs */ + + 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 { + onRejectModalOpen(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK); + } + }, + }; + } + return option; + }); + + const selectedTransactionsOptions = allExpensesSelected && selectionModeReportLevelActions.length ? [...selectionModeReportLevelActions, ...mappedOptions] : mappedOptions; + + const popoverUseScrollView = shouldPopoverUseScrollView(selectedTransactionsOptions); + + const hasPayInSelectionMode = allExpensesSelected && hasPayAction; + + const onSelectionModePaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { + isSelectionModePaymentRef.current = true; + if (checkForNecessaryAction()) { + return; + } + selectPaymentType({ + event, + iouPaymentType, + triggerKYCFlow, + policy, + onPress: confirmPayment, + currentAccountID: accountID, + currentEmail: email ?? '', + hasViolations: false, + isASAPSubmitBetaEnabled, + isUserValidated, + confirmApproval: () => confirmApproval(), + iouReport: moneyRequestReport, + iouReportNextStep: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + }); + }; + + const selectionModeKYCSuccess = (type?: PaymentMethodType) => { + isSelectionModePaymentRef.current = true; + confirmPayment({paymentType: type}); + }; + + if (!selectedTransactionsOptions.length || transactionThreadReportID) { + return null; + } + + if (hasPayInSelectionMode) { + return ( + + ); + } + + return ( + 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..fd08e03cd8e4 --- /dev/null +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -0,0 +1,271 @@ +import {useRef, useState} from 'react'; +import {InteractionManager, View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; +import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; +import MoneyReportHeaderEducationalModals from '@components/MoneyReportHeaderEducationalModals'; +import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; +import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; +import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; +import ReportPDFDownloadModal from '@components/ReportPDFDownloadModal'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import useExportAgainModal from '@hooks/useExportAgainModal'; +import useNetwork from '@hooks/useNetwork'; +import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import getPlatform from '@libs/getPlatform'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; +import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; + +type MoneyReportHeaderActionsProps = { + reportID: string | undefined; + primaryAction: ValueOf | ValueOf | ''; + isPaidAnimationRunning: boolean; + isApprovedAnimationRunning: boolean; + isSubmittingAnimationRunning: boolean; + stopAnimation: () => void; + startAnimation: () => void; + startApprovedAnimation: () => void; + startSubmittingAnimation: () => void; +}; + +/** + * 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, + isPaidAnimationRunning, + isApprovedAnimationRunning, + isSubmittingAnimationRunning, + stopAnimation, + startAnimation, + startApprovedAnimation, + startSubmittingAnimation, +}: MoneyReportHeaderActionsProps) { + const styles = useThemeStyles(); + + // ── Modal state ── + const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); + const [paymentType, setPaymentType] = useState(); + const [requestType, setRequestType] = useState(); + const [selectedVBBAToPayFromHoldMenu, setSelectedVBBAToPayFromHoldMenu] = useState(undefined); + const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); + const [rejectModalAction, setRejectModalAction] = useState(null); + const [isPDFModalVisible, setIsPDFModalVisible] = useState(false); + const isSelectionModePaymentRef = useRef(false); + const dropdownMenuRef = useRef(null) as React.RefObject; + + // ── Layout ── + // 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; + + // ── Onyx data ── + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + const {isOffline} = useNetwork(); + + // ── Transactions & report actions (for hold menu + educational modal) ── + const allReportTransactions = useReportTransactionsCollection(reportID); + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + 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 [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); + + const {transactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + const transactionsList = Object.values(transactions); + const transactionIDs = transactionsList.map((t) => t.transactionID); + + // Transaction for educational modal + 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)}`); + + // ── Hold menu data ── + const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(moneyRequestReport?.reportID); + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, true); + const {nonReimbursablePaymentErrorDecisionModal, showNonReimbursablePaymentErrorModal} = useNonReimbursablePaymentModal(moneyRequestReport, transactionsList); + + // ── Export modal ── + const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); + + // ── Selection mode ── + const {selectedTransactionIDs} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); + const shouldShowSelectedTransactionsButton = !!selectedTransactionIDs.length && !transactionThreadReportID; + + const primaryActionForSecondary = narrowPrimaryAction(primaryAction); + + // ── Callbacks ── + const onHoldMenuOpen = (actionType: string, payType?: PaymentMethodType, methodID?: number) => { + setRequestType(actionType as ActionHandledType); + setPaymentType(payType); + setSelectedVBBAToPayFromHoldMenu(payType === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); + 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(() => setIsHoldMenuVisible(true)); + } else { + setIsHoldMenuVisible(true); + } + }; + + // ── Sub-elements ── + const primaryActionElement = ( + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} + /> + ); + + const secondaryActionsElement = ( + setIsPDFModalVisible(true)} + onHoldEducationalOpen={() => setIsHoldEducationalModalVisible(true)} + onRejectModalOpen={(action) => setRejectModalAction(action)} + startAnimation={startAnimation} + startApprovedAnimation={startApprovedAnimation} + startSubmittingAnimation={startSubmittingAnimation} + dropdownMenuRef={dropdownMenuRef} + /> + ); + + const selectionDropdownElement = ( + setRejectModalAction(action)} + startApprovedAnimation={startApprovedAnimation} + startSubmittingAnimation={startSubmittingAnimation} + wrapperStyle={shouldDisplayNarrowMoreButton ? undefined : styles.w100} + /> + ); + + const actionButtons = (() => { + if (shouldShowSelectedTransactionsButton) { + return shouldDisplayNarrowMoreButton ? ( + {selectionDropdownElement} + ) : ( + {selectionDropdownElement} + ); + } + + if (shouldDisplayNarrowMoreButton) { + return ( + + {primaryActionElement} + {secondaryActionsElement} + + ); + } + + return ( + + {!!primaryAction && {primaryActionElement}} + {secondaryActionsElement} + + ); + })(); + + return ( + <> + {actionButtons} + + {isHoldMenuVisible && requestType !== undefined && ( + { + setSelectedVBBAToPayFromHoldMenu(undefined); + setIsHoldMenuVisible(false); + isSelectionModePaymentRef.current = false; + }} + isVisible={isHoldMenuVisible} + paymentType={paymentType} + methodID={paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? selectedVBBAToPayFromHoldMenu : undefined} + chatReport={chatReport} + moneyRequestReport={moneyRequestReport} + hasNonHeldExpenses={!hasOnlyHeldExpenses} + startAnimation={() => { + if (isSelectionModePaymentRef.current) { + clearSelectedTransactions(true); + return; + } + if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { + startApprovedAnimation(); + } else { + startAnimation(); + } + }} + transactionCount={transactionIDs.length} + transactions={transactionsList} + onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} + /> + )} + setIsHoldEducationalModalVisible(false)} + onRejectModalDismissed={() => setRejectModalAction(null)} + /> + {nonReimbursablePaymentErrorDecisionModal} + setIsPDFModalVisible(false)} + /> + + ); +} + +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..f622992007fd --- /dev/null +++ b/src/components/MoneyReportHeaderActions/types.ts @@ -0,0 +1,35 @@ +import type {StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type CONST from '@src/CONST'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; + +type AnimationCallbacks = { + isPaidAnimationRunning: boolean; + isApprovedAnimationRunning: boolean; + isSubmittingAnimationRunning: boolean; + stopAnimation: () => void; + startAnimation: () => void; + startApprovedAnimation: () => void; + startSubmittingAnimation: () => void; +}; + +type ModalTriggers = { + onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; + onPDFModalOpen: () => void; + onHoldEducationalOpen: () => void; + onRejectModalOpen: (action: RejectModalAction) => void; +}; + +type SecondaryActionEntry = DropdownOption> & Pick; + +type MoneyReportHeaderActionsProps = AnimationCallbacks & { + reportID: string | undefined; + primaryAction: ValueOf | ValueOf | ''; + /** Style to apply when rendered inline (narrow layout inside HeaderWithBackButton) */ + style?: StyleProp; +}; + +export type {AnimationCallbacks, ModalTriggers, SecondaryActionEntry, MoneyReportHeaderActionsProps}; diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts new file mode 100644 index 000000000000..cc718aedbbce --- /dev/null +++ b/src/hooks/useExpenseActions.ts @@ -0,0 +1,563 @@ +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 {PopoverMenuItem} from '@components/PopoverMenu'; +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 {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {isPolicyAccessible} from '@libs/PolicyUtils'; +import {getFilteredReportActionsForReportView, getIOUActionForTransactionID, getOneTransactionThreadReportID, 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, isDistanceRequest, isTransactionPendingDelete} from '@libs/TransactionUtils'; +import {getNavigationUrlOnMoneyRequestDelete, startMoneyRequest} from '@userActions/IOU'; +import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES 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 useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePaginatedReportActions from './usePaginatedReportActions'; +import usePermissions from './usePermissions'; +import useReportIsArchived from './useReportIsArchived'; +import useReportTransactionsCollection from './useReportTransactionsCollection'; +import useThrottledButtonState from './useThrottledButtonState'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; + +type SecondaryActionEntry = DropdownOption> & Pick; + +type UseExpenseActionsParams = { + reportID: string | undefined; +}; + +type UseExpenseActionsReturn = { + actions: Partial, SecondaryActionEntry>>; + addExpenseDropdownOptions: Array>; + handleOptionsMenuHide: () => void; + isDuplicateReportActive: boolean; + wasDuplicateReportTriggered: React.MutableRefObject; +}; + +function useExpenseActions({reportID}: UseExpenseActionsParams): UseExpenseActionsReturn { + const {translate, localeCompare} = useLocalize(); + const {isOffline} = useNetwork(); + 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)}`); + + // Report actions + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + // Transactions + const allReportTransactions = useReportTransactionsCollection(reportID); + 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 {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); + + // Transaction / original transaction + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); + const requestParentReportAction = (() => { + if (!reportActions || !transactionThreadReport?.parentReportActionID) { + return null; + } + return ( + 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 [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 = (() => { + if (!transaction?.comment?.originalTransactionID) { + return false; + } + const children = getChildTransactions(allTransactions, allReports, transaction.comment.originalTransactionID); + return children.length > 1; + })(); + const isReportOpen = isOpenReport(moneyRequestReport); + const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); + + // Duplicate report throttle + const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); + const wasDuplicateReportTriggered = useRef(false); + + const handleOptionsMenuHide = () => { + wasDuplicateReportTriggered.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. + + // canMoveSingleExpense + const canMoveSingleExpense = (() => { + 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; + })(); + + // Duplicate expense: unsupported / shouldClose flags + const isDistanceExpenseUnsupportedForDuplicating = !!( + isDistanceRequest(transaction) && + (isArchivedReport || isChatReportArchived || (activePolicyExpenseChat && (isDM(chatReport) || isSelfDM(chatReport)))) + ); + const shouldDuplicateCloseModalOnSelect = isDistanceExpenseUnsupportedForDuplicating || activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID; + + // Dropdown ref is owned by the orchestrator — no reset callback needed here. + const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(); + + // Policy tags for duplicate report + const targetPolicyTags = defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {}; + + // duplicateExpenseTransaction + 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: undefined, + recentWaypoints, + targetPolicyTags, + }); + } + }; + + // addExpenseDropdownOptions + 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', + ]); + + // Route is not available in a hook — isReportInSearch must be inferred by the caller and passed in + // if needed. For now we default to false (safe: no search hash applied). + const isReportInSearch = false; + + const actions: Partial, SecondaryActionEntry>> = { + [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, + value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE, + onSelected: () => { + if (isDistanceExpenseUnsupportedForDuplicating) { + showConfirmModal({ + title: translate('common.duplicateExpense'), + prompt: translate('iou.cannotDuplicateDistanceExpense'), + 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, + 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: undefined, + 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 ?? 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 = 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, + wasDuplicateReportTriggered, + }; +} + +export default useExpenseActions; +export type {UseExpenseActionsParams, UseExpenseActionsReturn, SecondaryActionEntry}; diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts new file mode 100644 index 000000000000..f4f79f72cf08 --- /dev/null +++ b/src/hooks/useExportActions.ts @@ -0,0 +1,267 @@ +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 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 usePolicy from './usePolicy'; +import useThemeStyles from './useThemeStyles'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; + +type UseExportActionsParams = { + reportID: string | undefined; + 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, onPDFModalOpen}: UseExportActionsParams): UseExportActionsReturn { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + 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 policyFromHook = usePolicy(moneyRequestReport?.policyID); + + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + const transactionIDs = Object.values(reportTransactions).map((t) => t.transactionID); + + const connectedIntegration = getValidConnectedIntegration(policyFromHook); + const connectedIntegrationFallback = getConnectedIntegration(policyFromHook); + 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', + '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..93e3b00724b6 --- /dev/null +++ b/src/hooks/useHoldRejectActions.ts @@ -0,0 +1,142 @@ +import type {ValueOf} from 'type-fest'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, 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 {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePaginatedReportActions from './usePaginatedReportActions'; +import useReportTransactionsCollection from './useReportTransactionsCollection'; + +type SecondaryActionEntry = DropdownOption> & Pick; + +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); + + // Report data + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + + // Report actions — needed to derive transactionThreadReportID and requestParentReportAction + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + // Transactions — needed for getOneTransactionThreadReportID + 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)}`); + + // Look up the parent report actions collection so we can resolve requestParentReportAction by ID directly + const [reportActionsForParent] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`, {canEvict: false}); + const requestParentReportAction = transactionThreadReport?.parentReportActionID ? reportActionsForParent?.[transactionThreadReport.parentReportActionID] : undefined; + + // Transaction — derive ID from the IOU action, fall back to DEFAULT_NUMBER_ID so the Onyx key is always valid + const iouTransactionID = isMoneyRequestAction(requestParentReportAction) + ? (getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) + : CONST.DEFAULT_NUMBER_ID; + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${iouTransactionID}`); + + // Dismissed explanation flags + const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); + const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); + + // Derived booleans + const isReportSubmitter = isCurrentUserSubmitter(moneyRequestReport); + 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, SecondaryActionEntry}; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx new file mode 100644 index 000000000000..5dcf1a54e0d9 --- /dev/null +++ b/src/hooks/useLifecycleActions.tsx @@ -0,0 +1,379 @@ +import {delegateEmailSelector} from '@selectors/Account'; +import type {ValueOf} from 'type-fest'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {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 {approveMoneyRequest, cancelPayment, reopenReport, retractReport, submitReport, unapproveExpenseReport} from '@userActions/IOU'; +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: string) => void; +}; + +type SecondaryActionEntry = DropdownOption> & Pick; + +type UseLifecycleActionsResult = { + actions: Record; + confirmApproval: (skipAnimation?: boolean) => void; + handleSubmitReport: (skipAnimation?: boolean) => void; + shouldBlockSubmit: boolean; + isBlockSubmitDueToPreventSelfApproval: boolean; +}; + +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}${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 shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'CircularArrowBackwards', 'Clear']); + + // Derived values + 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); + return; + } + if (!skipAnimation) { + startApprovedAnimation(); + } + approveMoneyRequest({ + expenseReport: moneyRequestReport, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + expenseReportCurrentNextStepDeprecated: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + full: true, + onApproved: () => { + if (skipAnimation) { + return; + } + startApprovedAnimation(); + }, + }); + }; + + 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, + }); + } + }; + + 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, SecondaryActionEntry}; From caee1001e1a7f6c2036eafeae015d83184befbbe Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 7 Apr 2026 16:22:33 +0200 Subject: [PATCH 02/38] Extract MoneyReportHeaderHoldMenu gate with early return --- .../MoneyReportHeaderActionsBar.tsx | 72 +++++++++++++++++++ .../MoneyReportHeaderHoldMenu.tsx | 51 +++++++++++++ .../MoneyReportHeaderActions/index.tsx | 64 ++++++++--------- 3 files changed, 154 insertions(+), 33 deletions(-) create mode 100644 src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx create mode 100644 src/components/MoneyReportHeaderActions/MoneyReportHeaderHoldMenu.tsx diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx new file mode 100644 index 000000000000..865df5d4f460 --- /dev/null +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx @@ -0,0 +1,72 @@ +import type {ReactNode} from 'react'; +import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type MoneyReportHeaderActionsBarProps = { + primaryAction: ValueOf | ValueOf | ''; + shouldDisplayNarrowMoreButton: boolean; + shouldShowSelectedTransactionsButton: boolean; + primaryActionElement: ReactNode; + secondaryActionsElement: ReactNode; + selectionDropdownElement: ReactNode; +}; + +function MoneyReportHeaderPrimaryActionSlot({ + primaryAction, + children, +}: { + primaryAction: MoneyReportHeaderActionsBarProps['primaryAction']; + children: ReactNode; +}) { + const styles = useThemeStyles(); + + if (!primaryAction) { + return null; + } + + return {children}; +} + +/** + * Lays out primary action, secondary (more) menu, and search selection dropdown + * based on selection mode and narrow vs wide header constraints. + */ +function MoneyReportHeaderActionsBar({ + primaryAction, + shouldDisplayNarrowMoreButton, + shouldShowSelectedTransactionsButton, + primaryActionElement, + secondaryActionsElement, + selectionDropdownElement, +}: MoneyReportHeaderActionsBarProps) { + const styles = useThemeStyles(); + + if (shouldShowSelectedTransactionsButton) { + if (shouldDisplayNarrowMoreButton) { + return {selectionDropdownElement}; + } + + return {selectionDropdownElement}; + } + + if (shouldDisplayNarrowMoreButton) { + return ( + + {primaryActionElement} + {secondaryActionsElement} + + ); + } + + return ( + + {primaryActionElement} + {secondaryActionsElement} + + ); +} + +export default MoneyReportHeaderActionsBar; +export type {MoneyReportHeaderActionsBarProps}; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderHoldMenu.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderHoldMenu.tsx new file mode 100644 index 000000000000..57b861af6780 --- /dev/null +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderHoldMenu.tsx @@ -0,0 +1,51 @@ +import type {ComponentProps} from 'react'; +import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; + +type MoneyReportHeaderHoldMenuProps = ComponentProps; + +/** + * Renders the hold / pay-or-approve decision flow only when the menu is open and a request type is set. + * Keeps the parent free of `{visible && }` conditional rendering. + */ +function MoneyReportHeaderHoldMenu({ + chatReport, + fullAmount, + isVisible, + moneyRequestReport, + nonHeldAmount, + onClose, + paymentType, + methodID, + requestType, + transactionCount, + startAnimation, + hasNonHeldExpenses, + onNonReimbursablePaymentError, + transactions, +}: MoneyReportHeaderHoldMenuProps) { + if (!isVisible || requestType === undefined) { + return null; + } + + return ( + + ); +} + +export default MoneyReportHeaderHoldMenu; +export type {MoneyReportHeaderHoldMenuProps}; diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index fd08e03cd8e4..673d3f629ad7 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -5,7 +5,6 @@ import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; import MoneyReportHeaderEducationalModals from '@components/MoneyReportHeaderEducationalModals'; import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; -import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import ReportPDFDownloadModal from '@components/ReportPDFDownloadModal'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; @@ -28,6 +27,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import MoneyReportHeaderHoldMenu from './MoneyReportHeaderHoldMenu'; import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; @@ -216,38 +216,36 @@ function MoneyReportHeaderActions({ <> {actionButtons} - {isHoldMenuVisible && requestType !== undefined && ( - { - setSelectedVBBAToPayFromHoldMenu(undefined); - setIsHoldMenuVisible(false); - isSelectionModePaymentRef.current = false; - }} - isVisible={isHoldMenuVisible} - paymentType={paymentType} - methodID={paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? selectedVBBAToPayFromHoldMenu : undefined} - chatReport={chatReport} - moneyRequestReport={moneyRequestReport} - hasNonHeldExpenses={!hasOnlyHeldExpenses} - startAnimation={() => { - if (isSelectionModePaymentRef.current) { - clearSelectedTransactions(true); - return; - } - if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { - startApprovedAnimation(); - } else { - startAnimation(); - } - }} - transactionCount={transactionIDs.length} - transactions={transactionsList} - onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} - /> - )} + { + setSelectedVBBAToPayFromHoldMenu(undefined); + setIsHoldMenuVisible(false); + isSelectionModePaymentRef.current = false; + }} + isVisible={isHoldMenuVisible} + paymentType={paymentType} + methodID={paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? selectedVBBAToPayFromHoldMenu : undefined} + chatReport={chatReport} + moneyRequestReport={moneyRequestReport} + hasNonHeldExpenses={!hasOnlyHeldExpenses} + startAnimation={() => { + if (isSelectionModePaymentRef.current) { + clearSelectedTransactions(true); + return; + } + if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { + startApprovedAnimation(); + } else { + startAnimation(); + } + }} + transactionCount={transactionIDs.length} + transactions={transactionsList} + onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} + /> Date: Wed, 8 Apr 2026 09:42:05 +0200 Subject: [PATCH 03/38] Deduplicate types, extract useTransactionThreadReport, wire up ActionsBar --- .../MoneyReportHeaderActionsBar.tsx | 2 +- .../MoneyReportHeaderSelectionDropdown.tsx | 15 +---- .../MoneyReportHeaderActions/index.tsx | 57 +++++-------------- src/hooks/useExpenseActions.ts | 29 ++-------- src/hooks/useHoldRejectActions.ts | 29 ++-------- src/hooks/useLifecycleActions.tsx | 8 +-- src/hooks/useTransactionThreadReport.ts | 40 +++++++++++++ 7 files changed, 70 insertions(+), 110 deletions(-) create mode 100644 src/hooks/useTransactionThreadReport.ts diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx index 865df5d4f460..6f61516fa606 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx @@ -2,7 +2,7 @@ import type {ReactNode} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; +import type CONST from '@src/CONST'; type MoneyReportHeaderActionsBarProps = { primaryAction: ValueOf | ValueOf | ''; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index cbe782e89d03..b533f14edfc9 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -22,17 +22,15 @@ 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 usePaymentOptions from '@hooks/usePaymentOptions'; import usePermissions from '@hooks/usePermissions'; -import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getAllNonDeletedTransactions, getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; +import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; -import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID} from '@libs/ReportActionsUtils'; import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; @@ -93,21 +91,14 @@ function MoneyReportHeaderSelectionDropdown({ `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, ); - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); - const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + const {transactionThreadReportID, reportActions} = useTransactionThreadReport(reportID); const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); - const allReportTransactions = useReportTransactionsCollection(reportID); const allTransactionValues = Object.values(reportTransactions); const transactions = allTransactionValues; const nonPendingDeleteTransactions = allTransactionValues.filter((t) => !isTransactionPendingDelete(t)); - 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 {accountID, email, login: currentUserLogin} = useCurrentUserPersonalDetails(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 673d3f629ad7..0a3d7f6714a1 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -1,5 +1,5 @@ import {useRef, useState} from 'react'; -import {InteractionManager, View} from 'react-native'; +import {InteractionManager} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; @@ -9,24 +9,22 @@ import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import ReportPDFDownloadModal from '@components/ReportPDFDownloadModal'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import useExportAgainModal from '@hooks/useExportAgainModal'; -import useNetwork from '@hooks/useNetwork'; import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; -import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getPlatform from '@libs/getPlatform'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import MoneyReportHeaderActionsBar from './MoneyReportHeaderActionsBar'; import MoneyReportHeaderHoldMenu from './MoneyReportHeaderHoldMenu'; import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; @@ -89,17 +87,9 @@ function MoneyReportHeaderActions({ // ── Onyx data ── const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); - const {isOffline} = useNetwork(); - // ── Transactions & report actions (for hold menu + educational modal) ── - const allReportTransactions = useReportTransactionsCollection(reportID); - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); - const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); - 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 [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); + // ── Transaction thread & report actions ── + const {transactionThreadReportID, transactionThreadReport, reportActions} = useTransactionThreadReport(reportID); const {transactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); const transactionsList = Object.values(transactions); @@ -186,35 +176,16 @@ function MoneyReportHeaderActions({ /> ); - const actionButtons = (() => { - if (shouldShowSelectedTransactionsButton) { - return shouldDisplayNarrowMoreButton ? ( - {selectionDropdownElement} - ) : ( - {selectionDropdownElement} - ); - } - - if (shouldDisplayNarrowMoreButton) { - return ( - - {primaryActionElement} - {secondaryActionsElement} - - ); - } - - return ( - - {!!primaryAction && {primaryActionElement}} - {secondaryActionsElement} - - ); - })(); - return ( <> - {actionButtons} + > & Pick; +import useTransactionThreadReport from './useTransactionThreadReport'; type UseExpenseActionsParams = { reportID: string | undefined; @@ -72,7 +67,6 @@ type UseExpenseActionsReturn = { function useExpenseActions({reportID}: UseExpenseActionsParams): UseExpenseActionsReturn { const {translate, localeCompare} = useLocalize(); - const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const {getCurrencyDecimals} = useCurrencyListActions(); @@ -86,17 +80,7 @@ function useExpenseActions({reportID}: UseExpenseActionsParams): UseExpenseActio const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); - // Report actions - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); - const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); - - // Transactions - const allReportTransactions = useReportTransactionsCollection(reportID); - 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 {transactionThreadReportID, transactionThreadReport, reportActions} = useTransactionThreadReport(reportID); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); @@ -110,9 +94,6 @@ function useExpenseActions({reportID}: UseExpenseActionsParams): UseExpenseActio } const currentTransaction = transactions.at(0); - - // Transaction / original transaction - const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); const requestParentReportAction = (() => { if (!reportActions || !transactionThreadReport?.parentReportActionID) { return null; @@ -560,4 +541,4 @@ function useExpenseActions({reportID}: UseExpenseActionsParams): UseExpenseActio } export default useExpenseActions; -export type {UseExpenseActionsParams, UseExpenseActionsReturn, SecondaryActionEntry}; +export type {UseExpenseActionsParams, UseExpenseActionsReturn}; diff --git a/src/hooks/useHoldRejectActions.ts b/src/hooks/useHoldRejectActions.ts index 93e3b00724b6..cb9be3a107f8 100644 --- a/src/hooks/useHoldRejectActions.ts +++ b/src/hooks/useHoldRejectActions.ts @@ -1,12 +1,10 @@ import type {ValueOf} from 'type-fest'; -import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; -import type {PopoverMenuItem} from '@components/PopoverMenu'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {changeMoneyRequestHoldStatus, isCurrentUserSubmitter, isDM} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -15,10 +13,7 @@ import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; -import usePaginatedReportActions from './usePaginatedReportActions'; -import useReportTransactionsCollection from './useReportTransactionsCollection'; - -type SecondaryActionEntry = DropdownOption> & Pick; +import useTransactionThreadReport from './useTransactionThreadReport'; type UseHoldRejectActionsParams = { reportID: string | undefined; @@ -39,24 +34,10 @@ function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpe const expensifyIcons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'ThumbsDown'] as const); - // Report data const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + const {transactionThreadReport} = useTransactionThreadReport(reportID); - // Report actions — needed to derive transactionThreadReportID and requestParentReportAction - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); - const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); - - // Transactions — needed for getOneTransactionThreadReportID - 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)}`); - - // Look up the parent report actions collection so we can resolve requestParentReportAction by ID directly const [reportActionsForParent] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`, {canEvict: false}); const requestParentReportAction = transactionThreadReport?.parentReportActionID ? reportActionsForParent?.[transactionThreadReport.parentReportActionID] : undefined; @@ -139,4 +120,4 @@ function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpe } export default useHoldRejectActions; -export type {UseHoldRejectActionsParams, UseHoldRejectActionsReturn, SecondaryActionEntry}; +export type {UseHoldRejectActionsParams, UseHoldRejectActionsReturn}; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 5dcf1a54e0d9..0e082f1efb06 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -1,9 +1,7 @@ import {delegateEmailSelector} from '@selectors/Account'; -import type {ValueOf} from 'type-fest'; -import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {ModalActions} from '@components/Modal/Global/ModalContext'; -import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; import {useSearchStateContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import {search} from '@libs/actions/Search'; @@ -45,8 +43,6 @@ type UseLifecycleActionsParams = { onHoldMenuOpen: (requestType: string) => void; }; -type SecondaryActionEntry = DropdownOption> & Pick; - type UseLifecycleActionsResult = { actions: Record; confirmApproval: (skipAnimation?: boolean) => void; @@ -376,4 +372,4 @@ function useLifecycleActions({reportID, startApprovedAnimation, startSubmittingA } export default useLifecycleActions; -export type {UseLifecycleActionsParams, UseLifecycleActionsResult, SecondaryActionEntry}; +export type {UseLifecycleActionsParams, UseLifecycleActionsResult}; 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; From 790214c7398f5986bf9f40e3e3ab821621abb197 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 10:03:22 +0200 Subject: [PATCH 04/38] ActionsBar renders sub-components directly, no ReactNode props --- .../MoneyReportHeaderActionsBar.tsx | 130 +++++++++++++----- .../MoneyReportHeaderActions/index.tsx | 82 ++--------- 2 files changed, 110 insertions(+), 102 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx index 6f61516fa606..204141363d0b 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx @@ -1,32 +1,51 @@ -import type {ReactNode} from 'react'; +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; +import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; +import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; +import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; import useThemeStyles from '@hooks/useThemeStyles'; -import type CONST from '@src/CONST'; +import CONST from '@src/CONST'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; +import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; type MoneyReportHeaderActionsBarProps = { + reportID: string | undefined; + chatReportID: string | undefined; primaryAction: ValueOf | ValueOf | ''; shouldDisplayNarrowMoreButton: boolean; shouldShowSelectedTransactionsButton: boolean; - primaryActionElement: ReactNode; - secondaryActionsElement: ReactNode; - selectionDropdownElement: ReactNode; -}; -function MoneyReportHeaderPrimaryActionSlot({ - primaryAction, - children, -}: { - primaryAction: MoneyReportHeaderActionsBarProps['primaryAction']; - children: ReactNode; -}) { - const styles = useThemeStyles(); + // Animation + isPaidAnimationRunning: boolean; + isApprovedAnimationRunning: boolean; + isSubmittingAnimationRunning: boolean; + stopAnimation: () => void; + startAnimation: () => void; + startApprovedAnimation: () => void; + startSubmittingAnimation: () => void; - if (!primaryAction) { - return null; - } + // Modal triggers + onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; + onExportModalOpen: () => void; + onPDFModalOpen: () => void; + onHoldEducationalOpen: () => void; + onRejectModalOpen: (action: RejectModalAction) => void; + + dropdownMenuRef: React.RefObject; +}; - return {children}; +/** + * 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: MoneyReportHeaderActionsBarProps['primaryAction']): ValueOf | '' { + if ((Object.values(CONST.REPORT.PRIMARY_ACTIONS) as string[]).includes(primaryAction)) { + return primaryAction as ValueOf; + } + return ''; } /** @@ -34,36 +53,77 @@ function MoneyReportHeaderPrimaryActionSlot({ * based on selection mode and narrow vs wide header constraints. */ function MoneyReportHeaderActionsBar({ + reportID, + chatReportID, primaryAction, shouldDisplayNarrowMoreButton, shouldShowSelectedTransactionsButton, - primaryActionElement, - secondaryActionsElement, - selectionDropdownElement, + isPaidAnimationRunning, + isApprovedAnimationRunning, + isSubmittingAnimationRunning, + stopAnimation, + startAnimation, + startApprovedAnimation, + startSubmittingAnimation, + onHoldMenuOpen, + onExportModalOpen, + onPDFModalOpen, + onHoldEducationalOpen, + onRejectModalOpen, + dropdownMenuRef, }: MoneyReportHeaderActionsBarProps) { const styles = useThemeStyles(); + const narrowedPrimaryAction = narrowPrimaryAction(primaryAction); + const selectionDropdownWrapperStyle: StyleProp = shouldDisplayNarrowMoreButton ? undefined : styles.w100; if (shouldShowSelectedTransactionsButton) { - if (shouldDisplayNarrowMoreButton) { - return {selectionDropdownElement}; - } - - return {selectionDropdownElement}; - } - - if (shouldDisplayNarrowMoreButton) { return ( - - {primaryActionElement} - {secondaryActionsElement} + + ); } return ( - - {primaryActionElement} - {secondaryActionsElement} + + {!!primaryAction && ( + + + + )} + ); } diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 0a3d7f6714a1..31b013e6adfb 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -4,7 +4,6 @@ import type {ValueOf} from 'type-fest'; import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; import MoneyReportHeaderEducationalModals from '@components/MoneyReportHeaderEducationalModals'; -import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import ReportPDFDownloadModal from '@components/ReportPDFDownloadModal'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; @@ -13,7 +12,6 @@ import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModa import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -26,8 +24,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import MoneyReportHeaderActionsBar from './MoneyReportHeaderActionsBar'; import MoneyReportHeaderHoldMenu from './MoneyReportHeaderHoldMenu'; -import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; -import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; type MoneyReportHeaderActionsProps = { reportID: string | undefined; @@ -41,17 +37,6 @@ type MoneyReportHeaderActionsProps = { startSubmittingAnimation: () => void; }; -/** - * 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, @@ -63,8 +48,6 @@ function MoneyReportHeaderActions({ startApprovedAnimation, startSubmittingAnimation, }: MoneyReportHeaderActionsProps) { - const styles = useThemeStyles(); - // ── Modal state ── const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); @@ -115,8 +98,6 @@ function MoneyReportHeaderActions({ const {clearSelectedTransactions} = useSearchActionsContext(); const shouldShowSelectedTransactionsButton = !!selectedTransactionIDs.length && !transactionThreadReportID; - const primaryActionForSecondary = narrowPrimaryAction(primaryAction); - // ── Callbacks ── const onHoldMenuOpen = (actionType: string, payType?: PaymentMethodType, methodID?: number) => { setRequestType(actionType as ActionHandledType); @@ -131,60 +112,27 @@ function MoneyReportHeaderActions({ } }; - // ── Sub-elements ── - const primaryActionElement = ( - triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} - /> - ); - - const secondaryActionsElement = ( - setIsPDFModalVisible(true)} - onHoldEducationalOpen={() => setIsHoldEducationalModalVisible(true)} - onRejectModalOpen={(action) => setRejectModalAction(action)} - startAnimation={startAnimation} - startApprovedAnimation={startApprovedAnimation} - startSubmittingAnimation={startSubmittingAnimation} - dropdownMenuRef={dropdownMenuRef} - /> - ); - - const selectionDropdownElement = ( - setRejectModalAction(action)} - startApprovedAnimation={startApprovedAnimation} - startSubmittingAnimation={startSubmittingAnimation} - wrapperStyle={shouldDisplayNarrowMoreButton ? undefined : styles.w100} - /> - ); - return ( <> triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} + onPDFModalOpen={() => setIsPDFModalVisible(true)} + onHoldEducationalOpen={() => setIsHoldEducationalModalVisible(true)} + onRejectModalOpen={(action) => setRejectModalAction(action)} + dropdownMenuRef={dropdownMenuRef} /> Date: Wed, 8 Apr 2026 10:13:11 +0200 Subject: [PATCH 05/38] Collapse ActionsBar into index, adopt upstream modals context --- .../MoneyReportHeaderActionsBar.tsx | 132 ------------- .../MoneyReportHeaderHoldMenu.tsx | 51 ----- .../MoneyReportHeaderActions/index.tsx | 187 +++++++----------- 3 files changed, 69 insertions(+), 301 deletions(-) delete mode 100644 src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx delete mode 100644 src/components/MoneyReportHeaderActions/MoneyReportHeaderHoldMenu.tsx diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx deleted file mode 100644 index 204141363d0b..000000000000 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderActionsBar.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; -import {View} from 'react-native'; -import type {ValueOf} from 'type-fest'; -import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; -import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; -import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; -import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; - -type MoneyReportHeaderActionsBarProps = { - reportID: string | undefined; - chatReportID: string | undefined; - primaryAction: ValueOf | ValueOf | ''; - shouldDisplayNarrowMoreButton: boolean; - shouldShowSelectedTransactionsButton: boolean; - - // Animation - isPaidAnimationRunning: boolean; - isApprovedAnimationRunning: boolean; - isSubmittingAnimationRunning: boolean; - stopAnimation: () => void; - startAnimation: () => void; - startApprovedAnimation: () => void; - startSubmittingAnimation: () => void; - - // Modal triggers - onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; - onExportModalOpen: () => void; - onPDFModalOpen: () => void; - onHoldEducationalOpen: () => void; - onRejectModalOpen: (action: RejectModalAction) => void; - - dropdownMenuRef: React.RefObject; -}; - -/** - * 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: MoneyReportHeaderActionsBarProps['primaryAction']): ValueOf | '' { - if ((Object.values(CONST.REPORT.PRIMARY_ACTIONS) as string[]).includes(primaryAction)) { - return primaryAction as ValueOf; - } - return ''; -} - -/** - * Lays out primary action, secondary (more) menu, and search selection dropdown - * based on selection mode and narrow vs wide header constraints. - */ -function MoneyReportHeaderActionsBar({ - reportID, - chatReportID, - primaryAction, - shouldDisplayNarrowMoreButton, - shouldShowSelectedTransactionsButton, - isPaidAnimationRunning, - isApprovedAnimationRunning, - isSubmittingAnimationRunning, - stopAnimation, - startAnimation, - startApprovedAnimation, - startSubmittingAnimation, - onHoldMenuOpen, - onExportModalOpen, - onPDFModalOpen, - onHoldEducationalOpen, - onRejectModalOpen, - dropdownMenuRef, -}: MoneyReportHeaderActionsBarProps) { - const styles = useThemeStyles(); - const narrowedPrimaryAction = narrowPrimaryAction(primaryAction); - const selectionDropdownWrapperStyle: StyleProp = shouldDisplayNarrowMoreButton ? undefined : styles.w100; - - if (shouldShowSelectedTransactionsButton) { - return ( - - - - ); - } - - return ( - - {!!primaryAction && ( - - - - )} - - - ); -} - -export default MoneyReportHeaderActionsBar; -export type {MoneyReportHeaderActionsBarProps}; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderHoldMenu.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderHoldMenu.tsx deleted file mode 100644 index 57b861af6780..000000000000 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderHoldMenu.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type {ComponentProps} from 'react'; -import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; - -type MoneyReportHeaderHoldMenuProps = ComponentProps; - -/** - * Renders the hold / pay-or-approve decision flow only when the menu is open and a request type is set. - * Keeps the parent free of `{visible && }` conditional rendering. - */ -function MoneyReportHeaderHoldMenu({ - chatReport, - fullAmount, - isVisible, - moneyRequestReport, - nonHeldAmount, - onClose, - paymentType, - methodID, - requestType, - transactionCount, - startAnimation, - hasNonHeldExpenses, - onNonReimbursablePaymentError, - transactions, -}: MoneyReportHeaderHoldMenuProps) { - if (!isVisible || requestType === undefined) { - return null; - } - - return ( - - ); -} - -export default MoneyReportHeaderHoldMenu; -export type {MoneyReportHeaderHoldMenuProps}; diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 31b013e6adfb..6821b66be701 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -1,29 +1,23 @@ -import {useRef, useState} from 'react'; -import {InteractionManager} from 'react-native'; +import {useRef} from 'react'; +import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; -import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; -import MoneyReportHeaderEducationalModals from '@components/MoneyReportHeaderEducationalModals'; -import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; -import ReportPDFDownloadModal from '@components/ReportPDFDownloadModal'; -import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import type {ActionHandledType} from '@components/Modal/Global/HoldMenuModalWrapper'; +import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; +import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; +import {useSearchStateContext} from '@components/Search/SearchContext'; import useExportAgainModal from '@hooks/useExportAgainModal'; -import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import getPlatform from '@libs/getPlatform'; -import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import MoneyReportHeaderActionsBar from './MoneyReportHeaderActionsBar'; -import MoneyReportHeaderHoldMenu from './MoneyReportHeaderHoldMenu'; +import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; +import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; type MoneyReportHeaderActionsProps = { reportID: string | undefined; @@ -37,6 +31,17 @@ type MoneyReportHeaderActionsProps = { startSubmittingAnimation: () => void; }; +/** + * 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, @@ -48,15 +53,7 @@ function MoneyReportHeaderActions({ startApprovedAnimation, startSubmittingAnimation, }: MoneyReportHeaderActionsProps) { - // ── Modal state ── - const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); - const [paymentType, setPaymentType] = useState(); - const [requestType, setRequestType] = useState(); - const [selectedVBBAToPayFromHoldMenu, setSelectedVBBAToPayFromHoldMenu] = useState(undefined); - const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); - const [rejectModalAction, setRejectModalAction] = useState(null); - const [isPDFModalVisible, setIsPDFModalVisible] = useState(false); - const isSelectionModePaymentRef = useRef(false); + const styles = useThemeStyles(); const dropdownMenuRef = useRef(null) as React.RefObject; // ── Layout ── @@ -71,116 +68,70 @@ function MoneyReportHeaderActions({ const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); - // ── Transaction thread & report actions ── - const {transactionThreadReportID, transactionThreadReport, reportActions} = useTransactionThreadReport(reportID); - - const {transactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); - const transactionsList = Object.values(transactions); - const transactionIDs = transactionsList.map((t) => t.transactionID); + // ── Transaction thread ── + const {transactionThreadReportID} = useTransactionThreadReport(reportID); - // Transaction for educational modal - 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 {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); - // ── Hold menu data ── - const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(moneyRequestReport?.reportID); - const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, true); - const {nonReimbursablePaymentErrorDecisionModal, showNonReimbursablePaymentErrorModal} = useNonReimbursablePaymentModal(moneyRequestReport, transactionsList); + const onHoldMenuOpen = (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => { + openHoldMenu({requestType: requestType as ActionHandledType, paymentType, methodID}); + }; - // ── Export modal ── const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); - // ── Selection mode ── const {selectedTransactionIDs} = useSearchStateContext(); - const {clearSelectedTransactions} = useSearchActionsContext(); const shouldShowSelectedTransactionsButton = !!selectedTransactionIDs.length && !transactionThreadReportID; - // ── Callbacks ── - const onHoldMenuOpen = (actionType: string, payType?: PaymentMethodType, methodID?: number) => { - setRequestType(actionType as ActionHandledType); - setPaymentType(payType); - setSelectedVBBAToPayFromHoldMenu(payType === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); - 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(() => setIsHoldMenuVisible(true)); - } else { - setIsHoldMenuVisible(true); - } - }; + const narrowedPrimaryAction = narrowPrimaryAction(primaryAction); + + if (shouldShowSelectedTransactionsButton) { + return ( + + + + ); + } return ( - <> - + {!!primaryAction && ( + + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} + /> + + )} + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} - onPDFModalOpen={() => setIsPDFModalVisible(true)} - onHoldEducationalOpen={() => setIsHoldEducationalModalVisible(true)} - onRejectModalOpen={(action) => setRejectModalAction(action)} dropdownMenuRef={dropdownMenuRef} /> - - { - setSelectedVBBAToPayFromHoldMenu(undefined); - setIsHoldMenuVisible(false); - isSelectionModePaymentRef.current = false; - }} - isVisible={isHoldMenuVisible} - paymentType={paymentType} - methodID={paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? selectedVBBAToPayFromHoldMenu : undefined} - chatReport={chatReport} - moneyRequestReport={moneyRequestReport} - hasNonHeldExpenses={!hasOnlyHeldExpenses} - startAnimation={() => { - if (isSelectionModePaymentRef.current) { - clearSelectedTransactions(true); - return; - } - if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { - startApprovedAnimation(); - } else { - startAnimation(); - } - }} - transactionCount={transactionIDs.length} - transactions={transactionsList} - onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} - /> - setIsHoldEducationalModalVisible(false)} - onRejectModalDismissed={() => setRejectModalAction(null)} - /> - {nonReimbursablePaymentErrorDecisionModal} - setIsPDFModalVisible(false)} - /> - + ); } From ec34c1f290c60d8de15b9a64fdd1cade52173a32 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 10:55:49 +0200 Subject: [PATCH 06/38] cleanup MoneyReportHeader --- src/components/MoneyReportHeader.tsx | 1839 +------------------------- 1 file changed, 35 insertions(+), 1804 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f436a183b1eb..e8b63c965121 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,61 +1,26 @@ 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 React, {useEffect, useMemo, useRef} from 'react'; +import {View} from 'react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; -import useDeleteTransactions from '@hooks/useDeleteTransactions'; -import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; -import useEnvironment from '@hooks/useEnvironment'; -import useExportAgainModal from '@hooks/useExportAgainModal'; -import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; import useNetwork from '@hooks/useNetwork'; -import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; 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 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 {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; import { @@ -65,108 +30,42 @@ import { buildOptimisticNextStepForStrictPolicyRuleViolations, getReportNextStep, } from '@libs/NextStepUtils'; -import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; -import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; -import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow, isPolicyAccessible, sortPoliciesByName} from '@libs/PolicyUtils'; +import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import { getFilteredReportActionsForReportView, - getIOUActionForTransactionID, getOneTransactionThreadReportID, getOriginalMessage, hasPendingDEWApprove, hasPendingDEWSubmit, - hasRequestFromCurrentAccount, isMoneyRequestAction, } from '@libs/ReportActionsUtils'; import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; -import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import { - canEditFieldOfMoneyRequest, - canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - changeMoneyRequestHoldStatus, - generateReportID, - getAddExpenseDropdownOptions, getAllReportActionsErrorsAndReportActionThatRequiresAttention, - getIntegrationIcon, - getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, - getNextApproverAccountID, - getPolicyExpenseChat, getReasonAndReportActionThatRequiresAttention, - hasHeldExpenses as hasHeldExpensesReportUtils, - hasOnlyNonReimbursableTransactions, - hasUpdatedTotal, - hasViolations as hasViolationsReportUtils, - isAllowedToApproveExpenseReport, - isCurrentUserSubmitter, - isDM, - isExported as isExportedUtils, isInvoiceReport as isInvoiceReportUtil, - isIOUReport as isIOUReportUtil, isOpenExpenseReport, - isOpenReport, isReportOwner, - isSelfDM, - navigateOnDeleteExpense, - navigateToDetailsPage, shouldBlockSubmitDueToStrictPolicyRules, } from '@libs/ReportUtils'; -import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; -import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import { - getChildTransactions, - getOriginalTransactionWithSplitInfo, - hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils, - hasCustomUnitOutOfPolicyViolation as hasCustomUnitOutOfPolicyViolationTransactionUtils, - isDistanceRequest, - isExpensifyCardTransaction, - isPending, - isPerDiemRequest, isTransactionPendingDelete, } from '@libs/TransactionUtils'; -import { - approveMoneyRequest, - canApproveIOU, - cancelPayment, - canIOUBePaid as canIOUBePaidAction, - getNavigationUrlOnMoneyRequestDelete, - payInvoice, - payMoneyRequest, - reopenReport, - retractReport, - startMoneyRequest, - submitReport, - unapproveExpenseReport, -} from '@userActions/IOU'; -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 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 type {ButtonWithDropdownMenuRef} from './ButtonWithDropdownMenu/types'; 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 MoneyReportHeaderPrimaryAction from './MoneyReportHeaderPrimaryAction'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkeleton'; import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; -import {usePersonalDetails} from './OnyxListItemProvider'; -import type {PopoverMenuItem} from './PopoverMenu'; import {useSearchActionsContext, useSearchStateContext} from './Search/SearchContext'; -import type {PaymentActionParams} from './SettlementButton/types'; -import Text from './Text'; type MoneyReportHeaderProps = { /** The reportID of the report currently being looked at */ @@ -211,11 +110,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt >(); 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); @@ -224,73 +118,14 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt 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', - '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 {isProduction} = useEnvironment(); - const exportTemplates = useMemo( - () => getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy), - [integrationsExportTemplates, csvExportLayouts, policy, translate], - ); + const {translate} = useLocalize(); const {areStrictPolicyRulesEnabled} = useStrictPolicyRules(); - const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const requestParentReportAction = useMemo(() => { if (!reportActions || !transactionThreadReport?.parentReportActionID) { @@ -299,8 +134,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt return reportActions.find((action): action is OnyxTypes.ReportAction => action.reportActionID === transactionThreadReport.parentReportActionID); }, [reportActions, transactionThreadReport?.parentReportActionID]); - const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(requestParentReportAction); - const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); const {transactions, nonPendingDeleteTransactions} = useMemo(() => { @@ -315,88 +148,31 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt return {transactions: all, nonPendingDeleteTransactions: filtered}; }, [reportTransactions]); - // 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 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 isDEWPolicy = hasDynamicExternalWorkflow(policy); - 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 [isDuplicateReportActive] = useThrottledButtonState(); const dropdownMenuRef = useRef(null); const wasDuplicateReportTriggered = useRef(false); - const handleOptionsMenuHide = useCallback(() => { - wasDuplicateReportTriggered.current = false; - }, []); - useEffect(() => { if (!isDuplicateReportActive || !wasDuplicateReportTriggered.current) { return; @@ -405,165 +181,22 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt dropdownMenuRef.current?.setIsMenuVisible(false); }, [isDuplicateReportActive]); - const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); const policyType = policy?.type; - 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 {selectedTransactionIDs} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; - const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment} = useNonReimbursablePaymentModal(moneyRequestReport, transactions); - 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, - } = 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 reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions); - const onlyShowPayElsewhere = useMemo(() => { - if (reportHasOnlyNonReimbursableTransactions) { - return false; - } - return !canIOUBePaid && getCanIOUBePaid(true); - }, [canIOUBePaid, getCanIOUBePaid, reportHasOnlyNonReimbursableTransactions]); - - const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere || (reportHasOnlyNonReimbursableTransactions && (moneyRequestReport?.total ?? 0) !== 0); - - const shouldShowApproveButton = useMemo( - () => (canApproveIOU(moneyRequestReport, policy, reportMetadata, transactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning, - [moneyRequestReport, policy, reportMetadata, transactions, hasOnlyPendingTransactions, isApprovedAnimationRunning], - ); - - const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); - const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportIDProp, moneyRequestReport?.chatReportID); @@ -603,145 +236,12 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt } const shouldShowNextStep = isFromPaidPolicy && !isInvoiceReport && !shouldShowStatusBar; - 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; - } - if (shouldBlockDirectPayment(type)) { - showNonReimbursablePaymentErrorModal(); - 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, - showNonReimbursablePaymentErrorModal, - shouldBlockDirectPayment, - 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; @@ -749,206 +249,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt 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, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - expenseReportCurrentNextStepDeprecated: nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - full: true, - onApproved: () => { - if (skipAnimation) { - return; - } - startApprovedAnimation(); - }, - }); - if (skipAnimation) { - clearSelectedTransactions(true); - } - } - }, - [ - policy, - isDelegateAccessRestricted, - showDelegateNoAccessModal, - isAnyTransactionOnHold, - openHoldMenu, - startApprovedAnimation, - moneyRequestReport, - accountID, - email, - hasViolations, - isASAPSubmitBetaEnabled, - nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - clearSelectedTransactions, - ownerBillingGracePeriodEnd, - ], - ); - - 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 ?? '', @@ -987,881 +287,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt 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.reportID, 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(() => { - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return true; - } - if (isAccountLocked) { - showLockedAccountModal(); - return true; - } - if (!isUserValidated) { - handleUnvalidatedAccount(moneyRequestReport); - return true; - } - return false; - }, [isDelegateAccessRestricted, showDelegateNoAccessModal, isAccountLocked, showLockedAccountModal, isUserValidated, moneyRequestReport]); - - const selectionModeReportLevelActions = useMemo(() => { - if (isProduction) { - 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; - }, [ - isProduction, - 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'); - } - - 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; @@ -1881,187 +307,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 hasPayInSelectionMode = allExpensesSelected && hasPayAction; - - const makePaymentSelectHandler = useCallback( - (fromSelectionMode: boolean) => (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { - if (fromSelectionMode) { - isSelectionModePaymentRef.current = true; - if (checkForNecessaryAction()) { - return; - } - } - selectPaymentType({ - event, - iouPaymentType, - triggerKYCFlow, - policy, - onPress: confirmPayment, - currentAccountID: accountID, - currentEmail: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - isUserValidated, - confirmApproval: () => confirmApproval(), - iouReport: moneyRequestReport, - iouReportNextStep: nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - }); - }, - [ - checkForNecessaryAction, - policy, - confirmPayment, - accountID, - email, - hasViolations, - isASAPSubmitBetaEnabled, - isUserValidated, - confirmApproval, - moneyRequestReport, - nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - ], - ); - - 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); @@ -2109,46 +354,32 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt openParentReportInCurrentTab > {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} - /> - )} - - ))} + {!shouldDisplayNarrowMoreButton && ( + + )} {shouldShowMoreContent && ( From 3e9c1ecf6e73f35f8e03c15174fdacb756f70709 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 11:33:27 +0200 Subject: [PATCH 07/38] Extract useReportPrimaryAction, remove animation knowledge from getReportPrimaryAction --- src/components/MoneyReportHeader.tsx | 80 +++++----------------------- src/hooks/useReportPrimaryAction.ts | 65 ++++++++++++++++++++++ src/libs/ReportPrimaryActionUtils.ts | 14 ----- 3 files changed, 79 insertions(+), 80 deletions(-) create mode 100644 src/hooks/useReportPrimaryAction.ts diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index e8b63c965121..ecb622b44ef2 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -10,6 +10,7 @@ import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import usePaymentAnimations from '@hooks/usePaymentAnimations'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; @@ -39,7 +40,6 @@ import { hasPendingDEWSubmit, isMoneyRequestAction, } from '@libs/ReportActionsUtils'; -import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; import { getAllReportActionsErrorsAndReportActionThatRequiresAttention, getReasonAndReportActionThatRequiresAttention, @@ -49,9 +49,6 @@ import { shouldBlockSubmitDueToStrictPolicyRules, } from '@libs/ReportUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import { - isTransactionPendingDelete, -} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -109,7 +106,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt | PlatformStackRouteProp >(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {login: currentUserLogin, accountID, email} = currentUserPersonalDetails; + const {accountID, email} = currentUserPersonalDetails; const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`); const {isOffline} = useNetwork(); const allReportTransactions = useReportTransactionsCollection(reportIDProp); @@ -119,8 +116,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${moneyRequestReport?.reportID}`); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${moneyRequestReport?.reportID}`); @@ -136,17 +131,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt 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 = Object.values(reportTransactions); const isBlockSubmitDueToStrictPolicyRules = useMemo(() => { return shouldBlockSubmitDueToStrictPolicyRules(moneyRequestReport?.reportID, violations, areStrictPolicyRulesEnabled, accountID, email ?? '', transactions); @@ -155,12 +140,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); - - const [invoiceReceiverPolicy] = useOnyx( - `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, - {}, - ); - const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const isDEWPolicy = hasDynamicExternalWorkflow(policy); @@ -184,9 +163,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const policyType = policy?.type; const isArchivedReport = useReportIsArchived(moneyRequestReport?.reportID); - const isChatReportArchived = useReportIsArchived(chatReport?.reportID); - - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${moneyRequestReport?.reportID}`); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); @@ -241,7 +217,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; const isSelectionModePaymentRef = useRef(false); - + useEffect(() => { if (selectedTransactionIDs.length !== 0) { return; @@ -249,44 +225,16 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt isSelectionModePaymentRef.current = false; }, [selectedTransactionIDs.length]); - 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, - ]); - + let runningAction: 'pay' | 'submit' | undefined; + if (isPaidAnimationRunning || isApprovedAnimationRunning) { + runningAction = 'pay'; + } else if (isSubmittingAnimationRunning) { + runningAction = 'submit'; + } + const primaryAction = useReportPrimaryAction({ + reportID: reportIDProp, + runningAction, + }); useEffect(() => { if (!transactionThreadReportID) { diff --git a/src/hooks/useReportPrimaryAction.ts b/src/hooks/useReportPrimaryAction.ts new file mode 100644 index 000000000000..94fd01e9b9e0 --- /dev/null +++ b/src/hooks/useReportPrimaryAction.ts @@ -0,0 +1,65 @@ +import type {ValueOf} from 'type-fest'; +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'; + +type UseReportPrimaryActionParams = { + reportID: string | undefined; + /** When an action is actively running (animating), skip computation and return that action directly. */ + runningAction?: 'pay' | 'submit'; +}; + +function useReportPrimaryAction({reportID, runningAction}: UseReportPrimaryActionParams): ValueOf | '' { + 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 (runningAction === 'pay') { + return CONST.REPORT.PRIMARY_ACTIONS.PAY; + } + if (runningAction === 'submit') { + 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; +export type {UseReportPrimaryActionParams}; diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 83d34a71f57a..e0d2f97be45e 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 = { @@ -455,9 +452,6 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf Date: Wed, 8 Apr 2026 11:58:31 +0200 Subject: [PATCH 08/38] Remove dead isSelectionModePaymentRef and unused selectedTransactionIDs --- src/components/MoneyReportHeader.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ecb622b44ef2..03eed53fa99b 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -62,7 +62,7 @@ import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkeleton'; import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; -import {useSearchActionsContext, useSearchStateContext} from './Search/SearchContext'; +import {useSearchActionsContext} from './Search/SearchContext'; type MoneyReportHeaderProps = { /** The reportID of the report currently being looked at */ @@ -166,7 +166,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const {selectedTransactionIDs} = useSearchStateContext(); const {clearSelectedTransactions} = useSearchActionsContext(); const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); @@ -216,15 +215,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; - const isSelectionModePaymentRef = useRef(false); - - useEffect(() => { - if (selectedTransactionIDs.length !== 0) { - return; - } - isSelectionModePaymentRef.current = false; - }, [selectedTransactionIDs.length]); - let runningAction: 'pay' | 'submit' | undefined; if (isPaidAnimationRunning || isApprovedAnimationRunning) { runningAction = 'pay'; From e81ea4cf9c2a7597c03fd6a9bb3c0c01e0b82fef Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 12:14:57 +0200 Subject: [PATCH 09/38] Restore clearSelectedTransactions and hold menu onConfirm callbacks --- .../MoneyReportHeaderSecondaryActions.tsx | 10 ++++++---- .../MoneyReportHeaderSelectionDropdown.tsx | 6 +++--- src/components/MoneyReportHeaderActions/index.tsx | 4 ++-- src/hooks/useLifecycleActions.tsx | 9 ++++++++- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 3ca1ace45e6e..50435e458ca5 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -58,7 +58,7 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; type MoneyReportHeaderSecondaryActionsProps = { reportID: string | undefined; primaryAction: ValueOf | ''; - onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; + onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number, onConfirm?: (full: boolean) => void) => void; onPDFModalOpen: () => void; onHoldEducationalOpen: () => void; onRejectModalOpen: (action: RejectModalAction) => void; @@ -149,9 +149,11 @@ function MoneyReportHeaderSecondaryActions({ 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(() => onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined)); + InteractionManager.runAfterInteractions(() => + onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, () => startAnimation()), + ); } else { - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); + onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, () => startAnimation()); } } else if (isInvoiceReport) { startAnimation(); @@ -275,7 +277,7 @@ function MoneyReportHeaderSecondaryActions({ reportID, startApprovedAnimation, startSubmittingAnimation, - onHoldMenuOpen: (requestType) => onHoldMenuOpen(requestType), + onHoldMenuOpen: (requestType) => onHoldMenuOpen(requestType, undefined, undefined, () => startApprovedAnimation()), }); const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID}); diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index b533f14edfc9..a9e08608878c 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -45,7 +45,7 @@ const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight'] as const; type MoneyReportHeaderSelectionDropdownProps = { reportID: string | undefined; primaryAction: ValueOf | ''; - onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; + onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number, onConfirm?: (full: boolean) => void) => void; onRejectModalOpen: (action: RejectModalAction) => void; startApprovedAnimation: () => void; startSubmittingAnimation: () => void; @@ -123,7 +123,7 @@ function MoneyReportHeaderSelectionDropdown({ reportID, startApprovedAnimation, startSubmittingAnimation, - onHoldMenuOpen, + onHoldMenuOpen: (requestType) => onHoldMenuOpen(requestType, undefined, undefined, () => clearSelectedTransactions(true)), }); const { @@ -233,7 +233,7 @@ function MoneyReportHeaderSelectionDropdown({ if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else { - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); + onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, () => clearSelectedTransactions(true)); } }, shouldHidePaymentOptions: !shouldShowPayButton, diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 6821b66be701..35b30c50566c 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -73,8 +73,8 @@ function MoneyReportHeaderActions({ const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); - const onHoldMenuOpen = (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => { - openHoldMenu({requestType: requestType as ActionHandledType, paymentType, methodID}); + const onHoldMenuOpen = (requestType: string, paymentType?: PaymentMethodType, methodID?: number, onConfirm?: (full: boolean) => void) => { + openHoldMenu({requestType: requestType as ActionHandledType, paymentType, methodID, onConfirm}); }; const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 0e082f1efb06..090c321421d5 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -2,7 +2,7 @@ import {delegateEmailSelector} from '@selectors/Account'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; -import {useSearchStateContext} from '@components/Search/SearchContext'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -85,6 +85,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startSubmittingA 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']); @@ -160,6 +161,9 @@ function useLifecycleActions({reportID, startApprovedAnimation, startSubmittingA startApprovedAnimation(); }, }); + if (skipAnimation) { + clearSelectedTransactions(true); + } }; const handleSubmitReport = (skipAnimation = false) => { @@ -197,6 +201,9 @@ function useLifecycleActions({reportID, startApprovedAnimation, startSubmittingA isLoading: !!currentSearchResults?.search?.isLoading, }); } + if (skipAnimation) { + clearSelectedTransactions(true); + } }; confirmPendingRTERAndProceed(doSubmit); From 2b49674d284829ba5756c1d2276a4a03256e3ac0 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 12:18:33 +0200 Subject: [PATCH 10/38] Pass isReportInSearch to useExpenseActions instead of hardcoding false --- src/components/MoneyReportHeader.tsx | 2 ++ .../MoneyReportHeaderSecondaryActions.tsx | 4 +++- src/components/MoneyReportHeaderActions/index.tsx | 3 +++ src/hooks/useExpenseActions.ts | 7 ++----- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 03eed53fa99b..5de94d1d279f 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -302,6 +302,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt startAnimation={startAnimation} startApprovedAnimation={startApprovedAnimation} startSubmittingAnimation={startSubmittingAnimation} + isReportInSearch={isReportInSearch} /> )} @@ -316,6 +317,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt startAnimation={startAnimation} startApprovedAnimation={startApprovedAnimation} startSubmittingAnimation={startSubmittingAnimation} + isReportInSearch={isReportInSearch} /> )} diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 50435e458ca5..9f35b328a802 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -65,6 +65,7 @@ type MoneyReportHeaderSecondaryActionsProps = { startAnimation: () => void; startApprovedAnimation: () => void; startSubmittingAnimation: () => void; + isReportInSearch?: boolean; dropdownMenuRef?: React.RefObject; }; @@ -78,6 +79,7 @@ function MoneyReportHeaderSecondaryActions({ startAnimation, startApprovedAnimation, startSubmittingAnimation, + isReportInSearch, dropdownMenuRef, }: MoneyReportHeaderSecondaryActionsProps) { const {translate, localeCompare} = useLocalize(); @@ -280,7 +282,7 @@ function MoneyReportHeaderSecondaryActions({ onHoldMenuOpen: (requestType) => onHoldMenuOpen(requestType, undefined, undefined, () => startApprovedAnimation()), }); - const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID}); + const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID, isReportInSearch}); const holdRejectActions = useHoldRejectActions({ reportID, diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 35b30c50566c..86417fb007d5 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -29,6 +29,7 @@ type MoneyReportHeaderActionsProps = { startAnimation: () => void; startApprovedAnimation: () => void; startSubmittingAnimation: () => void; + isReportInSearch?: boolean; }; /** @@ -52,6 +53,7 @@ function MoneyReportHeaderActions({ startAnimation, startApprovedAnimation, startSubmittingAnimation, + isReportInSearch, }: MoneyReportHeaderActionsProps) { const styles = useThemeStyles(); const dropdownMenuRef = useRef(null) as React.RefObject; @@ -129,6 +131,7 @@ function MoneyReportHeaderActions({ startAnimation={startAnimation} startApprovedAnimation={startApprovedAnimation} startSubmittingAnimation={startSubmittingAnimation} + isReportInSearch={isReportInSearch} dropdownMenuRef={dropdownMenuRef} /> diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 19f52dd0fb8a..0f6714d324bc 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -55,6 +55,7 @@ import useTransactionThreadReport from './useTransactionThreadReport'; type UseExpenseActionsParams = { reportID: string | undefined; + isReportInSearch?: boolean; }; type UseExpenseActionsReturn = { @@ -65,7 +66,7 @@ type UseExpenseActionsReturn = { wasDuplicateReportTriggered: React.MutableRefObject; }; -function useExpenseActions({reportID}: UseExpenseActionsParams): UseExpenseActionsReturn { +function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActionsParams): UseExpenseActionsReturn { const {translate, localeCompare} = useLocalize(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -282,10 +283,6 @@ function useExpenseActions({reportID}: UseExpenseActionsParams): UseExpenseActio 'ArrowRight', ]); - // Route is not available in a hook — isReportInSearch must be inferred by the caller and passed in - // if needed. For now we default to false (safe: no search hash applied). - const isReportInSearch = false; - const actions: Partial, SecondaryActionEntry>> = { [CONST.REPORT.SECONDARY_ACTIONS.SPLIT]: { text: shouldShowSplitIndicator ? translate('iou.editSplits') : translate('iou.split'), From 926e9be63e2d64d8a2060b175ba67f3e8c10055d Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 13:23:01 +0200 Subject: [PATCH 11/38] update MoneyReportHeaderActions --- Mobile-Expensify | 2 +- src/components/MoneyReportHeaderActions/index.tsx | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 37e8f9e619a8..9989e3374533 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 37e8f9e619a85009852a458437ff60fb16e4cc3c +Subproject commit 9989e3374533c0530e8895558a0bdd836594e2da diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 86417fb007d5..7231b0d7f0d6 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -1,4 +1,4 @@ -import {useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; @@ -58,7 +58,6 @@ function MoneyReportHeaderActions({ const styles = useThemeStyles(); const dropdownMenuRef = useRef(null) as React.RefObject; - // ── Layout ── // 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(); @@ -66,11 +65,9 @@ function MoneyReportHeaderActions({ const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; - // ── Onyx data ── const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); - // ── Transaction thread ── const {transactionThreadReportID} = useTransactionThreadReport(reportID); const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); From e74d1a7da16cf18b24b86b69622e763c13eb9ec7 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 13:29:26 +0200 Subject: [PATCH 12/38] updateMoneyReportHeaderActions --- src/components/MoneyReportHeaderActions/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 7231b0d7f0d6..d513f0cc24c7 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -101,8 +101,7 @@ function MoneyReportHeaderActions({ return ( - {!!primaryAction && ( - + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} /> - )} Date: Wed, 8 Apr 2026 13:43:05 +0200 Subject: [PATCH 13/38] create animation context --- src/components/MoneyReportHeader.tsx | 31 ++++-------- .../MoneyReportHeaderSecondaryActions.tsx | 8 +--- .../MoneyReportHeaderSelectionDropdown.tsx | 14 ++---- .../MoneyReportHeaderActions/index.tsx | 48 ++++--------------- .../MoneyReportHeaderActions/types.ts | 15 ++---- .../ApprovePrimaryAction.tsx | 5 +- .../PayPrimaryAction.tsx | 9 ++-- .../SubmitPrimaryAction.tsx | 7 ++- .../MoneyReportHeaderPrimaryAction/index.tsx | 35 ++------------ .../MoneyReportHeaderPrimaryAction/types.ts | 7 --- src/components/PaymentAnimationsContext.tsx | 21 ++++++++ 11 files changed, 59 insertions(+), 141 deletions(-) create mode 100644 src/components/PaymentAnimationsContext.tsx diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 5de94d1d279f..db33d838d0b9 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -8,7 +8,6 @@ import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar' import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import usePaymentAnimations from '@hooks/usePaymentAnimations'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; @@ -62,6 +61,7 @@ import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkeleton'; import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; +import {PaymentAnimationsProvider, usePaymentAnimationsContext} from './PaymentAnimationsContext'; import {useSearchActionsContext} from './Search/SearchContext'; type MoneyReportHeaderProps = { @@ -78,11 +78,13 @@ type MoneyReportHeaderProps = { function MoneyReportHeader({reportID, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) { return ( - + + + ); } @@ -143,8 +145,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const isDEWPolicy = hasDynamicExternalWorkflow(policy); - const {isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, startAnimation, stopAnimation, startApprovedAnimation, startSubmittingAnimation} = - usePaymentAnimations(); + const {isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning} = usePaymentAnimationsContext(); const styles = useThemeStyles(); const theme = useTheme(); @@ -295,13 +296,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt )} @@ -310,13 +304,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt )} diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 9f35b328a802..84b98458c279 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -9,6 +9,7 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; +import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchStateContext} from '@components/Search/SearchContext'; import type {PaymentActionParams} from '@components/SettlementButton/types'; @@ -62,9 +63,6 @@ type MoneyReportHeaderSecondaryActionsProps = { onPDFModalOpen: () => void; onHoldEducationalOpen: () => void; onRejectModalOpen: (action: RejectModalAction) => void; - startAnimation: () => void; - startApprovedAnimation: () => void; - startSubmittingAnimation: () => void; isReportInSearch?: boolean; dropdownMenuRef?: React.RefObject; }; @@ -76,12 +74,10 @@ function MoneyReportHeaderSecondaryActions({ onPDFModalOpen, onHoldEducationalOpen, onRejectModalOpen, - startAnimation, - startApprovedAnimation, - startSubmittingAnimation, isReportInSearch, dropdownMenuRef, }: MoneyReportHeaderSecondaryActionsProps) { + const {startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {translate, localeCompare} = useLocalize(); const kycWallRef = useContext(KYCWallContext); diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index a9e08608878c..31560b227f86 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -10,6 +10,7 @@ import {useLockedAccountActions, useLockedAccountState} from '@components/Locked import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; +import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import type {PaymentActionParams} from '@components/SettlementButton/types'; @@ -47,20 +48,11 @@ type MoneyReportHeaderSelectionDropdownProps = { primaryAction: ValueOf | ''; onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number, onConfirm?: (full: boolean) => void) => void; onRejectModalOpen: (action: RejectModalAction) => void; - startApprovedAnimation: () => void; - startSubmittingAnimation: () => void; wrapperStyle?: StyleProp; }; -function MoneyReportHeaderSelectionDropdown({ - reportID, - primaryAction, - onHoldMenuOpen, - onRejectModalOpen, - startApprovedAnimation, - startSubmittingAnimation, - wrapperStyle, -}: MoneyReportHeaderSelectionDropdownProps) { +function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, onHoldMenuOpen, onRejectModalOpen, wrapperStyle}: MoneyReportHeaderSelectionDropdownProps) { + const {startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); const {isProduction} = useEnvironment(); diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index d513f0cc24c7..0417cf5f5452 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -22,13 +22,6 @@ import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDrop type MoneyReportHeaderActionsProps = { reportID: string | undefined; primaryAction: ValueOf | ValueOf | ''; - isPaidAnimationRunning: boolean; - isApprovedAnimationRunning: boolean; - isSubmittingAnimationRunning: boolean; - stopAnimation: () => void; - startAnimation: () => void; - startApprovedAnimation: () => void; - startSubmittingAnimation: () => void; isReportInSearch?: boolean; }; @@ -43,18 +36,7 @@ function narrowPrimaryAction(primaryAction: MoneyReportHeaderActionsProps['prima return ''; } -function MoneyReportHeaderActions({ - reportID, - primaryAction, - isPaidAnimationRunning, - isApprovedAnimationRunning, - isSubmittingAnimationRunning, - stopAnimation, - startAnimation, - startApprovedAnimation, - startSubmittingAnimation, - isReportInSearch, -}: MoneyReportHeaderActionsProps) { +function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch}: MoneyReportHeaderActionsProps) { const styles = useThemeStyles(); const dropdownMenuRef = useRef(null) as React.RefObject; @@ -91,8 +73,6 @@ function MoneyReportHeaderActions({ primaryAction={narrowedPrimaryAction} onHoldMenuOpen={onHoldMenuOpen} onRejectModalOpen={openRejectModal} - startApprovedAnimation={startApprovedAnimation} - startSubmittingAnimation={startSubmittingAnimation} wrapperStyle={shouldDisplayNarrowMoreButton ? undefined : styles.w100} /> @@ -101,21 +81,14 @@ function MoneyReportHeaderActions({ return ( - - triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} - /> - + + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} + /> + diff --git a/src/components/MoneyReportHeaderActions/types.ts b/src/components/MoneyReportHeaderActions/types.ts index f622992007fd..abd68f3ada7a 100644 --- a/src/components/MoneyReportHeaderActions/types.ts +++ b/src/components/MoneyReportHeaderActions/types.ts @@ -6,16 +6,6 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import type CONST from '@src/CONST'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -type AnimationCallbacks = { - isPaidAnimationRunning: boolean; - isApprovedAnimationRunning: boolean; - isSubmittingAnimationRunning: boolean; - stopAnimation: () => void; - startAnimation: () => void; - startApprovedAnimation: () => void; - startSubmittingAnimation: () => void; -}; - type ModalTriggers = { onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; onPDFModalOpen: () => void; @@ -25,11 +15,12 @@ type ModalTriggers = { type SecondaryActionEntry = DropdownOption> & Pick; -type MoneyReportHeaderActionsProps = AnimationCallbacks & { +type MoneyReportHeaderActionsProps = { reportID: string | undefined; primaryAction: ValueOf | ValueOf | ''; /** Style to apply when rendered inline (narrow layout inside HeaderWithBackButton) */ style?: StyleProp; + isReportInSearch?: boolean; }; -export type {AnimationCallbacks, ModalTriggers, SecondaryActionEntry, MoneyReportHeaderActionsProps}; +export type {ModalTriggers, 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 0bba9ba3b42b..ac34765a26fb 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'; @@ -34,14 +35,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 929c2457c477..53c0d7a0d42b 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}; From 0228a9ef71f72f05c0e6ddc5e21907c02cc0903f Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 14:01:05 +0200 Subject: [PATCH 14/38] Replace modal handler props with direct context consumption --- .../MoneyReportHeaderSecondaryActions.tsx | 43 ++++++++++--------- .../MoneyReportHeaderSelectionDropdown.tsx | 21 ++++++--- .../MoneyReportHeaderActions/index.tsx | 15 ------- .../MoneyReportHeaderActions/types.ts | 11 +---- src/hooks/useLifecycleActions.tsx | 3 +- 5 files changed, 39 insertions(+), 54 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 84b98458c279..2f9996b109bb 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -7,8 +7,8 @@ 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 type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; 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'; @@ -59,25 +59,16 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; type MoneyReportHeaderSecondaryActionsProps = { reportID: string | undefined; primaryAction: ValueOf | ''; - onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number, onConfirm?: (full: boolean) => void) => void; - onPDFModalOpen: () => void; - onHoldEducationalOpen: () => void; - onRejectModalOpen: (action: RejectModalAction) => void; isReportInSearch?: boolean; dropdownMenuRef?: React.RefObject; }; -function MoneyReportHeaderSecondaryActions({ - reportID, - primaryAction, - onHoldMenuOpen, - onPDFModalOpen, - onHoldEducationalOpen, - onRejectModalOpen, - isReportInSearch, - dropdownMenuRef, -}: MoneyReportHeaderSecondaryActionsProps) { +function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { const {startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); + const {openHoldMenu: openHoldMenuAsync, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); + const openHoldMenu = (params: Parameters[0]) => { + openHoldMenuAsync(params); + }; const {translate, localeCompare} = useLocalize(); const kycWallRef = useContext(KYCWallContext); @@ -148,10 +139,20 @@ function MoneyReportHeaderSecondaryActions({ // InteractionManager delays modal until current interaction completes, preventing visual glitches on iOS // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, () => startAnimation()), + openHoldMenu({ + requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: type, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + onConfirm: () => startAnimation(), + }), ); } else { - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, () => startAnimation()); + openHoldMenu({ + requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: type, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + onConfirm: () => startAnimation(), + }); } } else if (isInvoiceReport) { startAnimation(); @@ -275,20 +276,20 @@ function MoneyReportHeaderSecondaryActions({ reportID, startApprovedAnimation, startSubmittingAnimation, - onHoldMenuOpen: (requestType) => onHoldMenuOpen(requestType, undefined, undefined, () => startApprovedAnimation()), + onHoldMenuOpen: (requestType) => openHoldMenu({requestType, onConfirm: () => startApprovedAnimation()}), }); const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID, isReportInSearch}); const holdRejectActions = useHoldRejectActions({ reportID, - onHoldEducationalOpen, - onRejectModalOpen, + onHoldEducationalOpen: openHoldEducational, + onRejectModalOpen: openRejectModal, }); const {exportActionEntries} = useExportActions({ reportID, - onPDFModalOpen, + onPDFModalOpen: openPDFDownload, }); // Compute list of applicable secondary action keys diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 31560b227f86..59889ec1a566 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -8,8 +8,8 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import {ModalActions} from '@components/Modal/Global/ModalContext'; -import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; +import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; @@ -46,13 +46,15 @@ const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight'] as const; type MoneyReportHeaderSelectionDropdownProps = { reportID: string | undefined; primaryAction: ValueOf | ''; - onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number, onConfirm?: (full: boolean) => void) => void; - onRejectModalOpen: (action: RejectModalAction) => void; wrapperStyle?: StyleProp; }; -function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, onHoldMenuOpen, onRejectModalOpen, wrapperStyle}: MoneyReportHeaderSelectionDropdownProps) { +function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, wrapperStyle}: MoneyReportHeaderSelectionDropdownProps) { const {startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); + const {openHoldMenu: openHoldMenuAsync, openRejectModal} = useMoneyReportHeaderModals(); + const openHoldMenu = (params: Parameters[0]) => { + openHoldMenuAsync(params); + }; const {translate} = useLocalize(); const {isOffline} = useNetwork(); const {isProduction} = useEnvironment(); @@ -115,7 +117,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, onHoldMenu reportID, startApprovedAnimation, startSubmittingAnimation, - onHoldMenuOpen: (requestType) => onHoldMenuOpen(requestType, undefined, undefined, () => clearSelectedTransactions(true)), + onHoldMenuOpen: (requestType) => openHoldMenu({requestType, onConfirm: () => clearSelectedTransactions(true)}), }); const { @@ -225,7 +227,12 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, onHoldMenu if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else { - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.PAY, type, type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, () => clearSelectedTransactions(true)); + openHoldMenu({ + requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: type, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + onConfirm: () => clearSelectedTransactions(true), + }); } }, shouldHidePaymentOptions: !shouldShowPayButton, @@ -326,7 +333,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, onHoldMenu if (dismissedRejectUseExplanation) { option.onSelected?.(); } else { - onRejectModalOpen(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK); + openRejectModal(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK); } }, }; diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 0417cf5f5452..4123d97cecba 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -2,8 +2,6 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; -import type {ActionHandledType} from '@components/Modal/Global/HoldMenuModalWrapper'; -import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; import {useSearchStateContext} from '@components/Search/SearchContext'; import useExportAgainModal from '@hooks/useExportAgainModal'; @@ -15,7 +13,6 @@ import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; @@ -52,12 +49,6 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch}: M const {transactionThreadReportID} = useTransactionThreadReport(reportID); - const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); - - const onHoldMenuOpen = (requestType: string, paymentType?: PaymentMethodType, methodID?: number, onConfirm?: (full: boolean) => void) => { - openHoldMenu({requestType: requestType as ActionHandledType, paymentType, methodID, onConfirm}); - }; - const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); const {selectedTransactionIDs} = useSearchStateContext(); @@ -71,8 +62,6 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch}: M @@ -92,10 +81,6 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch}: M diff --git a/src/components/MoneyReportHeaderActions/types.ts b/src/components/MoneyReportHeaderActions/types.ts index abd68f3ada7a..00c4ac45b925 100644 --- a/src/components/MoneyReportHeaderActions/types.ts +++ b/src/components/MoneyReportHeaderActions/types.ts @@ -1,17 +1,8 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import type CONST from '@src/CONST'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; - -type ModalTriggers = { - onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, methodID?: number) => void; - onPDFModalOpen: () => void; - onHoldEducationalOpen: () => void; - onRejectModalOpen: (action: RejectModalAction) => void; -}; type SecondaryActionEntry = DropdownOption> & Pick; @@ -23,4 +14,4 @@ type MoneyReportHeaderActionsProps = { isReportInSearch?: boolean; }; -export type {ModalTriggers, SecondaryActionEntry, MoneyReportHeaderActionsProps}; +export type {SecondaryActionEntry, MoneyReportHeaderActionsProps}; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 090c321421d5..e1c1226a0b1c 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -1,5 +1,6 @@ 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'; @@ -40,7 +41,7 @@ type UseLifecycleActionsParams = { reportID: string | undefined; startApprovedAnimation: () => void; startSubmittingAnimation: () => void; - onHoldMenuOpen: (requestType: string) => void; + onHoldMenuOpen: (requestType: ActionHandledType) => void; }; type UseLifecycleActionsResult = { From 96b9eac54cfdbd45b720af1f4c8ab7dff71b1851 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 14:03:30 +0200 Subject: [PATCH 15/38] prettier --- src/hooks/useReportPrimaryAction.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hooks/useReportPrimaryAction.ts b/src/hooks/useReportPrimaryAction.ts index 94fd01e9b9e0..ac164e07a578 100644 --- a/src/hooks/useReportPrimaryAction.ts +++ b/src/hooks/useReportPrimaryAction.ts @@ -32,16 +32,15 @@ function useReportPrimaryAction({reportID, runningAction}: UseReportPrimaryActio const {reportActions} = useTransactionThreadReport(reportID); const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); - const isChatReportArchived = useReportIsArchived(chatReport?.reportID); - + if (runningAction === 'pay') { return CONST.REPORT.PRIMARY_ACTIONS.PAY; } if (runningAction === 'submit') { return CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; } - + const nonPendingDeleteTransactions = Object.values(reportTransactions).filter((t) => !isTransactionPendingDelete(t)); return getReportPrimaryAction({ From d7841e4d4e1f83f034ac302afcf8e2067c4be7c1 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 14:12:15 +0200 Subject: [PATCH 16/38] Rename shouldShow booleans to descriptive hasX/isX naming --- Mobile-Expensify | 2 +- .../MoneyReportHeaderSecondaryActions.tsx | 12 ++++++------ .../MoneyReportHeaderSelectionDropdown.tsx | 4 ++-- src/components/MoneyReportHeaderActions/index.tsx | 5 +++-- src/hooks/useExpenseActions.ts | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 9989e3374533..0782891e5352 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 9989e3374533c0530e8895558a0bdd836594e2da +Subproject commit 0782891e535200f105ec5d2d6528a1337cfc90f2 diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 2f9996b109bb..5587c567cb69 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -213,9 +213,9 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS !reportHasOnlyNonReimbursableTransactions && !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, true, undefined, invoiceReceiverPolicy); - const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere || (reportHasOnlyNonReimbursableTransactions && (moneyRequestReport?.total ?? 0) !== 0); - const shouldShowApproveButton = canApproveIOU(moneyRequestReport, policy, reportMetadata, allTransactions); - const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); + const isPayable = canIOUBePaid || onlyShowPayElsewhere || (reportHasOnlyNonReimbursableTransactions && (moneyRequestReport?.total ?? 0) !== 0); + const isApprovable = canApproveIOU(moneyRequestReport, policy, reportMetadata, allTransactions); + const isApproveDisabled = isApprovable && !isAllowedToApproveExpenseReport(moneyRequestReport); const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions); @@ -226,9 +226,9 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS formattedAmount: totalAmount, policyID: moneyRequestReport?.policyID, onPress: confirmPayment, - shouldHidePaymentOptions: !shouldShowPayButton, - shouldShowApproveButton, - shouldDisableApproveButton, + shouldHidePaymentOptions: !isPayable, + shouldShowApproveButton: isApprovable, + shouldDisableApproveButton: isApproveDisabled, onlyShowPayElsewhere, }); diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 59889ec1a566..51dd78f6fe77 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -182,7 +182,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, wrapperSty const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, false, undefined, invoiceReceiverPolicy); - const shouldShowPayButton = hasPayAction && canIOUBePaid; + const isPayable = hasPayAction && canIOUBePaid; const confirmPayment = ({paymentType: type, methodID}: PaymentActionParams) => { if (!type || !chatReport) { @@ -235,7 +235,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, wrapperSty }); } }, - shouldHidePaymentOptions: !shouldShowPayButton, + shouldHidePaymentOptions: !isPayable, shouldShowApproveButton: false, shouldDisableApproveButton: false, onlyShowPayElsewhere: false, diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 4123d97cecba..2cddc5938c9c 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -52,11 +52,12 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch}: M const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); const {selectedTransactionIDs} = useSearchStateContext(); - const shouldShowSelectedTransactionsButton = !!selectedTransactionIDs.length && !transactionThreadReportID; + const hasSelectedTransactions = !!selectedTransactionIDs.length; + const isTransactionThread = !!transactionThreadReportID; const narrowedPrimaryAction = narrowPrimaryAction(primaryAction); - if (shouldShowSelectedTransactionsButton) { + if (hasSelectedTransactions && !isTransactionThread) { return ( 1; })(); const isReportOpen = isOpenReport(moneyRequestReport); - const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); + const hasSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); // Duplicate report throttle const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); @@ -285,7 +285,7 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio const actions: Partial, SecondaryActionEntry>> = { [CONST.REPORT.SECONDARY_ACTIONS.SPLIT]: { - text: shouldShowSplitIndicator ? translate('iou.editSplits') : translate('iou.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, From 20ffd04e53610d2cb4a624f03c82618f948cc339 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 14:27:12 +0200 Subject: [PATCH 17/38] Replace deprecated MutableRefObject with RefObject --- src/hooks/useExpenseActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 97319e3d4679..bad23753efd0 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -63,7 +63,7 @@ type UseExpenseActionsReturn = { addExpenseDropdownOptions: Array>; handleOptionsMenuHide: () => void; isDuplicateReportActive: boolean; - wasDuplicateReportTriggered: React.MutableRefObject; + wasDuplicateReportTriggered: React.RefObject; }; function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActionsParams): UseExpenseActionsReturn { From df29a4b034056d3de0e0c43b1a9ae162ace109b6 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 8 Apr 2026 15:05:24 +0200 Subject: [PATCH 18/38] Fix isOnSearch, backTo route, and missing duplicate expense guards --- .../MoneyReportHeaderSelectionDropdown.tsx | 13 +++++-- .../MoneyReportHeaderActions/index.tsx | 1 + src/hooks/useExpenseActions.ts | 39 ++++++++++++++++++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 51dd78f6fe77..9665a8592e6c 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -1,3 +1,4 @@ +import {useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import React, {useContext, useRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -39,6 +40,7 @@ import {canIOUBePaid as canIOUBePaidAction, payMoneyRequest} from '@userActions/ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight'] as const; @@ -46,10 +48,12 @@ const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight'] as const; type MoneyReportHeaderSelectionDropdownProps = { reportID: string | undefined; primaryAction: ValueOf | ''; + isReportInSearch?: boolean; wrapperStyle?: StyleProp; }; -function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, wrapperStyle}: MoneyReportHeaderSelectionDropdownProps) { +function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportInSearch, wrapperStyle}: MoneyReportHeaderSelectionDropdownProps) { + const route = useRoute(); const {startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {openHoldMenu: openHoldMenuAsync, openRejectModal} = useMoneyReportHeaderModals(); const openHoldMenu = (params: Parameters[0]) => { @@ -133,7 +137,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, wrapperSty onExportOffline: showOfflineModal, policy, beginExportWithTemplate, - isOnSearch: false, + isOnSearch: !!isReportInSearch, }); const computedSecondaryActions = moneyRequestReport @@ -259,7 +263,10 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, wrapperSty } const nonPendingCount = transactions.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; if (nonPendingCount === selectedTransactionIDs.length) { - const backToRoute = chatReport?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID) : undefined; + // 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(); diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 2cddc5938c9c..98277c0bfaac 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -63,6 +63,7 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch}: M diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index bad23753efd0..fe7f83d58fc5 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -30,7 +30,14 @@ import { navigateOnDeleteExpense, } from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {getChildTransactions, getOriginalTransactionWithSplitInfo, isDistanceRequest, isTransactionPendingDelete} from '@libs/TransactionUtils'; +import { + getChildTransactions, + getOriginalTransactionWithSplitInfo, + hasCustomUnitOutOfPolicyViolation as hasCustomUnitOutOfPolicyViolationTransactionUtils, + isDistanceRequest, + isPerDiemRequest, + isTransactionPendingDelete, +} from '@libs/TransactionUtils'; import {getNavigationUrlOnMoneyRequestDelete, startMoneyRequest} from '@userActions/IOU'; import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -52,6 +59,7 @@ import useReportIsArchived from './useReportIsArchived'; import useThrottledButtonState from './useThrottledButtonState'; import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; import useTransactionThreadReport from './useTransactionThreadReport'; +import useTransactionViolations from './useTransactionViolations'; type UseExpenseActionsParams = { reportID: string | undefined; @@ -204,11 +212,18 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio })(); // 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 || activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID; + const shouldDuplicateCloseModalOnSelect = + isDistanceExpenseUnsupportedForDuplicating || + isPerDiemRequestOnNonDefaultWorkspace || + hasCustomUnitOutOfPolicyViolation || + activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID; // Dropdown ref is owned by the orchestrator — no reset callback needed here. const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(); @@ -313,6 +328,16 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio icon: isDuplicateActive ? expensifyIcons.ExpenseCopy : expensifyIcons.Checkmark, 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'), @@ -323,6 +348,16 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio return; } + if (isPerDiemRequestOnNonDefaultWorkspace) { + showConfirmModal({ + title: translate('common.duplicateExpense'), + prompt: translate('iou.duplicateNonDefaultWorkspacePerDiemError'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + if (!isDuplicateActive || !transaction) { return; } From 4c8dcfc7916537fd51278b3e6842bf56e204ee2e Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 08:51:11 +0200 Subject: [PATCH 19/38] update MoneyReportHeaderSecondaryActions --- .../MoneyReportHeaderSecondaryActions.tsx | 83 ++++++++----------- 1 file changed, 33 insertions(+), 50 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 8ec332fdb367..1e8f7b13f12b 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -135,24 +135,18 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS 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({ - requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, - paymentType: type, - methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - onConfirm: () => startAnimation(), - }), - ); + InteractionManager.runAfterInteractions(() => openHoldMenu(holdMenuParams)); } else { - openHoldMenu({ - requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, - paymentType: type, - methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - onConfirm: () => startAnimation(), - }); + openHoldMenu(holdMenuParams); } } else if (isInvoiceReport) { startAnimation(); @@ -234,20 +228,12 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS const activeAdminPolicies = useActiveAdminPolicies(); - const workspacePolicyOptions = (() => { - 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.reportID, accountID ?? CONST.DEFAULT_NUMBER_ID); - if (!canUseBusinessBankAccount) { - return []; - } - return sortPoliciesByName(activeAdminPolicies, localeCompare); - })(); + const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); + const canUseBusinessBankAccount = !!moneyRequestReport?.reportID && !hasRequestFromCurrentAccount(moneyRequestReport.reportID, accountID ?? CONST.DEFAULT_NUMBER_ID); + const workspacePolicyOptions = + isIOUReportUtil(moneyRequestReport) && hasPersonalPaymentOption && activeAdminPolicies.length && canUseBusinessBankAccount + ? sortPoliciesByName(activeAdminPolicies, localeCompare) + : []; const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Cash', 'ArrowRight', 'Building']); @@ -293,28 +279,25 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS }); // Compute list of applicable secondary action keys - const secondaryActions = (() => { - if (!moneyRequestReport) { - return []; - } - return getSecondaryReportActions({ - currentUserLogin: currentUserLogin ?? '', - currentUserAccountID: accountID, - report: moneyRequestReport, - chatReport, - reportTransactions: nonPendingDeleteTransactions, - originalTransaction: undefined, - violations, - bankAccountList, - policy, - reportNameValuePairs, - reportActions, - reportMetadata, - policies, - outstandingReportsByPolicyID, - isChatReportArchived, - }); - })(); + const secondaryActions = moneyRequestReport + ? getSecondaryReportActions({ + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID: accountID, + report: moneyRequestReport, + chatReport, + reportTransactions: nonPendingDeleteTransactions, + originalTransaction: undefined, + violations, + bankAccountList, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, + policies, + outstandingReportsByPolicyID, + isChatReportArchived, + }) + : []; // Merge all action implementations const secondaryActionsImplementation: Record = { From 87ab763058aecfcb5efc51fdf45a1d9665b12d9b Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 09:36:59 +0200 Subject: [PATCH 20/38] update MoneyReportHeaderSelectionDropdown --- .../MoneyReportHeaderSelectionDropdown.tsx | 142 +++++++++++------- src/hooks/useExportActions.ts | 4 +- 2 files changed, 89 insertions(+), 57 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 9665a8592e6c..31c6c9d017e6 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -1,5 +1,6 @@ import {useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; +import truncate from 'lodash/truncate'; import React, {useContext, useRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -15,6 +16,7 @@ import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext' import type {PopoverMenuItem} from '@components/PopoverMenu'; 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 useEnvironment from '@hooks/useEnvironment'; @@ -33,7 +35,10 @@ 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 {isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import {canIOUBePaid as canIOUBePaidAction, payMoneyRequest} from '@userActions/IOU'; @@ -41,9 +46,10 @@ 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'] as const; +const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight', 'Building'] as const; type MoneyReportHeaderSelectionDropdownProps = { reportID: string | undefined; @@ -59,11 +65,12 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const openHoldMenu = (params: Parameters[0]) => { openHoldMenuAsync(params); }; - const {translate} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const {isOffline} = useNetwork(); const {isProduction} = useEnvironment(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const activeAdminPolicies = useActiveAdminPolicies(); const {selectedTransactionIDs} = useSearchStateContext(); const {clearSelectedTransactions} = useSearchActionsContext(); @@ -114,7 +121,6 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const {beginExportWithTemplate, showOfflineModal, showDownloadErrorModal} = useExportActions({ reportID, - onPDFModalOpen: () => {}, }); const {confirmApproval, handleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval} = useLifecycleActions({ @@ -181,11 +187,8 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn }; const canAllowSettlement = !!moneyRequestReport; - const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions); - const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, false, undefined, invoiceReceiverPolicy); - const isPayable = hasPayAction && canIOUBePaid; const confirmPayment = ({paymentType: type, methodID}: PaymentActionParams) => { @@ -193,10 +196,12 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn return; } isSelectionModePaymentRef.current = true; + if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); return; } + payMoneyRequest({ paymentType: type, chatReport, @@ -214,6 +219,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, onPaid: () => {}, }); + clearSelectedTransactions(true); }; @@ -227,7 +233,9 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn if (!type || !chatReport) { return; } + isSelectionModePaymentRef.current = true; + if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else { @@ -245,9 +253,31 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn onlyShowPayElsewhere: false, }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const buildPaymentSubMenuItems = (_onWorkspaceSelected: () => void) => { - return Object.values(paymentButtonOptions); + const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); + const canUseBusinessBankAccount = !!moneyRequestReport?.reportID && !hasRequestFromCurrentAccount(moneyRequestReport.reportID, 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 = () => { @@ -278,52 +308,54 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn // Ref writes below are inside onSelected callbacks that only fire on user interaction, never during render. /* eslint-disable react-hooks/refs */ - const selectionModeReportLevelActions = (() => { - if (isProduction) { - 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(() => { - isSelectionModePaymentRef.current = true; - if (checkForNecessaryAction()) { - return; - } - kycWallRef.current?.continueAction?.({}); - }), - onSelected: () => { - isSelectionModePaymentRef.current = true; - }, - }); - } - return actions; - })(); - /* eslint-enable react-hooks/refs */ + const selectionModeReportLevelActions: Array & Pick> = isProduction + ? [] + : [ + ...(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) { diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index f4f79f72cf08..61746edd1260 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -28,7 +28,7 @@ import useTransactionsAndViolationsForReport from './useTransactionsAndViolation type UseExportActionsParams = { reportID: string | undefined; - onPDFModalOpen: () => void; + onPDFModalOpen?: () => void; }; type UseExportActionsReturn = { @@ -236,7 +236,7 @@ function useExportActions({reportID, onPDFModalOpen}: UseExportActionsParams): U if (!moneyRequestReport?.reportID) { return; } - onPDFModalOpen(); + onPDFModalOpen?.(); exportReportToPDF({reportID: moneyRequestReport.reportID}); }, }, From 58b5f437587c736d8bc7ff289c2d1cfd9d1abe37 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 09:40:44 +0200 Subject: [PATCH 21/38] update secondary actions --- .../MoneyReportHeaderSecondaryActions.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 1e8f7b13f12b..be5c47f1e896 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -65,10 +65,7 @@ type MoneyReportHeaderSecondaryActionsProps = { function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { const {startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); - const {openHoldMenu: openHoldMenuAsync, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); - const openHoldMenu = (params: Parameters[0]) => { - openHoldMenuAsync(params); - }; + const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); const {translate, localeCompare} = useLocalize(); const kycWallRef = useContext(KYCWallContext); @@ -262,7 +259,9 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS reportID, startApprovedAnimation, startSubmittingAnimation, - onHoldMenuOpen: (requestType) => openHoldMenu({requestType, onConfirm: () => startApprovedAnimation()}), + onHoldMenuOpen: (requestType) => { + openHoldMenu({requestType, onConfirm: () => startApprovedAnimation()}); + }, }); const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID, isReportInSearch}); From cdabe00732489850f7ea4657a60920c9205ab6f6 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 10:16:44 +0200 Subject: [PATCH 22/38] update useExpenseActions --- src/hooks/useExpenseActions.ts | 48 +++++++++------------------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index fe7f83d58fc5..8e022ca824df 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -103,15 +103,9 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio } const currentTransaction = transactions.at(0); - const requestParentReportAction = (() => { - if (!reportActions || !transactionThreadReport?.parentReportActionID) { - return null; - } - return ( - reportActions.find((action): action is OnyxTypes.ReportAction => action.reportActionID === transactionThreadReport.parentReportActionID) ?? - null - ); - })(); + 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)}`); @@ -167,13 +161,7 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio // Split indicator const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); - const hasMultipleSplits = (() => { - if (!transaction?.comment?.originalTransactionID) { - return false; - } - const children = getChildTransactions(allTransactions, allReports, transaction.comment.originalTransactionID); - return children.length > 1; - })(); + const hasMultipleSplits = !!transaction?.comment?.originalTransactionID && getChildTransactions(allTransactions, allReports, transaction.comment.originalTransactionID).length > 1; const isReportOpen = isOpenReport(moneyRequestReport); const hasSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); @@ -190,26 +178,17 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio // 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. - // canMoveSingleExpense - const canMoveSingleExpense = (() => { - 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, + 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: transactionToMove, - }); - const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(moneyRequestReport, isChatReportArchived); - return canMoveExpense && canUserPerformWriteAction; - })(); + transaction: singleTransaction, + }) && + canUserPerformWriteActionReportUtils(moneyRequestReport, isChatReportArchived); // Duplicate expense: unsupported / shouldClose flags const transactionViolations = useTransactionViolations(transaction?.transactionID); @@ -228,10 +207,8 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio // Dropdown ref is owned by the orchestrator — no reset callback needed here. const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(); - // Policy tags for duplicate report const targetPolicyTags = defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {}; - // duplicateExpenseTransaction const duplicateExpenseTransaction = (transactionList: OnyxTypes.Transaction[]) => { if (!transactionList.length) { return; @@ -268,7 +245,6 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio } }; - // addExpenseDropdownOptions const addExpenseDropdownOptions = getAddExpenseDropdownOptions({ translate, icons: useMemoizedLazyExpensifyIcons(['Plus', 'ReceiptPlus', 'Location', 'Feed', 'ArrowRight']), From 2faecc90bbed503c2a3e8e15e5b516aaa00e70ce Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 10:19:07 +0200 Subject: [PATCH 23/38] update hold reject actions --- src/hooks/useHoldRejectActions.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/hooks/useHoldRejectActions.ts b/src/hooks/useHoldRejectActions.ts index cb9be3a107f8..c9994153488b 100644 --- a/src/hooks/useHoldRejectActions.ts +++ b/src/hooks/useHoldRejectActions.ts @@ -41,17 +41,14 @@ function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpe const [reportActionsForParent] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`, {canEvict: false}); const requestParentReportAction = transactionThreadReport?.parentReportActionID ? reportActionsForParent?.[transactionThreadReport.parentReportActionID] : undefined; - // Transaction — derive ID from the IOU action, fall back to DEFAULT_NUMBER_ID so the Onyx key is always valid const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? (getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${iouTransactionID}`); - // Dismissed explanation flags const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); - // Derived booleans const isReportSubmitter = isCurrentUserSubmitter(moneyRequestReport); const isChatReportDM = isDM(chatReport); From c614e6c3e771fb5f8ff2b519ac63e4a0d0201fd0 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 10:19:57 +0200 Subject: [PATCH 24/38] update useLifecycleActions --- src/hooks/useLifecycleActions.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index e1c1226a0b1c..33c57a8c8279 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -91,7 +91,6 @@ function useLifecycleActions({reportID, startApprovedAnimation, startSubmittingA const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'CircularArrowBackwards', 'Clear']); - // Derived values const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport); const isSubmitterSameAsNextApprover = isReportOwner(moneyRequestReport) && (nextApproverAccountID === moneyRequestReport?.ownerAccountID || moneyRequestReport?.managerID === moneyRequestReport?.ownerAccountID); From a6b06bf7be25c98f5eada8ff3b2458cd6da0b379 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 10:28:44 +0200 Subject: [PATCH 25/38] cleanup MoneyReportHeader --- src/components/MoneyReportHeader.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 3d1745f53b36..09148db0355d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -154,9 +154,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt isReportInSearch={isReportInSearch} /> )} - - ); From 2d9597275370fa4c5da6342525ff357f963554f3 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 13:59:05 +0200 Subject: [PATCH 26/38] Add delegateEmail to approve/selectPaymentType calls after merge --- .../MoneyReportHeaderSecondaryActions.tsx | 4 +++- .../MoneyReportHeaderSelectionDropdown.tsx | 4 +++- src/hooks/useLifecycleActions.tsx | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index be5c47f1e896..6dbe351c6af8 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -1,4 +1,4 @@ -import {isUserValidatedSelector} from '@selectors/Account'; +import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import truncate from 'lodash/truncate'; import React, {useContext} from 'react'; @@ -86,6 +86,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS 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( @@ -352,6 +353,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, + delegateEmail, }); }; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 31c6c9d017e6..fe174d34df02 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import {isUserValidatedSelector} from '@selectors/Account'; +import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; import truncate from 'lodash/truncate'; import React, {useContext, useRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -80,6 +80,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn 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 [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); const [betas] = useOnyx(ONYXKEYS.BETAS); @@ -409,6 +410,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, + delegateEmail, }); }; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 33c57a8c8279..b50bf5cc00d5 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -160,6 +160,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startSubmittingA } startApprovedAnimation(); }, + delegateEmail, }); if (skipAnimation) { clearSelectedTransactions(true); From 2e1736dc8eaab94184f3ad161e95ef55dbf35519 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Apr 2026 14:13:26 +0200 Subject: [PATCH 27/38] fix codex comments --- src/components/MoneyReportHeader.tsx | 3 +++ .../MoneyReportHeaderSelectionDropdown.tsx | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 09148db0355d..76b3c4b5efb0 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -25,6 +25,7 @@ import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import MoneyReportHeaderModals from './MoneyReportHeaderModals'; import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; import {PaymentAnimationsProvider} from './PaymentAnimationsContext'; +import {useSearchActionsContext} from './Search/SearchContext'; type MoneyReportHeaderProps = { /** The reportID of the report currently being looked at */ @@ -52,6 +53,7 @@ function MoneyReportHeader({reportID, shouldDisplayBackButton = false, onBackBut } 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)}`); @@ -117,6 +119,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt { + clearSelectedTransactions(true); turnOffMobileSelectionMode(); }} /> diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index fe174d34df02..f291818b858e 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -38,7 +38,7 @@ import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; import {sortPoliciesByName} from '@libs/PolicyUtils'; import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; -import {isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; +import {hasUpdatedTotal, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import {canIOUBePaid as canIOUBePaidAction, payMoneyRequest} from '@userActions/IOU'; @@ -187,7 +187,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn return false; }; - const canAllowSettlement = !!moneyRequestReport; + 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 isPayable = hasPayAction && canIOUBePaid; From a577b1377f7f539a7b8a02ec3ead8537ab59ecfe Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Apr 2026 08:51:39 +0200 Subject: [PATCH 28/38] Propagate main changes into extracted MoneyReportHeaderSelectionDropdown --- .../MoneyReportHeaderSelectionDropdown.tsx | 167 ++++++++++-------- 1 file changed, 93 insertions(+), 74 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index f291818b858e..29be4eb1124e 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -14,12 +14,12 @@ import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdo 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 useEnvironment from '@hooks/useEnvironment'; import useExportActions from '@hooks/useExportActions'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLifecycleActions from '@hooks/useLifecycleActions'; @@ -67,7 +67,6 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn }; const {translate, localeCompare} = useLocalize(); const {isOffline} = useNetwork(); - const {isProduction} = useEnvironment(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const activeAdminPolicies = useActiveAdminPolicies(); @@ -135,6 +134,10 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn options: originalSelectedTransactionsOptions, handleDeleteTransactions, handleDeleteTransactionsWithNavigation, + isDuplicateOptionVisible, + setDuplicateHandler, + allTransactions: allTransactionsForDuplicate, + allReports: allReportsForDuplicate, } = useSelectedTransactionsActions({ report: moneyRequestReport, reportActions, @@ -171,7 +174,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn 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 = () => { + const checkForNecessaryAction = (paymentMethodType?: PaymentMethodType) => { if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); return true; @@ -180,7 +183,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn showLockedAccountModal(); return true; } - if (!isUserValidated) { + if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { handleUnvalidatedAccount(moneyRequestReport); return true; } @@ -309,54 +312,52 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn // 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> = isProduction - ? [] - : [ - ...(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 selectionModeReportLevelActions: Array & Pick> = [ + ...(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) { @@ -385,11 +386,12 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const popoverUseScrollView = shouldPopoverUseScrollView(selectedTransactionsOptions); - const hasPayInSelectionMode = allExpensesSelected && hasPayAction; + 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()) { + if (checkForNecessaryAction(iouPaymentType)) { return; } selectPaymentType({ @@ -423,32 +425,49 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn return null; } + const bulkDuplicateHandler = isDuplicateOptionVisible ? ( + clearSelectedTransactions(true)} + /> + ) : null; + if (hasPayInSelectionMode) { return ( - + <> + {bulkDuplicateHandler} + + ); } return ( - null} - options={selectedTransactionsOptions} - customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} - isSplitButton={false} - shouldAlwaysShowDropdownMenu - shouldPopoverUseScrollView={popoverUseScrollView} - wrapperStyle={wrapperStyle} - /> + <> + {bulkDuplicateHandler} + null} + options={selectedTransactionsOptions} + customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} + isSplitButton={false} + shouldAlwaysShowDropdownMenu + shouldPopoverUseScrollView={popoverUseScrollView} + wrapperStyle={wrapperStyle} + /> + ); } From f6fcd9cce9664ce9496024c9ea313cae0751aab0 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Apr 2026 10:02:57 +0200 Subject: [PATCH 29/38] Fix archived chat check and hold guard in selection dropdown --- .../MoneyReportHeaderSelectionDropdown.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 29be4eb1124e..e9b7aa24cb3e 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -25,6 +25,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLifecycleActions from '@hooks/useLifecycleActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useReportIsArchived from '@hooks/useReportIsArchived'; import useOnyx from '@hooks/useOnyx'; import usePaymentOptions from '@hooks/usePaymentOptions'; import usePermissions from '@hooks/usePermissions'; @@ -38,7 +39,7 @@ import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; import {sortPoliciesByName} from '@libs/PolicyUtils'; import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; -import {hasUpdatedTotal, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; +import {hasHeldExpenses, hasUpdatedTotal, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import {canIOUBePaid as canIOUBePaidAction, payMoneyRequest} from '@userActions/IOU'; @@ -96,6 +97,9 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, ); + const isChatReportArchived = useReportIsArchived(chatReport?.reportID); + const isAnyTransactionOnHold = hasHeldExpenses(moneyRequestReport?.reportID); + const {transactionThreadReportID, reportActions} = useTransactionThreadReport(reportID); const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); @@ -166,7 +170,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn reportMetadata, policies: allPolicies, outstandingReportsByPolicyID, - isChatReportArchived: false, + isChatReportArchived, }) : []; @@ -206,6 +210,16 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn 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; + } + payMoneyRequest({ paymentType: type, chatReport, From dee3d1aa945b360271363af9a500a594a13f240e Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Apr 2026 10:13:50 +0200 Subject: [PATCH 30/38] fix prettier --- .../MoneyReportHeaderSelectionDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index e9b7aa24cb3e..8715537f6290 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -25,10 +25,10 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLifecycleActions from '@hooks/useLifecycleActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useReportIsArchived from '@hooks/useReportIsArchived'; import useOnyx from '@hooks/useOnyx'; import usePaymentOptions from '@hooks/usePaymentOptions'; import usePermissions from '@hooks/usePermissions'; +import useReportIsArchived from '@hooks/useReportIsArchived'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; From cfb56c2bdd13d0dfb4634ae853f482d75db380a1 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Apr 2026 12:28:43 +0200 Subject: [PATCH 31/38] address CR comments --- src/components/MoneyReportHeader.tsx | 4 ++ .../MoneyReportHeaderSecondaryActions.tsx | 6 +- .../MoneyReportHeaderSelectionDropdown.tsx | 68 +++++++++++++------ .../MoneyReportHeaderActions/index.tsx | 5 +- src/hooks/useExpenseActions.ts | 11 +-- src/hooks/useExportActions.ts | 7 +- 6 files changed, 70 insertions(+), 31 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 76b3c4b5efb0..b66c53283e97 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -17,6 +17,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {ButtonWithDropdownMenuRef} from './ButtonWithDropdownMenu/types'; import HeaderLoadingBar from './HeaderLoadingBar'; @@ -95,6 +96,8 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt 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; + // 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; const primaryAction = useReportPrimaryAction(reportIDProp); @@ -147,6 +150,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt reportID={reportIDProp} primaryAction={primaryAction} isReportInSearch={isReportInSearch} + backTo={backTo} /> )} diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 6dbe351c6af8..f8db547403d2 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -53,6 +53,7 @@ import { import {canApproveIOU, canIOUBePaid as canIOUBePaidAction, payInvoice, payMoneyRequest} from '@userActions/IOU'; 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'; @@ -60,10 +61,11 @@ type MoneyReportHeaderSecondaryActionsProps = { reportID: string | undefined; primaryAction: ValueOf | ''; isReportInSearch?: boolean; + backTo?: Route; dropdownMenuRef?: React.RefObject; }; -function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { +function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { const {startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); @@ -265,7 +267,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS }, }); - const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID, isReportInSearch}); + const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID, isReportInSearch, backTo}); const holdRejectActions = useHoldRejectActions({ reportID, diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 8715537f6290..68d2a534bccc 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -1,5 +1,6 @@ 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, useRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -26,8 +27,10 @@ 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 useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -39,10 +42,10 @@ import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; import {sortPoliciesByName} from '@libs/PolicyUtils'; import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; -import {hasHeldExpenses, hasUpdatedTotal, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; +import {hasHeldExpenses, hasUpdatedTotal, isInvoiceReport as isInvoiceReportUtil, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; -import {canIOUBePaid as canIOUBePaidAction, payMoneyRequest} from '@userActions/IOU'; +import {canIOUBePaid as canIOUBePaidAction, payInvoice, payMoneyRequest} from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -97,6 +100,12 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn `${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); @@ -199,7 +208,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, false, undefined, invoiceReceiverPolicy); const isPayable = hasPayAction && canIOUBePaid; - const confirmPayment = ({paymentType: type, methodID}: PaymentActionParams) => { + const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { if (!type || !chatReport) { return; } @@ -220,23 +229,42 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn return; } - payMoneyRequest({ - paymentType: type, - chatReport, - iouReport: moneyRequestReport, - introSelected, - iouReportCurrentNextStepDeprecated: nextStep, - currentUserAccountID: accountID, - activePolicy: undefined, - policy, - betas, - isSelfTourViewed: undefined, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - onPaid: () => {}, - }); + 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: () => {}, + }); + } clearSelectedTransactions(true); }; diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 98277c0bfaac..cbb23e340d7d 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -13,6 +13,7 @@ import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; @@ -20,6 +21,7 @@ type MoneyReportHeaderActionsProps = { reportID: string | undefined; primaryAction: ValueOf | ValueOf | ''; isReportInSearch?: boolean; + backTo?: Route; }; /** @@ -33,7 +35,7 @@ function narrowPrimaryAction(primaryAction: MoneyReportHeaderActionsProps['prima return ''; } -function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch}: MoneyReportHeaderActionsProps) { +function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, backTo}: MoneyReportHeaderActionsProps) { const styles = useThemeStyles(); const dropdownMenuRef = useRef(null) as React.RefObject; @@ -84,6 +86,7 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch}: M reportID={reportID} primaryAction={narrowedPrimaryAction} isReportInSearch={isReportInSearch} + backTo={backTo} dropdownMenuRef={dropdownMenuRef} /> diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 8e022ca824df..56661dbf1c86 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -43,6 +43,7 @@ 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'; @@ -64,6 +65,7 @@ import useTransactionViolations from './useTransactionViolations'; type UseExpenseActionsParams = { reportID: string | undefined; isReportInSearch?: boolean; + backTo?: Route; }; type UseExpenseActionsReturn = { @@ -74,7 +76,7 @@ type UseExpenseActionsReturn = { wasDuplicateReportTriggered: React.RefObject; }; -function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActionsParams): UseExpenseActionsReturn { +function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpenseActionsParams): UseExpenseActionsReturn { const {translate, localeCompare} = useLocalize(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -113,6 +115,7 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio 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); @@ -238,7 +241,7 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio existingTransactionDraft, draftTransactionIDs, betas, - personalDetails: undefined, + personalDetails, recentWaypoints, targetPolicyTags, }); @@ -376,7 +379,7 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio ownerPersonalDetails: currentUserPersonalDetails, isASAPSubmitBetaEnabled, betas, - personalDetails: undefined, + personalDetails, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], draftTransactionIDs, @@ -496,7 +499,7 @@ function useExpenseActions({reportID, isReportInSearch = false}: UseExpenseActio if (result.action !== ModalActions.CONFIRM) { return; } - const backToRoute = chatReport?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID) : undefined; + const backToRoute = backTo ?? (chatReport?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID) : undefined); const deleteNavigateBackUrl = backToRoute ?? Navigation.getActiveRoute(); setDeleteTransactionNavigateBackUrl(deleteNavigateBackUrl); diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index 61746edd1260..7e2c30ca15b8 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -45,7 +45,7 @@ function useExportActions({reportID, onPDFModalOpen}: UseExportActionsParams): U const styles = useThemeStyles(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const policy = usePolicy(moneyRequestReport?.policyID); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); @@ -54,13 +54,12 @@ function useExportActions({reportID, onPDFModalOpen}: UseExportActionsParams): U const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const {login: currentUserLogin, accountID} = useCurrentUserPersonalDetails(); - const policyFromHook = usePolicy(moneyRequestReport?.policyID); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); const transactionIDs = Object.values(reportTransactions).map((t) => t.transactionID); - const connectedIntegration = getValidConnectedIntegration(policyFromHook); - const connectedIntegrationFallback = getConnectedIntegration(policyFromHook); + const connectedIntegration = getValidConnectedIntegration(policy); + const connectedIntegrationFallback = getConnectedIntegration(policy); const exportTemplates = getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy); const isExported = isExportedUtils(reportActions, moneyRequestReport); From 2de9329fb77711f92ae89481624e9390626bf3a0 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 13 Apr 2026 11:02:46 +0200 Subject: [PATCH 32/38] fix: address PR review feedback for MoneyReportHeader decomposition --- src/components/MoneyReportHeader.tsx | 16 +----- .../MoneyReportHeaderSecondaryActions.tsx | 23 +++++++-- .../MoneyReportHeaderSelectionDropdown.tsx | 51 +++++++++++-------- .../MoneyReportHeaderActions/index.tsx | 9 +--- .../MoneyReportHeaderActions/types.ts | 5 +- src/hooks/useExpenseActions.ts | 12 ++--- src/hooks/useExportActions.ts | 7 +-- src/hooks/useLifecycleActions.tsx | 2 +- 8 files changed, 62 insertions(+), 63 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index b66c53283e97..ab932eb548c6 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useEffect, useRef} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -9,7 +9,6 @@ import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; -import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -19,7 +18,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {ButtonWithDropdownMenuRef} from './ButtonWithDropdownMenu/types'; import HeaderLoadingBar from './HeaderLoadingBar'; import HeaderWithBackButton from './HeaderWithBackButton'; import MoneyReportHeaderActions from './MoneyReportHeaderActions'; @@ -78,18 +76,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const styles = useThemeStyles(); - const [isDuplicateReportActive] = useThrottledButtonState(); - const dropdownMenuRef = useRef(null); - const wasDuplicateReportTriggered = useRef(false); - - useEffect(() => { - if (!isDuplicateReportActive || !wasDuplicateReportTriggered.current) { - return; - } - wasDuplicateReportTriggered.current = false; - dropdownMenuRef.current?.setIsMenuVisible(false); - }, [isDuplicateReportActive]); - const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index ef957d541c89..db29704fe738 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -1,7 +1,7 @@ import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import truncate from 'lodash/truncate'; -import React, {useContext} from 'react'; +import React, {useContext, useEffect} from 'react'; import {InteractionManager} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; @@ -21,6 +21,7 @@ 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 useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -100,6 +101,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {login: currentUserLogin, accountID, email} = currentUserPersonalDetails; + const {isOffline} = useNetwork(); const activePolicy = usePolicy(activePolicyID); const {isBetaEnabled} = usePermissions(); @@ -108,6 +110,8 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS 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); @@ -188,13 +192,13 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS startAnimation(); }, }); - if (currentSearchQueryJSON) { + if (currentSearchQueryJSON && !isOffline) { search({ searchKey: currentSearchKey, shouldCalculateTotals, offset: 0, queryJSON: currentSearchQueryJSON, - isOffline: false, + isOffline, isLoading: !!currentSearchResults?.search?.isLoading, }); } @@ -268,7 +272,15 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS }, }); - const {actions: expenseActions, handleOptionsMenuHide} = useExpenseActions({reportID, isReportInSearch, backTo}); + const {actions: expenseActions, handleOptionsMenuHide, isDuplicateReportActive, wasDuplicateReportTriggeredRef} = useExpenseActions({reportID, isReportInSearch, backTo}); + + useEffect(() => { + if (!isDuplicateReportActive || !wasDuplicateReportTriggeredRef.current) { + return; + } + wasDuplicateReportTriggeredRef.current = false; + dropdownMenuRef?.current?.setIsMenuVisible(false); + }, [isDuplicateReportActive, wasDuplicateReportTriggeredRef, dropdownMenuRef]); const holdRejectActions = useHoldRejectActions({ reportID, @@ -278,6 +290,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS const {exportActionEntries} = useExportActions({ reportID, + policy, onPDFModalOpen: openPDFDownload, }); @@ -289,7 +302,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS report: moneyRequestReport, chatReport, reportTransactions: nonPendingDeleteTransactions, - originalTransaction: undefined, + originalTransaction, violations, bankAccountList, policy, diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index dd5f7ac24356..aaec0839cc80 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -26,15 +26,18 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLifecycleActions from '@hooks/useLifecycleActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; 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'; @@ -42,7 +45,7 @@ import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; import {sortPoliciesByName} from '@libs/PolicyUtils'; import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; -import {hasHeldExpenses, hasUpdatedTotal, isInvoiceReport as isInvoiceReportUtil, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; +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 {canIOUBePaid as canIOUBePaidAction} from '@userActions/IOU'; @@ -76,8 +79,9 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const activeAdminPolicies = useActiveAdminPolicies(); - const {selectedTransactionIDs} = useSearchStateContext(); + 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)}`); @@ -86,6 +90,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn 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); @@ -117,13 +122,17 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn 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 {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment} = useNonReimbursablePaymentModal(moneyRequestReport, allTransactionValues); const kycWallRef = useContext(KYCWallContext); @@ -135,6 +144,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const {beginExportWithTemplate, showOfflineModal, showDownloadErrorModal} = useExportActions({ reportID, + policy, }); const {confirmApproval, handleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval} = useLifecycleActions({ @@ -171,7 +181,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn report: moneyRequestReport, chatReport, reportTransactions: nonPendingDeleteTransactions, - originalTransaction: undefined, + originalTransaction, violations, bankAccountList, policy, @@ -213,6 +223,10 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn if (!type || !chatReport) { return; } + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); + return; + } isSelectionModePaymentRef.current = true; if (isDelegateAccessRestricted) { @@ -265,6 +279,16 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, onPaid: () => {}, }); + if (currentSearchQueryJSON && !isOffline) { + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } } clearSelectedTransactions(true); @@ -276,24 +300,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn chatReportID: chatReport?.reportID, formattedAmount: totalAmount, policyID: moneyRequestReport?.policyID, - onPress: ({paymentType: type, methodID}: PaymentActionParams) => { - if (!type || !chatReport) { - return; - } - - isSelectionModePaymentRef.current = true; - - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - } else { - openHoldMenu({ - requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, - paymentType: type, - methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - onConfirm: () => clearSelectedTransactions(true), - }); - } - }, + onPress: confirmPayment, shouldHidePaymentOptions: !isPayable, shouldShowApproveButton: false, shouldDisableApproveButton: false, @@ -445,7 +452,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn onPress: confirmPayment, currentAccountID: accountID, currentEmail: email ?? '', - hasViolations: false, + hasViolations, isASAPSubmitBetaEnabled, isUserValidated, confirmApproval: () => confirmApproval(), diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index cbb23e340d7d..45b53b5db4b9 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -13,16 +13,9 @@ import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; import MoneyReportHeaderSecondaryActions from './MoneyReportHeaderSecondaryActions'; import MoneyReportHeaderSelectionDropdown from './MoneyReportHeaderSelectionDropdown'; - -type MoneyReportHeaderActionsProps = { - reportID: string | undefined; - primaryAction: ValueOf | ValueOf | ''; - isReportInSearch?: boolean; - backTo?: Route; -}; +import type {MoneyReportHeaderActionsProps} from './types'; /** * Narrow the wide primaryAction union to what report-level secondary actions accept. diff --git a/src/components/MoneyReportHeaderActions/types.ts b/src/components/MoneyReportHeaderActions/types.ts index 00c4ac45b925..605c0fb95af9 100644 --- a/src/components/MoneyReportHeaderActions/types.ts +++ b/src/components/MoneyReportHeaderActions/types.ts @@ -1,17 +1,16 @@ -import type {StyleProp, ViewStyle} from 'react-native'; 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 | ''; - /** Style to apply when rendered inline (narrow layout inside HeaderWithBackButton) */ - style?: StyleProp; isReportInSearch?: boolean; + backTo?: Route; }; export type {SecondaryActionEntry, MoneyReportHeaderActionsProps}; diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index bfb91477ce99..7c4bf9eb021f 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -74,7 +74,7 @@ type UseExpenseActionsReturn = { addExpenseDropdownOptions: Array>; handleOptionsMenuHide: () => void; isDuplicateReportActive: boolean; - wasDuplicateReportTriggered: React.RefObject; + wasDuplicateReportTriggeredRef: React.RefObject; }; function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpenseActionsParams): UseExpenseActionsReturn { @@ -171,10 +171,10 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpe // Duplicate report throttle const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); - const wasDuplicateReportTriggered = useRef(false); + const wasDuplicateReportTriggeredRef = useRef(false); const handleOptionsMenuHide = () => { - wasDuplicateReportTriggered.current = false; + wasDuplicateReportTriggeredRef.current = false; }; // The dropdown ref is owned by the caller (orchestrator) — we close the menu by calling into it. @@ -360,7 +360,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpe } temporarilyDisableDuplicateReportAction(); - wasDuplicateReportTriggered.current = true; + wasDuplicateReportTriggeredRef.current = true; const isSourcePolicyValid = !!policy && isPolicyAccessible(policy, currentUserLogin ?? ''); const targetPolicyForDuplicate = isSourcePolicyValid ? policy : defaultExpensePolicy; @@ -476,7 +476,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpe isChatIOUReportArchived, false, ); - const deleteNavigateBackUrl = goBackRoute ?? Navigation.getActiveRoute(); + const deleteNavigateBackUrl = goBackRoute ?? backTo ?? Navigation.getActiveRoute(); setDeleteTransactionNavigateBackUrl(deleteNavigateBackUrl); if (goBackRoute) { navigateOnDeleteExpense(goBackRoute); @@ -548,7 +548,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpe addExpenseDropdownOptions, handleOptionsMenuHide, isDuplicateReportActive, - wasDuplicateReportTriggered, + wasDuplicateReportTriggeredRef, }; } diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index 6e29663a2af8..7b8003a80da9 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -1,3 +1,4 @@ +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'; @@ -13,6 +14,7 @@ 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'; @@ -22,12 +24,12 @@ import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; import usePaginatedReportActions from './usePaginatedReportActions'; -import usePolicy from './usePolicy'; import useThemeStyles from './useThemeStyles'; import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; type UseExportActionsParams = { reportID: string | undefined; + policy?: OnyxEntry; onPDFModalOpen?: () => void; }; @@ -39,13 +41,12 @@ type UseExportActionsReturn = { showDownloadErrorModal: () => void; }; -function useExportActions({reportID, onPDFModalOpen}: UseExportActionsParams): UseExportActionsReturn { +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 policy = usePolicy(moneyRequestReport?.policyID); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 649e24ec1340..36a01112d5ab 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -56,7 +56,7 @@ type UseLifecycleActionsResult = { 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}${moneyRequestReport?.chatReportID}`); + 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); From 669ee47fcd60988402f0b46696d8da2d05e5d52c Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 14 Apr 2026 13:11:45 +0200 Subject: [PATCH 33/38] CR comments --- src/components/MoneyReportHeader.tsx | 1 + .../MoneyReportHeaderSelectionDropdown.tsx | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ab932eb548c6..d722c48968aa 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -145,6 +145,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt reportID={reportIDProp} primaryAction={primaryAction} isReportInSearch={isReportInSearch} + backTo={backTo} /> )} diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index aaec0839cc80..d7e940a0af2e 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -2,7 +2,7 @@ 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, useRef} from 'react'; +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'; @@ -117,6 +117,13 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const {transactionThreadReportID, reportActions} = useTransactionThreadReport(reportID); + useEffect(() => { + if (!transactionThreadReportID) { + return; + } + clearSelectedTransactions(true); + }, [transactionThreadReportID]); + const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); const allTransactionValues = Object.values(reportTransactions); @@ -140,6 +147,13 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn 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({ From 5220aed2034a881d130f8152e44e31f2f0f2f86b Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 14 Apr 2026 15:10:04 +0200 Subject: [PATCH 34/38] CR Updates --- .../MoneyReportHeaderSelectionDropdown.tsx | 7 +++++-- src/hooks/useExpenseActions.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 149e82c8fd09..961f096f3d5a 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -68,7 +68,7 @@ type MoneyReportHeaderSelectionDropdownProps = { function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportInSearch, wrapperStyle}: MoneyReportHeaderSelectionDropdownProps) { const route = useRoute(); - const {startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); + const {startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {openHoldMenu: openHoldMenuAsync, openRejectModal} = useMoneyReportHeaderModals(); const openHoldMenu = (params: Parameters[0]) => { openHoldMenuAsync(params); @@ -122,6 +122,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn if (!transactionThreadReportID) { return; } + clearSelectedTransactions(true); }, [transactionThreadReportID]); @@ -292,7 +293,9 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - onPaid: () => {}, + onPaid: () => { + startAnimation(); + }, }); if (currentSearchQueryJSON && !isOffline) { search({ diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 7c4bf9eb021f..7d94927a851e 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -285,7 +285,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpe value: CONST.REPORT.SECONDARY_ACTIONS.SPLIT, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SPLIT, onSelected: () => { - if (Number(transactions?.length) !== 1) { + if (transactions.length !== 1) { return; } initSplitExpense(currentTransaction, policy); From 87d8edc5d9ef296261d5ca665d05cc03bda408c0 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 15 Apr 2026 09:50:12 +0200 Subject: [PATCH 35/38] remove non-reimbursable payment modal logic deleted on main --- .../MoneyReportHeaderSecondaryActions.tsx | 20 ++++--------------- .../MoneyReportHeaderSelectionDropdown.tsx | 7 ------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 029fe3f44ca8..1830ad4d7f9b 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -22,7 +22,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLifecycleActions from '@hooks/useLifecycleActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; @@ -44,7 +43,6 @@ import {getFilteredReportActionsForReportView, hasRequestFromCurrentAccount} fro import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import { hasHeldExpenses as hasHeldExpensesReportUtils, - hasOnlyNonReimbursableTransactions, hasViolations as hasViolationsReportUtils, isAllowedToApproveExpenseReport, isInvoiceReport as isInvoiceReportUtil, @@ -68,7 +66,7 @@ type MoneyReportHeaderSecondaryActionsProps = { }; function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { - const {startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); + const {isPaidAnimationRunning, startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); const {translate, localeCompare} = useLocalize(); @@ -127,16 +125,10 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyRequestReport?.reportID); const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); - const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment} = useNonReimbursablePaymentModal(moneyRequestReport, allTransactions); - const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { if (!type || !chatReport) { return; } - if (shouldBlockDirectPayment(type)) { - showNonReimbursablePaymentErrorModal(); - return; - } if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (isAnyTransactionOnHold) { @@ -207,12 +199,8 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS // Payment button derivations const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, false, undefined, invoiceReceiverPolicy); - const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, allTransactions); - const onlyShowPayElsewhere = - !reportHasOnlyNonReimbursableTransactions && - !canIOUBePaid && - canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, true, undefined, invoiceReceiverPolicy); - const isPayable = canIOUBePaid || onlyShowPayElsewhere || (reportHasOnlyNonReimbursableTransactions && (moneyRequestReport?.total ?? 0) !== 0); + const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, true, undefined, invoiceReceiverPolicy); + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; const isApprovable = canApproveIOU(moneyRequestReport, policy, reportMetadata, allTransactions); const isApproveDisabled = isApprovable && !isAllowedToApproveExpenseReport(moneyRequestReport); @@ -225,7 +213,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS formattedAmount: totalAmount, policyID: moneyRequestReport?.policyID, onPress: confirmPayment, - shouldHidePaymentOptions: !isPayable, + shouldHidePaymentOptions: !shouldShowPayButton, shouldShowApproveButton: isApprovable, shouldDisableApproveButton: isApproveDisabled, onlyShowPayElsewhere, diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 961f096f3d5a..ab0036fea598 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -26,7 +26,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLifecycleActions from '@hooks/useLifecycleActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; import usePaymentOptions from '@hooks/usePaymentOptions'; @@ -141,8 +140,6 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); - const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment} = useNonReimbursablePaymentModal(moneyRequestReport, allTransactionValues); - const kycWallRef = useContext(KYCWallContext); const {showConfirmModal} = useConfirmModal(); @@ -239,10 +236,6 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn if (!type || !chatReport) { return; } - if (shouldBlockDirectPayment(type)) { - showNonReimbursablePaymentErrorModal(); - return; - } isSelectionModePaymentRef.current = true; if (isDelegateAccessRestricted) { From d915471b527809f438272b6dfc5a707d2a59c3f9 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 15 Apr 2026 10:53:24 +0200 Subject: [PATCH 36/38] address PR review comments --- .../MoneyReportHeaderSecondaryActions.tsx | 26 +++++++++++----- .../MoneyReportHeaderSelectionDropdown.tsx | 11 ++----- .../MoneyReportHeaderActions/index.tsx | 31 +++++++++++++------ src/hooks/useExpenseActions.ts | 16 ++++++++-- src/hooks/useHoldRejectActions.ts | 5 ++- src/hooks/useLifecycleActions.tsx | 4 +-- 6 files changed, 61 insertions(+), 32 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 1830ad4d7f9b..f21263996f56 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -49,6 +49,7 @@ import { 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'; @@ -66,7 +67,7 @@ type MoneyReportHeaderSecondaryActionsProps = { }; function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { - const {isPaidAnimationRunning, startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); + const {isPaidAnimationRunning, isApprovedAnimationRunning, startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); const {translate, localeCompare} = useLocalize(); @@ -201,8 +202,9 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS 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 isApprovable = canApproveIOU(moneyRequestReport, policy, reportMetadata, allTransactions); - const isApproveDisabled = isApprovable && !isAllowedToApproveExpenseReport(moneyRequestReport); + 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); @@ -214,7 +216,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS policyID: moneyRequestReport?.policyID, onPress: confirmPayment, shouldHidePaymentOptions: !shouldShowPayButton, - shouldShowApproveButton: isApprovable, + shouldShowApproveButton, shouldDisableApproveButton: isApproveDisabled, onlyShowPayElsewhere, }); @@ -255,12 +257,22 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS reportID, startApprovedAnimation, startSubmittingAnimation, - onHoldMenuOpen: (requestType) => { - openHoldMenu({requestType, onConfirm: () => startApprovedAnimation()}); + onHoldMenuOpen: (requestType, onConfirm) => { + openHoldMenu({requestType, onConfirm: onConfirm ?? (() => startApprovedAnimation())}); }, }); - const {actions: expenseActions, handleOptionsMenuHide, isDuplicateReportActive, wasDuplicateReportTriggeredRef} = useExpenseActions({reportID, isReportInSearch, backTo}); + const { + actions: expenseActions, + handleOptionsMenuHide, + isDuplicateReportActive, + wasDuplicateReportTriggeredRef, + } = useExpenseActions({ + reportID, + isReportInSearch, + backTo, + onDuplicateReset: () => dropdownMenuRef?.current?.setIsMenuVisible(false), + }); useEffect(() => { if (!isDuplicateReportActive || !wasDuplicateReportTriggeredRef.current) { diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index ab0036fea598..428f6c546c98 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -117,14 +117,6 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const {transactionThreadReportID, reportActions} = useTransactionThreadReport(reportID); - useEffect(() => { - if (!transactionThreadReportID) { - return; - } - - clearSelectedTransactions(true); - }, [transactionThreadReportID]); - const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); const allTransactionValues = Object.values(reportTransactions); @@ -230,6 +222,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn 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) => { @@ -315,7 +308,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn shouldHidePaymentOptions: !isPayable, shouldShowApproveButton: false, shouldDisableApproveButton: false, - onlyShowPayElsewhere: false, + onlyShowPayElsewhere, }); const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 45b53b5db4b9..624c8c52a45a 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -1,9 +1,9 @@ -import React, {useRef} from 'react'; +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 {useSearchStateContext} from '@components/Search/SearchContext'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import useExportAgainModal from '@hooks/useExportAgainModal'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -47,9 +47,18 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, ba 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) { @@ -67,14 +76,16 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, ba return ( - - triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} - /> - + {!!primaryAction && ( + + triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} + /> + + )} void; }; type UseExpenseActionsReturn = { @@ -77,7 +79,8 @@ type UseExpenseActionsReturn = { wasDuplicateReportTriggeredRef: React.RefObject; }; -function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpenseActionsParams): UseExpenseActionsReturn { +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); @@ -208,8 +211,13 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpe hasCustomUnitOutOfPolicyViolation || activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID; - // Dropdown ref is owned by the orchestrator — no reset callback needed here. - const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(); + const handleDuplicateReset = () => { + if (shouldDuplicateCloseModalOnSelect) { + return; + } + onDuplicateReset?.(); + }; + const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(handleDuplicateReset); const targetPolicyTags = defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {}; @@ -306,6 +314,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpe [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) { @@ -350,6 +359,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo}: UseExpe [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, diff --git a/src/hooks/useHoldRejectActions.ts b/src/hooks/useHoldRejectActions.ts index c9994153488b..8c383634720d 100644 --- a/src/hooks/useHoldRejectActions.ts +++ b/src/hooks/useHoldRejectActions.ts @@ -9,6 +9,7 @@ import {changeMoneyRequestHoldStatus, isCurrentUserSubmitter, isDM} from '@libs/ 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'; @@ -41,6 +42,8 @@ function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpe 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; @@ -49,7 +52,7 @@ function useHoldRejectActions({reportID, onHoldEducationalOpen, onRejectModalOpe const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); - const isReportSubmitter = isCurrentUserSubmitter(moneyRequestReport); + const isReportSubmitter = isCurrentUserSubmitter(chatIOUReport); const isChatReportDM = isDM(chatReport); return { diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index f9dd4b66e6b9..610e6fd33a14 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -42,7 +42,7 @@ type UseLifecycleActionsParams = { reportID: string | undefined; startApprovedAnimation: () => void; startSubmittingAnimation: () => void; - onHoldMenuOpen: (requestType: ActionHandledType) => void; + onHoldMenuOpen: (requestType: ActionHandledType, onConfirm?: () => void) => void; }; type UseLifecycleActionsResult = { @@ -136,7 +136,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startSubmittingA return; } if (isAnyTransactionOnHold) { - onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); + onHoldMenuOpen(CONST.IOU.REPORT_ACTION_TYPE.APPROVE, skipAnimation ? undefined : () => startApprovedAnimation()); return; } if (!skipAnimation) { From e9a5713cadc06499d4e9bd5bd9ddc7eb8f5dc430 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 15 Apr 2026 15:24:30 +0200 Subject: [PATCH 37/38] Add isAccountLocked guard to secondary actions payment flow and JSDoc to useLifecycleActions --- .../MoneyReportHeaderSecondaryActions.tsx | 7 +++++++ src/hooks/useLifecycleActions.tsx | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index f21263996f56..e7eac2443a64 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -7,6 +7,7 @@ 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 {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; @@ -119,6 +120,8 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); const {currentSearchQueryJSON, currentSearchKey, currentSearchResults} = useSearchStateContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); @@ -351,6 +354,10 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); const onPaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { + if (isAccountLocked) { + showLockedAccountModal(); + return; + } selectPaymentType({ event, iouPaymentType, diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 610e6fd33a14..574d24266242 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -53,6 +53,10 @@ type UseLifecycleActionsResult = { 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)}`); From d02a6afc79a66b1a0a55088f7e4b229b934dd3e8 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 15 Apr 2026 15:44:13 +0200 Subject: [PATCH 38/38] Remove unnecessary checkForNecessaryAction from onPaymentSelect --- .../MoneyReportHeaderSecondaryActions.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index e7eac2443a64..f21263996f56 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -7,7 +7,6 @@ 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 {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; @@ -120,8 +119,6 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {isAccountLocked} = useLockedAccountState(); - const {showLockedAccountModal} = useLockedAccountActions(); const {currentSearchQueryJSON, currentSearchKey, currentSearchResults} = useSearchStateContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); @@ -354,10 +351,6 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); const onPaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { - if (isAccountLocked) { - showLockedAccountModal(); - return; - } selectPaymentType({ event, iouPaymentType,