From 4c7c1bff41cbe51d59934923fad5aded9b385dd7 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Sat, 20 Dec 2025 22:17:06 +0100 Subject: [PATCH 01/39] Revert "Revert "Add merge option when selecting transactions in the report page"" --- src/CONFIG.ts | 2 +- src/CONST/index.ts | 1 + src/ROUTES.ts | 24 +- src/components/MoneyReportHeader.tsx | 7 +- src/components/MoneyRequestHeader.tsx | 7 +- .../MoneyRequestReceiptView.tsx | 24 +- .../ReportActionItem/MoneyRequestView.tsx | 388 ++++++++++-------- .../ReportActionItemImage.tsx | 3 +- src/hooks/useMergeTransactions.ts | 76 ++++ src/hooks/useSelectedTransactionsActions.ts | 49 ++- src/libs/MergeTransactionUtils.ts | 144 ++++--- src/libs/Navigation/types.ts | 4 + src/libs/ReportSecondaryActionUtils.ts | 34 +- src/libs/ReportUtils.ts | 26 +- src/libs/TransactionUtils/index.ts | 6 +- src/libs/actions/MergeTransaction.ts | 129 +++--- src/pages/Search/SearchPage.tsx | 60 ++- .../TransactionDuplicate/Confirmation.tsx | 1 + .../TransactionMerge/ConfirmationPage.tsx | 89 +--- .../TransactionMerge/DetailsReviewPage.tsx | 109 +---- .../MergeTransactionsListContent.tsx | 111 ++--- .../TransactionMerge/ReceiptReviewPage.tsx | 14 +- .../TransactionMergeReceipts.tsx | 10 +- .../report/ReportActionItemContentCreated.tsx | 2 + .../routes/TransactionReceiptModalContent.tsx | 9 +- src/types/onyx/SearchResults.ts | 3 + tests/unit/MergeTransactionUtilsTest.ts | 243 ++++++++--- tests/unit/ReportSecondaryActionUtilsTest.ts | 183 ++++++++- .../useSelectedTransactionsActions.test.ts | 12 +- 29 files changed, 1074 insertions(+), 696 deletions(-) create mode 100644 src/hooks/useMergeTransactions.ts diff --git a/src/CONFIG.ts b/src/CONFIG.ts index 7870c32cb0302..66cfadb04bc76 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -99,7 +99,7 @@ export default { SUFFIX: ENVIRONMENT === CONST.ENVIRONMENT.DEV ? get(Config, 'PUSHER_DEV_SUFFIX', '') : '', CLUSTER: 'mt1', }, - SITE_TITLE: 'New Expensify', + SITE_TITLE: ENVIRONMENT === CONST.ENVIRONMENT.DEV ? 'New Expensify [dev]' : 'New Expensify', FAVICON: { DEFAULT: '/favicon.png', UNREAD: '/favicon-unread.png', diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 73eca3749005d..365315c42befe 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6693,6 +6693,7 @@ const CONST = { PAY: 'pay', SUBMIT: 'submit', HOLD: 'hold', + MERGE: 'merge', UNHOLD: 'unhold', DELETE: 'delete', REJECT: 'reject', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 27d9c8432e8ef..c5ad171bdb9d7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2833,26 +2833,34 @@ const ROUTES = { MERGE_TRANSACTION_LIST_PAGE: { route: 'r/:transactionID/merge', - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (transactionID: string | undefined, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge` as const, backTo), + getRoute: (transactionID: string, backTo: string) => { + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + return getUrlWithBackToParam(`r/${transactionID}/merge` as const, backTo); + }, }, MERGE_TRANSACTION_RECEIPT_PAGE: { route: 'r/:transactionID/merge/receipt', - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/receipt` as const, backTo), + getRoute: (transactionID: string, backTo: string) => { + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + return getUrlWithBackToParam(`r/${transactionID}/merge/receipt` as const, backTo); + }, }, MERGE_TRANSACTION_DETAILS_PAGE: { route: 'r/:transactionID/merge/details', - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/details` as const, backTo), + getRoute: (transactionID: string, backTo: string) => { + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + return getUrlWithBackToParam(`r/${transactionID}/merge/details` as const, backTo); + }, }, MERGE_TRANSACTION_CONFIRMATION_PAGE: { route: 'r/:transactionID/merge/confirmation', - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/confirmation` as const, backTo), + getRoute: (transactionID: string, backTo: string) => { + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + return getUrlWithBackToParam(`r/${transactionID}/merge/confirmation` as const, backTo); + }, }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'workspaces/:policyID/accounting/xero/import', diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ea05dc2479547..75687a4836536 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -33,7 +33,7 @@ import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {openOldDotLink} from '@libs/actions/Link'; -import {setupMergeTransactionData} from '@libs/actions/MergeTransaction'; +import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {createTransactionThreadReport, deleteAppReport, downloadReportPDF, exportReportToCSV, exportReportToPDF, exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; import {getExportTemplates, queueExportSearchWithTemplate, search} from '@libs/actions/Search'; @@ -244,7 +244,7 @@ function MoneyReportHeader({ 'ReceiptPlus', ] as const); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, {canBeMissing: true}); - const {translate} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const exportTemplates = useMemo( () => getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy), @@ -1237,8 +1237,7 @@ function MoneyReportHeader({ return; } - setupMergeTransactionData(currentTransaction.transactionID, {targetTransactionID: currentTransaction.transactionID}); - Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(currentTransaction.transactionID, Navigation.getActiveRoute())); + setupMergeTransactionDataAndNavigate([currentTransaction], localeCompare); }, }, [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE]: { diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 5b55d79200c2e..ed9c56bec30bb 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -21,7 +21,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {deleteTrackExpense, initSplitExpense, markRejectViolationAsResolved} from '@libs/actions/IOU'; -import {setupMergeTransactionData} from '@libs/actions/MergeTransaction'; +import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {setNameValuePair} from '@libs/actions/User'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; @@ -130,7 +130,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const shouldShowLoadingBar = useLoadingBarVisibility(); const styles = useThemeStyles(); const theme = useTheme(); - const {translate} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['CreditCardHourglass', 'ReceiptScan']); const {login: currentUserLogin, email, accountID} = useCurrentUserPersonalDetails(); const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -398,8 +398,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre return; } - setupMergeTransactionData(transaction.transactionID, {targetTransactionID: transaction.transactionID}); - Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(transaction.transactionID, Navigation.getActiveRoute())); + setupMergeTransactionDataAndNavigate([transaction], localeCompare); }, }, [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE]: { diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index ef50ba5332714..0097174234be0 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -1,5 +1,5 @@ import mapValues from 'lodash/mapValues'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -27,7 +27,7 @@ import { getCreationReportErrors, isInvoiceReport, isPaidGroupPolicy, - isTrackExpenseReport, + isTrackExpenseReportNew, } from '@libs/ReportUtils'; import { didReceiptScanSucceed as didReceiptScanSucceedTransactionUtils, @@ -112,7 +112,7 @@ function MoneyRequestReceiptView({ const [isLoading, setIsLoading] = useState(true); const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(parentReportAction); - const isTrackExpense = isTrackExpenseReport(report); + const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(report, parentReport, parentReportAction); const moneyRequestReport = parentReport; const linkedTransactionID = useMemo(() => { if (!parentReportAction) { @@ -163,7 +163,7 @@ function MoneyRequestReceiptView({ const transactionToCheck = updatedTransaction ?? transaction; const doesTransactionHaveReceipt = !!transactionToCheck?.receipt && !isEmptyObject(transactionToCheck?.receipt); - const shouldShowReceiptEmptyState = !isInvoice && !hasReceipt && !!transaction && !doesTransactionHaveReceipt; + const shouldShowReceiptEmptyState = !isInvoice && !hasReceipt && !!transactionToCheck && !doesTransactionHaveReceipt; const [receiptImageViolations, receiptViolations] = useMemo(() => { const imageViolations = []; @@ -213,7 +213,7 @@ function MoneyRequestReceiptView({ [transaction?.errors, parentReportAction?.errors], ); - const dismissReceiptError = useCallback(() => { + const dismissReceiptError = () => { if (!report?.reportID) { return; } @@ -248,19 +248,7 @@ function MoneyRequestReceiptView({ } navigateToConciergeChatAndDeleteReport(report.reportID, true, true); } - }, [ - transaction, - chatReport, - parentReportAction, - linkedTransactionID, - report?.reportID, - iouReport, - chatIOUReport, - isChatIOUReportArchived, - errorsWithoutReportCreation, - reportCreationError, - isInNarrowPaneModal, - ]); + }; let receiptStyle: StyleProp; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index ce7109f3dbe5b..dc8883c29173a 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -49,13 +49,12 @@ import { isTaxTrackingEnabled, } from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getReportName} from '@libs/ReportNameUtils'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; -import type {TransactionDetails} from '@libs/ReportUtils'; import { canEditFieldOfMoneyRequest, canEditMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, // eslint-disable-next-line @typescript-eslint/no-deprecated - getReportName, getTransactionDetails, getTripIDFromTransactionParentReportID, isInvoiceReport, @@ -63,7 +62,7 @@ import { isReportApproved, isReportInGroupPolicy, isSettled as isSettledReportUtils, - isTrackExpenseReport, + isTrackExpenseReportNew, shouldEnableNegative, } from '@libs/ReportUtils'; import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; @@ -107,7 +106,9 @@ type MoneyRequestViewProps = { allReports: OnyxCollection; /** The report currently being looked at */ - report: OnyxEntry; + report?: OnyxEntry; + + parentReportID?: string; /** Policy that the report belongs to */ expensePolicy: OnyxEntry; @@ -141,7 +142,8 @@ const perDiemPoliciesSelector = (policies: OnyxCollection) => function MoneyRequestView({ allReports, - report, + report: transactionThreadReport, + parentReportID, expensePolicy, shouldShowAnimatedBackground, readonly = false, @@ -161,23 +163,26 @@ function MoneyRequestView({ const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); - const parentReportID = report?.parentReportID; - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, {canBeMissing: true}); + + const parentReportActionSelector = (reportActions: OnyxEntry) => + transactionThreadReport?.parentReportActionID ? reportActions?.[transactionThreadReport.parentReportActionID] : undefined; + + // The parentReportActionSelector is memoized by React Compiler + // eslint-disable-next-line rulesdir/no-inline-useOnyx-selector + const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { canEvict: false, canBeMissing: true, + selector: parentReportActionSelector, }); - const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; + const isFromMergeTransaction = !!mergeTransactionID; - const linkedTransactionID = useMemo(() => { - if (!parentReportAction) { - return undefined; - } - const originalMessage = parentReportAction && isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction) : undefined; - return originalMessage?.IOUTransactionID; - }, [parentReportAction]); - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(linkedTransactionID)}`, {canBeMissing: true}); - const isExpenseUnreported = isExpenseUnreportedTransactionUtils(updatedTransaction ?? transaction); + const linkedTransactionID = parentReportAction && isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; + let [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(linkedTransactionID)}`, {canBeMissing: true}); + if (!transaction && !!updatedTransaction) { + transaction = updatedTransaction; + } + const isExpenseUnreported = isExpenseUnreportedTransactionUtils(transaction); const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); const [policiesWithPerDiem] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { @@ -199,13 +204,12 @@ function MoneyRequestView({ policyID = perDiemOriginalPolicy?.id; } else { policy = expensePolicy; - policyID = report?.policyID; + policyID = parentReport?.policyID; } const allPolicyCategories = usePolicyCategories(); const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; - const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${updatedTransaction?.reportID}`]; - const targetPolicyID = updatedTransaction?.reportID ? transactionReport?.policyID : policyID; + const targetPolicyID = updatedTransaction?.reportID ? parentReport?.policyID : policyID; const allPolicyTags = usePolicyTags(); const policyTagList = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`]; const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {canBeMissing: true}); @@ -222,7 +226,7 @@ function MoneyRequestView({ const moneyRequestReport = parentReport; const isApproved = isReportApproved({report: moneyRequestReport}); const isInvoice = isInvoiceReport(moneyRequestReport); - const isTrackExpense = isTrackExpenseReport(report); + const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); const iouType = useMemo(() => { if (isTrackExpense) { @@ -252,10 +256,7 @@ function MoneyRequestView({ originalAmount: transactionOriginalAmount, originalCurrency: transactionOriginalCurrency, postedDate: transactionPostedDate, - } = useMemo>( - () => getTransactionDetails(transaction, undefined, undefined, allowNegativeAmount, false, currentUserPersonalDetails) ?? {}, - [allowNegativeAmount, currentUserPersonalDetails, transaction], - ); + } = getTransactionDetails(transaction, undefined, undefined, allowNegativeAmount, false, currentUserPersonalDetails) ?? {}; const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); @@ -296,10 +297,10 @@ function MoneyRequestView({ // Flags for allowing or disallowing editing an expense // Used for non-restricted fields such as: description, category, tag, billable, etc... - const isReportArchived = useReportIsArchived(report?.reportID); - const isEditable = !!canUserPerformWriteActionReportUtils(report, isReportArchived) && !readonly; + const isReportArchived = useReportIsArchived(transactionThreadReport?.reportID); + const isEditable = !!canUserPerformWriteActionReportUtils(transactionThreadReport, isReportArchived) && !readonly; const canEdit = isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, isChatReportArchived, moneyRequestReport, policy, transaction) && isEditable; - const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(report?.policyID)}`; + const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(transactionThreadReport?.policyID)}`; const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`, {canBeMissing: true}); const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); const isSplitAvailable = moneyRequestReport && transaction && isSplitAction(moneyRequestReport, [transaction], originalTransaction, policy); @@ -318,8 +319,8 @@ function MoneyRequestView({ // A flag for verifying that the current report is a sub-report of a expense chat // if the policy of the report is either Collect or Control, then this report must be tied to expense chat - const isPolicyExpenseChat = isReportInGroupPolicy(report); - const policyTagLists = useMemo(() => getTagLists(policyTagList), [policyTagList]); + const isPolicyExpenseChat = isReportInGroupPolicy(moneyRequestReport); + const policyTagLists = getTagLists(policyTagList); const category = transactionCategory ?? ''; const categoryForDisplay = isCategoryMissing(category) ? '' : category; @@ -343,13 +344,13 @@ function MoneyRequestView({ !isCardTransaction && !isInvoice; const canEditReimbursable = isEditable && canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, undefined, isChatReportArchived); - const shouldShowAttendees = useMemo(() => shouldShowAttendeesTransactionUtils(iouType, policy), [iouType, policy]); + const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest, isPerDiemRequest); const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; - const {getViolationsForField} = useViolations(transactionViolations ?? [], isTransactionScanning || !isPaidGroupPolicy(report)); + const {getViolationsForField} = useViolations(transactionViolations ?? [], isTransactionScanning || !isPaidGroupPolicy(transactionThreadReport)); const hasViolations = useCallback( (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0, @@ -375,56 +376,45 @@ function MoneyRequestView({ const shouldNavigateToUpgradePath = !policyForMovingExpenses && !shouldSelectPolicy; - const updatedTransactionDescription = useMemo(() => { - if (!updatedTransaction) { - return undefined; - } - return getDescription(updatedTransaction ?? null); - }, [updatedTransaction]); + const updatedTransactionDescription = getDescription(updatedTransaction) || undefined; const isEmptyUpdatedMerchant = updatedTransaction?.modifiedMerchant === '' || updatedTransaction?.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const updatedMerchantTitle = isEmptyUpdatedMerchant ? '' : (updatedTransaction?.modifiedMerchant ?? merchantTitle); - const saveBillable = useCallback( - (newBillable: boolean) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newBillable === getBillable(transaction) || !transaction?.transactionID || !report?.reportID) { - return; - } - updateMoneyRequestBillable( - transaction.transactionID, - report?.reportID, - newBillable, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - ); - }, - [transaction, report?.reportID, policy, policyTagList, policyCategories, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled], - ); + const saveBillable = (newBillable: boolean) => { + // If the value hasn't changed, don't request to save changes on the server and just close the modal + if (newBillable === getBillable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + updateMoneyRequestBillable( + transaction.transactionID, + transactionThreadReport?.reportID, + newBillable, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + ); + }; - const saveReimbursable = useCallback( - (newReimbursable: boolean) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !report?.reportID) { - return; - } - updateMoneyRequestReimbursable( - transaction.transactionID, - report?.reportID, - newReimbursable, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - ); - }, - [transaction, report?.reportID, policy, policyTagList, policyCategories, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled], - ); + const saveReimbursable = (newReimbursable: boolean) => { + // If the value hasn't changed, don't request to save changes on the server and just close the modal + if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + updateMoneyRequestReimbursable( + transaction.transactionID, + transactionThreadReport?.reportID, + newReimbursable, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + ); + }; if (isCardTransaction) { if (transactionPostedDate) { @@ -463,84 +453,53 @@ function MoneyRequestView({ // Need to return undefined when we have pendingAction to avoid the duplicate pending action const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => (pendingAction ? undefined : transaction?.pendingFields?.[fieldPath]); - const getErrorForField = useCallback( - (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { - // Checks applied when creating a new expense - // NOTE: receipt field can return multiple violations, so we need to handle it separately - const fieldChecks: Partial> = { - amount: { - isError: transactionAmount === 0, - translationPath: canEditAmount ? 'common.error.enterAmount' : 'common.error.missingAmount', - }, - merchant: { - isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, - translationPath: canEditMerchant ? 'common.error.enterMerchant' : 'common.error.missingMerchantName', - }, - date: { - isError: transactionDate === '', - translationPath: canEditDate ? 'common.error.enterDate' : 'common.error.missingDate', - }, - }; - - const {isError, translationPath} = fieldChecks[field] ?? {}; - - if (readonly) { - return ''; - } - - // Return form errors if there are any - if (hasErrors && isError && translationPath) { - return translate(translationPath); - } - - if (isCustomUnitOutOfPolicy && field === 'customUnitRateID') { - return translate('violations.customUnitOutOfPolicy'); - } + const getErrorForField = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { + // Checks applied when creating a new expense + // NOTE: receipt field can return multiple violations, so we need to handle it separately + const fieldChecks: Partial> = { + amount: { + isError: transactionAmount === 0, + translationPath: canEditAmount ? 'common.error.enterAmount' : 'common.error.missingAmount', + }, + merchant: { + isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, + translationPath: canEditMerchant ? 'common.error.enterMerchant' : 'common.error.missingMerchantName', + }, + date: { + isError: transactionDate === '', + translationPath: canEditDate ? 'common.error.enterDate' : 'common.error.missingDate', + }, + }; - // Return violations if there are any - if (field !== 'merchant' && hasViolations(field, data, policyHasDependentTags, tagValue)) { - const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); - return `${violations.map((violation) => ViolationsUtils.getViolationTranslation(violation, translate, canEdit, undefined, companyCardPageURL)).join('. ')}.`; - } + const {isError, translationPath} = fieldChecks[field] ?? {}; + if (readonly) { return ''; - }, - [ - transactionAmount, - isSettled, - isCancelled, - isPolicyExpenseChat, - isEmptyMerchant, - transactionDate, - readonly, - hasErrors, - hasViolations, - translate, - getViolationsForField, - canEditAmount, - canEditDate, - canEditMerchant, - canEdit, - isCustomUnitOutOfPolicy, - companyCardPageURL, - ], - ); + } - const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; - const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; - const amountCopyValue = !canEditAmount ? amountTitle : undefined; - const descriptionCopyValue = useMemo(() => { - if (canEdit) { - return undefined; + // Return form errors if there are any + if (hasErrors && isError && translationPath) { + return translate(translationPath); } - const descriptionHTML = updatedTransactionDescription ?? transactionDescription; - if (!descriptionHTML) { - return undefined; + if (isCustomUnitOutOfPolicy && field === 'customUnitRateID') { + return translate('violations.customUnitOutOfPolicy'); } - return Parser.htmlToText(descriptionHTML); - }, [canEdit, transactionDescription, updatedTransactionDescription]); + // Return violations if there are any + if (field !== 'merchant' && hasViolations(field, data, policyHasDependentTags, tagValue)) { + const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); + return `${violations.map((violation) => ViolationsUtils.getViolationTranslation(violation, translate, canEdit, undefined, companyCardPageURL)).join('. ')}.`; + } + + return ''; + }; + + const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; + const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; + const amountCopyValue = !canEditAmount ? amountTitle : undefined; + const descriptionHTML = updatedTransactionDescription ?? transactionDescription; + const descriptionCopyValue = !canEdit && descriptionHTML ? Parser.htmlToText(descriptionHTML) : undefined; const merchantCopyValue = !canEditMerchant ? updatedMerchantTitle : undefined; const dateCopyValue = !canEditDate ? transactionDate : undefined; const categoryValue = updatedTransaction?.category ?? categoryForDisplay; @@ -562,7 +521,7 @@ function MoneyRequestView({ shouldShowRightIcon={canEditDistance} titleStyle={styles.flex1} onPress={() => { - if (!transaction?.transactionID || !report?.reportID) { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } @@ -573,13 +532,25 @@ function MoneyRequestView({ if (isManualDistanceRequest) { Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport.reportID, + getReportRHPActiveRoute(), + ), ); return; } Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport.reportID, + getReportRHPActiveRoute(), + ), ); }} copyValue={distanceCopyValue} @@ -594,7 +565,7 @@ function MoneyRequestView({ shouldShowRightIcon={canEditDistanceRate} titleStyle={styles.flex1} onPress={() => { - if (!transaction?.transactionID || !report?.reportID) { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } @@ -604,7 +575,13 @@ function MoneyRequestView({ } Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport.reportID, + getReportRHPActiveRoute(), + ), ); }} brickRoadIndicator={getErrorForField('customUnitRateID') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -631,9 +608,7 @@ function MoneyRequestView({ setCurrentTransactionTag(transactionTag); }, [transactionTag, previousTransactionTag]); - const getAttendeesTitle = useMemo(() => { - return Array.isArray(actualAttendees) ? actualAttendees.map((item) => item?.displayName ?? item?.login).join(', ') : ''; - }, [actualAttendees]); + const getAttendeesTitle = Array.isArray(actualAttendees) ? actualAttendees.map((item) => item?.displayName ?? item?.login).join(', ') : ''; const attendeesCopyValue = !canEdit ? getAttendeesTitle : undefined; const previousTagLength = getLengthOfTag(previousTag ?? ''); @@ -700,11 +675,18 @@ function MoneyRequestView({ shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => { - if (!transaction?.transactionID || !report?.reportID) { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + orderWeight, + transaction.transactionID, + transactionThreadReport.reportID, + getReportRHPActiveRoute(), + ), ); }} brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -718,17 +700,17 @@ function MoneyRequestView({ ); }); - // eslint-disable-next-line @typescript-eslint/no-deprecated - const reportNameToDisplay = isFromMergeTransaction ? updatedTransaction?.reportName : getReportName(parentReport) || parentReport?.reportName; + const reportNameToDisplay = isFromMergeTransaction ? (updatedTransaction?.reportName ?? translate('common.none')) : getReportName(parentReport) || parentReport?.reportName; const shouldShowReport = !!parentReportID || (isFromMergeTransaction && !!reportNameToDisplay); - const reportCopyValue = !canEditReport ? reportNameToDisplay : undefined; + const reportCopyValue = !canEditReport && reportNameToDisplay !== translate('common.none') ? reportNameToDisplay : undefined; // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const {wideRHPRouteKeys} = useContext(WideRHPContext); - if (!report?.reportID || !transaction?.transactionID) { + // If the view is readonly, we don't need the transactionThread dependency + if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { return ; } @@ -739,7 +721,7 @@ function MoneyRequestView({ {(wideRHPRouteKeys.length === 0 || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( { - if (!transaction?.transactionID || !report?.reportID) { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } @@ -782,7 +764,15 @@ function MoneyRequestView({ } Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, '', '', getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport.reportID, + '', + '', + getReportRHPActiveRoute(), + ), ); }} brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -801,7 +791,13 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => { Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + getReportRHPActiveRoute(), + ), ); }} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} @@ -824,7 +820,13 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => { Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + getReportRHPActiveRoute(), + ), ); }} wrapperStyle={[styles.taskDescriptionMenuItem]} @@ -845,7 +847,13 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => { Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + getReportRHPActiveRoute(), + ), ); }} brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -864,13 +872,13 @@ function MoneyRequestView({ shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => { - if (shouldNavigateToUpgradePath) { + if (shouldNavigateToUpgradePath && transactionThreadReport) { Navigation.navigate( ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ action: CONST.IOU.ACTION.EDIT, iouType, transactionID: transaction.transactionID, - reportID: report.reportID, + reportID: transactionThreadReport?.reportID, upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, }), ); @@ -881,14 +889,20 @@ function MoneyRequestView({ CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, - report.reportID, + transactionThreadReport?.reportID, Navigation.getActiveRoute(), ), ), ); } else { Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, Navigation.getActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + Navigation.getActiveRoute(), + ), ); } }} @@ -922,7 +936,13 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => { Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + getReportRHPActiveRoute(), + ), ); }} brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -942,7 +962,13 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => { Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + getReportRHPActiveRoute(), + ), ); }} copyValue={taxAmountCopyValue} @@ -963,7 +989,7 @@ function MoneyRequestView({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID)); + Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)); }} interactive={canEdit} shouldShowRightIcon={canEdit} @@ -1024,7 +1050,7 @@ function MoneyRequestView({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - if (!canEditReport) { + if (!canEditReport || !transactionThreadReport) { return; } if (shouldNavigateToUpgradePath) { @@ -1033,7 +1059,7 @@ function MoneyRequestView({ iouType, action: CONST.IOU.ACTION.EDIT, transactionID: transaction?.transactionID, - reportID: report.reportID, + reportID: transactionThreadReport?.reportID, upgradePath: CONST.UPGRADE_PATHS.REPORTS, }), ); @@ -1044,7 +1070,7 @@ function MoneyRequestView({ CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, - report.reportID, + transactionThreadReport?.reportID, getReportRHPActiveRoute() || lastVisitedPath, ), ); @@ -1064,9 +1090,9 @@ function MoneyRequestView({ onPress={() => { const reservations = transaction?.receipt?.reservationList?.length ?? 0; if (reservations > 1) { - Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(report.reportID, transaction.transactionID, getReportRHPActiveRoute())); + Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID, getReportRHPActiveRoute())); } - Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(report.reportID, transaction.transactionID, '0', 0, getReportRHPActiveRoute())); + Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0, getReportRHPActiveRoute())); }} /> )} diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index d4f87ba2017d2..1ea38d3844d81 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -13,6 +13,7 @@ import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getReportIDForExpense} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEReceipt, hasReceiptSource, isDistanceRequest, isFetchingWaypointsFromServer, isManualDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; @@ -185,7 +186,7 @@ function ReportActionItemImage({ onPress={() => Navigation.navigate( ROUTES.TRANSACTION_RECEIPT.getRoute( - transactionThreadReport?.reportID ?? report?.reportID ?? reportProp?.reportID, + transactionThreadReport?.reportID ?? report?.reportID ?? reportProp?.reportID ?? getReportIDForExpense(transaction), transaction?.transactionID, readonly, isFromReviewDuplicates, diff --git a/src/hooks/useMergeTransactions.ts b/src/hooks/useMergeTransactions.ts new file mode 100644 index 0000000000000..e0b33122f1d34 --- /dev/null +++ b/src/hooks/useMergeTransactions.ts @@ -0,0 +1,76 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {useSearchContext} from '@components/Search/SearchContext'; +import {getTransactionFromMergeTransaction} from '@libs/MergeTransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MergeTransaction, Report, SearchResults, Transaction} from '@src/types/onyx'; +import useOnyx from './useOnyx'; + +type UseMergeTransactionsProps = { + mergeTransaction?: MergeTransaction; +}; + +type UseMergeTransactionsReturn = { + targetTransaction?: Transaction; + sourceTransaction?: Transaction; + targetTransactionReport?: Report; + sourceTransactionReport?: Report; +}; + +function getTransaction( + mergeTransaction: MergeTransaction | undefined, + transactionID: string | undefined, + onyxTransaction: OnyxEntry, + currentSearchResults: SearchResults | undefined, +) { + if (!transactionID) { + return undefined; + } + + const transaction = getTransactionFromMergeTransaction(mergeTransaction, transactionID); + if (transaction) { + return transaction; + } + + return currentSearchResults?.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? onyxTransaction; +} + +function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): UseMergeTransactionsReturn { + const searchContext = useSearchContext(); + const searchHash = searchContext?.currentSearchHash ?? CONST.DEFAULT_NUMBER_ID; + const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${searchHash}`, {canBeMissing: true}); + + const [onyxTargetTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.targetTransactionID}`, { + canBeMissing: true, + }); + const [onyxSourceTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.sourceTransactionID}`, { + canBeMissing: true, + }); + + const targetTransaction = getTransaction(mergeTransaction, mergeTransaction?.targetTransactionID, onyxTargetTransaction, currentSearchResults); + const sourceTransaction = getTransaction(mergeTransaction, mergeTransaction?.sourceTransactionID, onyxSourceTransaction, currentSearchResults); + + const [onyxTargetTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${targetTransaction?.reportID}`, { + canBeMissing: true, + }); + const [onyxSourceTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`, { + canBeMissing: true, + }); + + // Always use transactions from the search snapshot if we're coming from the Reports page + let targetTransactionReport = onyxTargetTransactionReport; + let sourceTransactionReport = onyxSourceTransactionReport; + if (searchHash && currentSearchResults?.data) { + targetTransactionReport = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${targetTransaction?.reportID}`] ?? onyxTargetTransactionReport; + sourceTransactionReport = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`] ?? onyxSourceTransactionReport; + } + + return { + targetTransaction, + sourceTransaction, + targetTransactionReport, + sourceTransactionReport, + }; +} + +export default useMergeTransactions; diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 7ef4a8b8c5fda..6dd82b2acec52 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -2,12 +2,12 @@ import {useCallback, useMemo, useState} from 'react'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchContext} from '@components/Search/SearchContext'; import {initSplitExpense, unholdRequest} from '@libs/actions/IOU'; -import {setupMergeTransactionData} from '@libs/actions/MergeTransaction'; +import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {exportReportToCSV} from '@libs/actions/Report'; import {getExportTemplates} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID, getReportAction, isDeletedAction} from '@libs/ReportActionsUtils'; -import {isMergeAction, isSplitAction} from '@libs/ReportSecondaryActionUtils'; +import {isMergeActionForSelectedTransactions, isSplitAction} from '@libs/ReportSecondaryActionUtils'; import { canDeleteCardTransactionByLiabilityType, canDeleteTransaction, @@ -128,9 +128,10 @@ function useSelectedTransactionsActions({ return false; }, [selectedTransactionsList, selectedTransactionsMeta, selectedTransactionIDs.length]); - const {translate} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); - // eslint-disable-next-line @typescript-eslint/no-deprecated + + // eslint-disable-next-line @typescript-eslint/no-deprecated const isTrackExpenseThread = isTrackExpenseReport(report); const isInvoice = isInvoiceReport(report); @@ -309,6 +310,15 @@ function useSelectedTransactionsActions({ }); } + const canMergeTransaction = selectedTransactionsList.length < 3 && report && policy && isMergeActionForSelectedTransactions(selectedTransactionsList, [report], [policy]); + if (canMergeTransaction) { + options.push({ + text: translate('common.merge'), + icon: expensifyIcons.ArrowCollapse, + value: MERGE, + onSelected: () => setupMergeTransactionDataAndNavigate(selectedTransactionsList, localeCompare), + }); + } const firstTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${selectedTransactionIDs.at(0)}`]; const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransaction?.comment?.originalTransactionID}`]; @@ -326,26 +336,6 @@ function useSelectedTransactionsActions({ }); } - // In phase 1, we only show merge action if report is eligible for merge and only one transaction is selected - const canMergeTransaction = selectedTransactionsList.length === 1 && report && isMergeAction(report, selectedTransactionsList, policy); - if (canMergeTransaction) { - options.push({ - text: translate('common.merge'), - icon: expensifyIcons.ArrowCollapse, - value: MERGE, - onSelected: () => { - const targetTransaction = selectedTransactionsList.at(0); - - if (!report || !targetTransaction) { - return; - } - - setupMergeTransactionData(targetTransaction.transactionID, {targetTransactionID: targetTransaction.transactionID}); - Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(targetTransaction.transactionID, Navigation.getActiveRoute())); - }, - }); - } - const canAllSelectedTransactionsBeRemoved = selectedTransactionsList.every((transaction) => { const canRemoveTransaction = canDeleteCardTransactionByLiabilityType(transaction); const action = getIOUActionForTransactionID(reportActions, transaction.transactionID); @@ -390,7 +380,16 @@ function useSelectedTransactionsActions({ allReports, session?.accountID, showDeleteModal, - expensifyIcons, + expensifyIcons.Stopwatch, + expensifyIcons.ThumbsDown, + expensifyIcons.Table, + expensifyIcons.Export, + expensifyIcons.ArrowRight, + expensifyIcons.ArrowSplit, + expensifyIcons.DocumentMerge, + expensifyIcons.ArrowCollapse, + expensifyIcons.Trashcan, + localeCompare, ]); return { diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index b07fa6adccae7..7f1117d700fa8 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -4,17 +4,32 @@ import type {TupleToUnion} from 'type-fest'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {MergeTransaction, Transaction} from '@src/types/onyx'; +import type {MergeTransaction, Report, Transaction} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import SafeString from '@src/utils/SafeString'; import {convertToDisplayString} from './CurrencyUtils'; import Parser from './Parser'; import {getCommaSeparatedTagNameWithSanitizedColons} from './PolicyUtils'; import {getIOUActionForReportID} from './ReportActionsUtils'; -import {findSelfDMReportID, getReportName, getReportOrDraftReport, getTransactionDetails} from './ReportUtils'; +import {getReportName} from './ReportNameUtils'; +import {findSelfDMReportID, getReportOrDraftReport, getTransactionDetails, isIOUReport} from './ReportUtils'; import type {TransactionDetails} from './ReportUtils'; import StringUtils from './StringUtils'; -import {getAttendeesListDisplayString, getCurrency, getReimbursable, getWaypoints, isDistanceRequest, isExpenseSplit, isManagedCardTransaction, isMerchantMissing} from './TransactionUtils'; +import { + getAmount, + getAttendeesListDisplayString, + getCurrency, + getReimbursable, + getWaypoints, + isDistanceRequest, + isExpenseSplit, + isFetchingWaypointsFromServer, + isManagedCardTransaction, + isMerchantMissing, + isPerDiemRequest, + isScanning, + isTransactionPendingDelete, +} from './TransactionUtils'; const RECEIPT_SOURCE_URL = 'https://www.expensify.com/receipts/'; @@ -86,32 +101,6 @@ const getTransactionFromMergeTransaction = (mergeTransaction: OnyxEntry) => { - if (!mergeTransaction?.sourceTransactionID) { - return undefined; - } - - return getTransactionFromMergeTransaction(mergeTransaction, mergeTransaction.sourceTransactionID); -}; - -/** - * Get the target transaction from a merge transaction - * @param mergeTransaction - The merge transaction to get the target transaction from - * @returns The target transaction or null if it doesn't exist - */ -const getTargetTransactionFromMergeTransaction = (mergeTransaction: OnyxEntry) => { - if (!mergeTransaction?.targetTransactionID) { - return undefined; - } - - return getTransactionFromMergeTransaction(mergeTransaction, mergeTransaction.targetTransactionID); -}; - /** * Check if the user should navigate to the receipt review page * @param transactions - array of target and source transactions @@ -180,15 +169,15 @@ function getMergeFields(targetTransaction: OnyxEntry) { * Get mergeableData data if one is missing, and conflict fields that need to be resolved by the user * @param targetTransaction - The target transaction * @param sourceTransaction - The source transaction - * @param originalTargetTransaction - The original transaction of target transaction + * @param localeCompare - The localize compare function * @param localeCompare - The localize compare function * @returns mergeableData and conflictFields */ function getMergeableDataAndConflictFields( targetTransaction: OnyxEntry, sourceTransaction: OnyxEntry, - originalTargetTransaction: OnyxEntry, - localeCompare: (a: string, b: string) => number, + localeCompare: LocaleContextProps['localeCompare'], + searchReports: Array> = [], ) { const conflictFields: string[] = []; const mergeableData: Record = {}; @@ -207,7 +196,7 @@ function getMergeableDataAndConflictFields( // If target transaction is a card or split expense, always preserve the target transaction's amount and currency // Card takes precedence over split expense // See https://github.com/Expensify/App/issues/68189#issuecomment-3167156907 - const isTargetExpenseSplit = isExpenseSplit(targetTransaction, originalTargetTransaction); + const isTargetExpenseSplit = isExpenseSplit(targetTransaction); if (isManagedCardTransaction(targetTransaction) || isTargetExpenseSplit) { mergeableData[field] = targetValue; mergeableData.currency = getCurrency(targetTransaction); @@ -241,7 +230,7 @@ function getMergeableDataAndConflictFields( // We allow user to select unreported report if (field === 'reportID') { if (targetValue === sourceValue) { - const updatedValues = getMergeFieldUpdatedValues(targetTransaction, field, SafeString(targetValue)); + const updatedValues = getMergeFieldUpdatedValues(targetTransaction, field, SafeString(targetValue), searchReports); Object.assign(mergeableData, updatedValues); } else { conflictFields.push(field); @@ -271,7 +260,7 @@ function getMergeableDataAndConflictFields( if (isTargetValueEmpty || isSourceValueEmpty || targetValue === sourceValue) { const selectedTransaction = isTargetValueEmpty ? sourceTransaction : targetTransaction; const selectedFieldValue = isTargetValueEmpty ? sourceValue : targetValue; - const updatedValues = getMergeFieldUpdatedValues(selectedTransaction, field, selectedFieldValue as MergeTransaction[typeof field]); + const updatedValues = getMergeFieldUpdatedValues(selectedTransaction, field, selectedFieldValue as MergeTransaction[typeof field], searchReports); Object.assign(mergeableData, updatedValues); } else { conflictFields.push(field); @@ -346,6 +335,59 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m }; } +/** + * Determines whether two transactions can be merged together. + */ +function areTransactionsEligibleForMerge(transaction1: OnyxEntry, transaction2: OnyxEntry) { + // Both transactions are required + if (!transaction1 || !transaction2) { + return false; + } + + // Do not allow merging transactions that are pending delete + if (isTransactionPendingDelete(transaction1) || isTransactionPendingDelete(transaction2)) { + return false; + } + + if (isScanning(transaction1) || isScanning(transaction2)) { + return false; + } + + if (isFetchingWaypointsFromServer(transaction1) || isFetchingWaypointsFromServer(transaction2)) { + return false; + } + + // Do not allow merging two card transactions + if (isManagedCardTransaction(transaction1) && isManagedCardTransaction(transaction2)) { + return false; + } + + // Do not allow merging two split expenses + if (isExpenseSplit(transaction1) && isExpenseSplit(transaction2)) { + return false; + } + + // Do not allow merging two $0 transactions + if (getAmount(transaction1, false, false) === 0 && getAmount(transaction2, false, false) === 0) { + return false; + } + + // Do not allow merging a per diem and a card transaction + if ((isPerDiemRequest(transaction1) && isManagedCardTransaction(transaction2)) || (isPerDiemRequest(transaction2) && isManagedCardTransaction(transaction1))) { + return false; + } + + if (isIOUReport(transaction1?.reportID) || isIOUReport(transaction2?.reportID)) { + return false; + } + + if (isDistanceRequest(transaction1) !== isDistanceRequest(transaction2)) { + return false; + } + + return true; +} + /** * Determines the correct target and source transactions for merging based on transaction types. * @@ -357,13 +399,12 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m * * @param targetTransaction - The transaction where the merge action is started from * @param sourceTransaction - The selected transaction to be merged with the target transaction - * @param originalSourceTransaction - The original transaction of the source transaction * @returns An object containing the determined targetTransaction and sourceTransaction */ -function selectTargetAndSourceTransactionsForMerge(targetTransaction: OnyxEntry, sourceTransaction: OnyxEntry, originalSourceTransaction?: OnyxEntry) { +function selectTargetAndSourceTransactionsForMerge(targetTransaction: OnyxEntry, sourceTransaction: OnyxEntry) { // If target transaction is a card or split expense, always preserve the target transaction // Card takes precedence over split expense - if (isManagedCardTransaction(sourceTransaction) || (isExpenseSplit(sourceTransaction, originalSourceTransaction) && !isManagedCardTransaction(targetTransaction))) { + if (isManagedCardTransaction(sourceTransaction) || (isExpenseSplit(sourceTransaction) && !isManagedCardTransaction(targetTransaction))) { return {targetTransaction: sourceTransaction, sourceTransaction: targetTransaction}; } @@ -377,7 +418,7 @@ function selectTargetAndSourceTransactionsForMerge(targetTransaction: OnyxEntry< * @param translate - The translation function * @returns The formatted display string for the field value */ -function getDisplayValue(field: MergeFieldKey, transaction: Transaction, translate: LocaleContextProps['translate']): string { +function getDisplayValue(field: MergeFieldKey, transaction: Transaction, translate: LocaleContextProps['translate'], reports?: Array>): string { const fieldValue = getMergeFieldValue(getTransactionDetails(transaction), transaction, field); if (isEmptyMergeValue(fieldValue) || fieldValue === undefined) { @@ -400,7 +441,11 @@ function getDisplayValue(field: MergeFieldKey, transaction: Transaction, transla return translate('common.none'); } - return transaction?.reportName ?? getReportName(getReportOrDraftReport(SafeString(fieldValue))); + if (transaction?.reportName === '') { + return translate('common.none'); + } + + return transaction?.reportName ?? getReportName(getReportOrDraftReport(SafeString(fieldValue), reports)); } if (field === 'attendees') { return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue) : ''; @@ -423,6 +468,7 @@ function buildMergeFieldsData( sourceTransaction: Transaction | undefined, mergeTransaction: MergeTransaction | null | undefined, translate: LocaleContextProps['translate'], + reports: Array> = [], ): MergeFieldData[] { if (!targetTransaction || !sourceTransaction) { return []; @@ -436,12 +482,12 @@ function buildMergeFieldsData( const options: MergeFieldOption[] = [ { transaction: targetTransaction, - displayValue: getDisplayValue(field, targetTransaction, translate), + displayValue: getDisplayValue(field, targetTransaction, translate, reports), isSelected: selectedTransactionId === targetTransaction.transactionID, }, { transaction: sourceTransaction, - displayValue: getDisplayValue(field, sourceTransaction, translate), + displayValue: getDisplayValue(field, sourceTransaction, translate, reports), isSelected: selectedTransactionId === sourceTransaction.transactionID, }, ]; @@ -458,7 +504,12 @@ function buildMergeFieldsData( * Build updated values for merge transaction field selection * Handles special cases like currency for amount field, reportID and additional fields for distance requests */ -function getMergeFieldUpdatedValues(transaction: OnyxEntry, field: K, fieldValue: MergeTransaction[K]): MergeTransactionUpdateValues { +function getMergeFieldUpdatedValues( + transaction: OnyxEntry, + field: K, + fieldValue: MergeTransaction[K], + searchReports?: Array>, +): MergeTransactionUpdateValues { const updatedValues: MergeTransactionUpdateValues = { [field]: fieldValue, }; @@ -468,7 +519,8 @@ function getMergeFieldUpdatedValues(transaction: OnyxEn } if (field === 'reportID') { - updatedValues.reportName = transaction?.reportName ?? getReportName(getReportOrDraftReport(getReportIDForExpense(transaction))); + const reportName = transaction?.reportName?.length ? transaction?.reportName : getReportName(getReportOrDraftReport(getReportIDForExpense(transaction), searchReports)); + updatedValues.reportName = reportName.length ? reportName : null; } if (field === 'merchant' && isDistanceRequest(transaction)) { @@ -495,8 +547,7 @@ function getRateFromMerchant(merchant: string | undefined): string { } export { - getSourceTransactionFromMergeTransaction, - getTargetTransactionFromMergeTransaction, + getTransactionFromMergeTransaction, shouldNavigateToReceiptReview, getMergeableDataAndConflictFields, getMergeFieldValue, @@ -510,6 +561,7 @@ export { buildMergeFieldsData, getReportIDForExpense, getMergeFieldErrorText, + areTransactionsEligibleForMerge, MERGE_FIELDS, getRateFromMerchant, getMergeFieldUpdatedValues, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 96fabcc262c30..28f1cf7350c7d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2137,21 +2137,25 @@ type MergeTransactionNavigatorParamList = { transactionID: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; + hash?: number; }; [SCREENS.MERGE_TRANSACTION.RECEIPT_PAGE]: { transactionID: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; + hash?: number; }; [SCREENS.MERGE_TRANSACTION.DETAILS_PAGE]: { transactionID: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; + hash?: number; }; [SCREENS.MERGE_TRANSACTION.CONFIRMATION_PAGE]: { transactionID: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; + hash?: number; }; }; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index c60206b759b27..e0621d944cbc4 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -5,6 +5,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ExportTemplate, Policy, Report, ReportAction, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; import {isApprover as isApproverUtils} from './actions/Policy/Member'; import {getCurrentUserAccountID, getCurrentUserEmail} from './actions/Report'; +import {areTransactionsEligibleForMerge} from './MergeTransactionUtils'; import {getLoginByAccountID} from './PersonalDetailsUtils'; import { arePaymentsEnabled as arePaymentsEnabledUtils, @@ -551,13 +552,12 @@ function isReopenAction(report: Report, policy?: Policy): boolean { * Checks whether the supplied report supports merging transactions from it. */ function isMergeAction(parentReport: Report, reportTransactions: Transaction[], policy?: Policy): boolean { - // Do not show merge action if there are multiple transactions - if (reportTransactions.length !== 1) { + // Do not show merge action if there are more than 2 transactions + if (reportTransactions.length > 2) { return false; } - // Temporary disable merge action for IOU reports - // See: https://github.com/Expensify/App/issues/70329#issuecomment-3277062003 + // Merging IOUs is currently not planned if (isIOUReportUtils(parentReport)) { return false; } @@ -590,6 +590,28 @@ function isMergeAction(parentReport: Report, reportTransactions: Transaction[], return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin); } +function isMergeActionForSelectedTransactions(transactions: Transaction[], reports: Report[], policies: Policy[]) { + if ([transactions, reports, policies].some((collection) => collection?.length > 2)) { + return false; + } + + // All reports must be in an editable state by the current user to allow merging + const allReportsEligible = reports.every((report) => { + // Transaction could be unreported + if (!report) { + return true; + } + const policy = policies.find((p) => p?.id === report?.policyID); + if (hasOnlyNonReimbursableTransactions(report.reportID) && isSubmitAndClose(policy) && isInstantSubmitEnabled(policy)) { + return false; + } + + return isMoneyRequestReportEligibleForMerge(report, policy?.role === CONST.POLICY.ROLE.ADMIN); + }); + + return allReportsEligible && (transactions.length === 1 || areTransactionsEligibleForMerge(transactions.at(0), transactions.at(1))); +} + function isRemoveHoldAction( report: Report, chatReport: OnyxEntry, @@ -770,7 +792,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.SPLIT); } - if (isMergeAction(report, reportTransactions, policy)) { + if (reportTransactions?.length === 1 && isMergeAction(report, reportTransactions, policy)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.MERGE); } @@ -866,4 +888,4 @@ function getSecondaryTransactionThreadActions( return options; } -export {getSecondaryReportActions, getSecondaryTransactionThreadActions, isMergeAction, getSecondaryExportReportActions, isSplitAction}; +export {getSecondaryReportActions, getSecondaryTransactionThreadActions, isMergeAction, isMergeActionForSelectedTransactions, getSecondaryExportReportActions, isSplitAction}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7f73f2ee0cd34..d4f063c51a596 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1237,8 +1237,8 @@ function getChatType(report: OnyxInputOrEntry | Participant): ValueOf): OnyxEntry { - const searchReport = searchReports?.find((report) => report.reportID === reportID); +function getReportOrDraftReport(reportID: string | undefined, searchReports?: Array>, fallbackReport?: Report, reportDrafts?: OnyxCollection): OnyxEntry { + const searchReport = searchReports?.find((report) => report?.reportID === reportID); const onyxReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; return searchReport ?? onyxReport ?? (reportDrafts ?? allReportsDraft)?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`] ?? fallbackReport; } @@ -2795,13 +2795,13 @@ function canDeleteTransaction(moneyRequestReport: OnyxEntry, isReportArc * - **Submitters**: IOUs, unreported expenses, and expenses on Open or Processing reports at the first level of approval * - **Managers**: Expenses on Open or Processing reports * - * @param reportID - The ID of the money request report to check for merge eligibility + * @param reportOrReportID - The ID of the money request report to check for merge eligibility * @param isAdmin - Whether the current user is an admin of the policy associated with the target report * * @returns True if the report is eligible for merging transactions, false otherwise */ -function isMoneyRequestReportEligibleForMerge(reportID: string, isAdmin: boolean): boolean { - const report = getReportOrDraftReport(reportID); +function isMoneyRequestReportEligibleForMerge(reportOrReportID: Report | string, isAdmin: boolean): boolean { + const report = typeof reportOrReportID === 'string' ? getReportOrDraftReport(reportOrReportID) : reportOrReportID; if (!isMoneyRequestReport(report)) { return false; @@ -4589,7 +4589,7 @@ function getTransactionCommentObject(transaction: OnyxEntry): Comme * - the current user is the manager of the report * - or the current user is an admin on the policy the expense report is tied to * - * This is used in conjunction with canEditRestrictedField to control editing of specific fields like amount, currency, created, receipt, and distance. + * This is used in conjunction with canEditMoneyRequest to control editing of specific fields like amount, currency, created, receipt, and distance. * On its own, it only controls allowing/disallowing navigating to the editing pages or showing/hiding the 'Edit' icon on report actions */ function canEditMoneyRequest( @@ -5093,17 +5093,17 @@ function getTransactionReportName({ transactions?: Transaction[]; reports?: Report[]; }): string { - if (isReversedTransaction(reportAction)) { + if (reportAction && isReversedTransaction(reportAction)) { // eslint-disable-next-line @typescript-eslint/no-deprecated return translateLocal('parentReportAction.reversedTransaction'); } - if (isDeletedAction(reportAction)) { + if (reportAction && isDeletedAction(reportAction)) { // eslint-disable-next-line @typescript-eslint/no-deprecated return translateLocal('parentReportAction.deletedExpense'); } - const transaction = getLinkedTransaction(reportAction, transactions); + const transaction = reportAction ? getLinkedTransaction(reportAction, transactions) : transactions?.at(0); if (isEmptyObject(transaction)) { // Transaction data might be empty on app's first load, if so we fallback to Expense/Track Expense @@ -5316,6 +5316,7 @@ function getReportPreviewMessage( return translateLocal(translatePhraseKey, payerDisplayName ?? ''); } if (translatePhraseKey === 'iou.payerPaidAmount') { + // eslint-disable-next-line @typescript-eslint/no-deprecated return translateLocal(translatePhraseKey, '', payerDisplayName ?? ''); } } @@ -7062,6 +7063,8 @@ function getMovedTransactionMessage(action: ReportAction) { const fromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; const report = fromReport ?? toReport; + + // This will be fixed as follow up https://github.com/Expensify/App/pull/75357 // eslint-disable-next-line @typescript-eslint/no-deprecated const reportName = getReportName(report) ?? report?.reportName ?? ''; let reportUrl = getReportURLForCurrentContext(report?.reportID); @@ -7092,6 +7095,7 @@ function getUnreportedTransactionMessage(action: ReportAction) { const fromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; + // This will be fixed as follow up https://github.com/Expensify/App/pull/75357 // eslint-disable-next-line @typescript-eslint/no-deprecated const reportName = getReportName(fromReport) ?? fromReport?.reportName ?? ''; @@ -10521,6 +10525,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry, if (automaticAction) { if (originalMessage.paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + // eslint-disable-next-line @typescript-eslint/no-deprecated return translateLocal('iou.automaticallyPaidWithExpensify', ''); } // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -10528,6 +10533,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry, } break; default: + // eslint-disable-next-line @typescript-eslint/no-deprecated return translateLocal('iou.payerPaidAmount', '', ''); } if (translationKey === 'iou.businessBankAccount') { @@ -11030,7 +11036,7 @@ function canBeAutoReimbursed(report: OnyxInputOrEntry, policy: OnyxInput /** Check if the current user is an owner of the report */ function isReportOwner(report: OnyxInputOrEntry): boolean { - return report?.ownerAccountID === currentUserPersonalDetails?.accountID; + return report?.ownerAccountID === currentUserAccountID; } function isAllowedToApproveExpenseReport(report: OnyxEntry, approverAccountID?: number, reportPolicy?: OnyxEntry): boolean { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 57fffc9200b72..558e7a05facc5 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -2227,7 +2227,11 @@ function getAllSortedTransactions(iouReportID?: string): Array, originalTransaction: OnyxEntry): boolean { +function isExpenseSplit(transaction: OnyxEntry, originalTransaction?: OnyxEntry): boolean { + if (!originalTransaction) { + return !!transaction?.comment?.originalTransactionID && transaction?.comment?.source === 'split'; + } + const {originalTransactionID, source, splits} = transaction?.comment ?? {}; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 71e16c79a60c6..c1b5d2c6a6280 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -1,33 +1,28 @@ import {deepEqual} from 'fast-equals'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry, OnyxMergeInput, OnyxUpdate} from 'react-native-onyx'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import * as API from '@libs/API'; import type {GetTransactionsForMergingParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {getMergeFieldValue, getTransactionThreadReportID, MERGE_FIELDS} from '@libs/MergeTransactionUtils'; +import { + areTransactionsEligibleForMerge, + getMergeableDataAndConflictFields, + getMergeFieldValue, + getTransactionThreadReportID, + MERGE_FIELDS, + selectTargetAndSourceTransactionsForMerge, + shouldNavigateToReceiptReview, +} from '@libs/MergeTransactionUtils'; import type {MergeFieldKey, MergeTransactionUpdateValues} from '@libs/MergeTransactionUtils'; +import Navigation from '@libs/Navigation/Navigation'; import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; -import { - getReportOrDraftReport, - getReportTransactions, - getTransactionDetails, - isCurrentUserSubmitter, - isIOUReport, - isMoneyRequestReportEligibleForMerge, - isReportManager, -} from '@libs/ReportUtils'; +import {getReportOrDraftReport, getReportTransactions, getTransactionDetails, isCurrentUserSubmitter, isMoneyRequestReportEligibleForMerge, isReportManager} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import { - getAmount, - getTransactionViolationsOfTransaction, - isDistanceRequest, - isExpenseSplit, - isManagedCardTransaction, - isPerDiemRequest, - isTransactionPendingDelete, -} from '@src/libs/TransactionUtils'; +import {getTransactionViolationsOfTransaction, isDistanceRequest, isTransactionPendingDelete} from '@src/libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {MergeTransaction, Policy, PolicyCategories, PolicyTagLists, Report, Transaction} from '@src/types/onyx'; import {getUpdateMoneyRequestParams, getUpdateTrackExpenseParams} from './IOU'; import type {UpdateMoneyRequestData} from './IOU'; @@ -46,71 +41,82 @@ function setMergeTransactionKey(transactionID: string, values: MergeTransactionU Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values as OnyxMergeInput<`${typeof ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${string}`>); } -/** - * Fetches eligible transactions for merging - */ -function getTransactionsForMergingFromAPI(transactionID: string) { - const parameters: GetTransactionsForMergingParams = { - transactionID, - }; - - API.read(READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING, parameters); -} - -function areTransactionsEligibleForMerge(transaction1: Transaction, transaction2: Transaction, originalTransaction1?: Transaction, originalTransaction2?: Transaction) { - // Do not allow merging two card transactions - if (isManagedCardTransaction(transaction1) && isManagedCardTransaction(transaction2)) { - return false; +function setupMergeTransactionDataAndNavigate(transactions: Transaction[], localeCompare: LocaleContextProps['localeCompare'], searchReports?: Report[]) { + if (!transactions.length || transactions.length > 2) { + return; } - // Do not allow merging two split expenses - if (isExpenseSplit(transaction1, originalTransaction1) && isExpenseSplit(transaction2, originalTransaction2)) { - return false; + // Target & source transactionID might switch, we should keep the Onyx key consistent + // otherwise we might end up creating a new object entry in Onyx with a different transactionID + const onyxMergeTransactionID = transactions.at(0)?.transactionID; + if (!onyxMergeTransactionID) { + return; } - // Do not allow merging two $0 transactions - if (getAmount(transaction1, false, false) === 0 && getAmount(transaction2, false, false) === 0) { - return false; + if (transactions.length === 1) { + const transaction = transactions.at(0); + if (transaction) { + setupMergeTransactionData(onyxMergeTransactionID, {targetTransactionID: transaction.transactionID}); + Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(transaction.transactionID, Navigation.getActiveRoute())); + return; + } } - // Do not allow merging a per diem and a card transaction - if ((isPerDiemRequest(transaction1) && isManagedCardTransaction(transaction2)) || (isPerDiemRequest(transaction2) && isManagedCardTransaction(transaction1))) { - return false; + const {targetTransaction, sourceTransaction} = selectTargetAndSourceTransactionsForMerge(transactions.at(0), transactions.at(1)); + if (!targetTransaction || !sourceTransaction) { + return; } - // Temporary exclude IOU reports from eligible list - // See: https://github.com/Expensify/App/issues/70329#issuecomment-3277062003 - if (isIOUReport(transaction1.reportID) || isIOUReport(transaction2.reportID)) { - return false; - } + setupMergeTransactionData(onyxMergeTransactionID, {targetTransactionID: targetTransaction?.transactionID, sourceTransactionID: sourceTransaction?.transactionID}); + if (shouldNavigateToReceiptReview([targetTransaction, sourceTransaction])) { + // Navigate to the receipt review page if both transactions have a receipt + Navigation.navigate(ROUTES.MERGE_TRANSACTION_RECEIPT_PAGE.getRoute(targetTransaction.transactionID, Navigation.getActiveRoute())); + } else { + // If transactions are identical, skip to the confirmation page + const {conflictFields, mergeableData} = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction, localeCompare, searchReports); + if (!conflictFields.length) { + // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page + setMergeTransactionKey(onyxMergeTransactionID, mergeableData); + Navigation.navigate(ROUTES.MERGE_TRANSACTION_CONFIRMATION_PAGE.getRoute(targetTransaction.transactionID, Navigation.getActiveRoute())); + return; + } - if (isDistanceRequest(transaction1) !== isDistanceRequest(transaction2)) { - return false; + const receipt = targetTransaction.receipt?.receiptID ? targetTransaction.receipt : sourceTransaction.receipt; + if (receipt) { + setMergeTransactionKey(onyxMergeTransactionID, {receipt}); + } + Navigation.navigate(ROUTES.MERGE_TRANSACTION_DETAILS_PAGE.getRoute(targetTransaction.transactionID, Navigation.getActiveRoute())); } +} + +/** + * Fetches eligible transactions for merging + */ +function getTransactionsForMergingFromAPI(transactionID: string) { + const parameters: GetTransactionsForMergingParams = { + transactionID, + }; - return true; + API.read(READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING, parameters); } /** * Fetches eligible transactions for merging locally * This is FE version of READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call */ -function getTransactionsForMergingLocally(transactionID: string, targetTransaction: Transaction, transactions: OnyxCollection) { +function getTransactionsForMergingLocally(transactionID: string, targetTransaction: Transaction, transactions: OnyxCollection, isAdmin = false) { const transactionsArray = Object.values(transactions ?? {}); - const targetOriginalTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction?.comment?.originalTransactionID}`]; const eligibleTransactions = transactionsArray.filter((transaction): transaction is Transaction => { if (!transaction || transaction.transactionID === targetTransaction.transactionID) { return false; } - const originalTransactionID = transaction.comment?.originalTransactionID; - const originalTransaction = originalTransactionID ? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`] : undefined; const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; return ( - areTransactionsEligibleForMerge(targetTransaction, transaction, targetOriginalTransaction, originalTransaction) && + areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction) && - (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, false))) + (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, isAdmin))) ); }); @@ -147,15 +153,12 @@ function getTransactionsForMerging({ if (isPaidGroupPolicy(policy) && (isAdmin || isManager) && !isCurrentUserSubmitter(report)) { const reportTransactions = getReportTransactions(report?.reportID); - const targetOriginalTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction?.comment?.originalTransactionID}`]; const eligibleTransactions = reportTransactions.filter((transaction): transaction is Transaction => { if (!transaction || transaction.transactionID === transactionID) { return false; } - const originalTransactionID = transaction.comment?.originalTransactionID; - const originalTransaction = originalTransactionID ? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`] : undefined; - return areTransactionsEligibleForMerge(targetTransaction, transaction, targetOriginalTransaction, originalTransaction); + return areTransactionsEligibleForMerge(targetTransaction, transaction); }); Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, { @@ -165,7 +168,7 @@ function getTransactionsForMerging({ } if (isOffline) { - getTransactionsForMergingLocally(transactionID, targetTransaction, transactions); + getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, isAdmin); } else { getTransactionsForMergingFromAPI(transactionID); } @@ -448,4 +451,4 @@ function mergeTransactionRequest({ API.write(WRITE_COMMANDS.MERGE_TRANSACTION, params, {optimisticData, failureData, successData}); } -export {areTransactionsEligibleForMerge, setupMergeTransactionData, setMergeTransactionKey, getTransactionsForMerging, mergeTransactionRequest}; +export {areTransactionsEligibleForMerge, setupMergeTransactionData, setupMergeTransactionDataAndNavigate, setMergeTransactionKey, getTransactionsForMerging, mergeTransactionRequest}; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index cf92aba356972..e55de31bf129d 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -34,6 +34,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {confirmReadyToOpenApp} from '@libs/actions/App'; +import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, @@ -63,6 +64,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {getActiveAdminWorkspaces, getAllTaxRates, hasDynamicExternalWorkflow, hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil, isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; import { generateReportID, getPolicyExpenseChat, @@ -154,6 +156,7 @@ function SearchPage({route}: SearchPageProps) { 'ThumbsUp', 'ThumbsDown', 'ArrowRight', + 'ArrowCollapse', 'Stopwatch', 'Exclamation', 'SmartScan', @@ -352,6 +355,7 @@ function SearchPage({route}: SearchPageProps) { ) as PaymentData[]; payMoneyRequestOnSearch(hash, paymentData); + // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { clearSelectedTransactions(); @@ -360,6 +364,14 @@ function SearchPage({route}: SearchPageProps) { [clearSelectedTransactions, hash, isOffline, lastPaymentMethods, selectedReports, selectedTransactions, policies, formatPhoneNumber, policyIDsWithVBBA], ); + const [isSorting, setIsSorting] = useState(false); + let searchResults: SearchResults | undefined; + if (currentSearchResults?.data) { + searchResults = currentSearchResults; + } else if (isSorting) { + searchResults = lastNonEmptySearchResults.current; + } + // Check if all selected transactions are from the submitter const areAllTransactionsFromSubmitter = useMemo(() => { if (!currentUserPersonalDetails?.accountID) { @@ -651,6 +663,26 @@ function SearchPage({route}: SearchPageProps) { }); } + if (selectedTransactionsKeys.length < 3 && searchResults?.search.type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && searchResults?.data) { + const transaction1 = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${selectedTransactionsKeys.at(0)}`]; + const transaction2 = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${selectedTransactionsKeys.at(1)}`]; + const reports = [searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction1?.reportID}`], searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction2?.reportID}`]]; + const transactionPolicies = [ + searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${transaction1?.policyID}`], + searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${transaction2?.policyID}`], + ]; + const transactions = selectedTransactionsKeys.length === 1 ? [transaction1] : [transaction1, transaction2]; + + if (isMergeActionForSelectedTransactions(transactions, reports, transactionPolicies)) { + options.push({ + text: translate('common.merge'), + icon: expensifyIcons.ArrowCollapse, + value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE, + onSelected: () => setupMergeTransactionDataAndNavigate(transactions, localeCompare, reports), + }); + } + } + const ownerAccountIDs = new Set(); let hasUnknownOwner = false; for (const id of selectedTransactionsKeys) { @@ -745,11 +777,14 @@ function SearchPage({route}: SearchPageProps) { return options; }, [ + searchResults?.data, + searchResults?.search?.type, selectedTransactionsKeys, status, hash, selectedTransactions, translate, + localeCompare, areAllMatchingItemsSelected, isOffline, selectedReports, @@ -764,7 +799,6 @@ function SearchPage({route}: SearchPageProps) { csvExportLayouts, clearSelectedTransactions, beginExportWithTemplate, - dismissedRejectUseExplanation, bulkPayButtonOptions, onBulkPaySelected, allReports, @@ -772,8 +806,21 @@ function SearchPage({route}: SearchPageProps) { styles.colorMuted, styles.fontWeightNormal, styles.textWrap, - expensifyIcons, + expensifyIcons.ArrowCollapse, + expensifyIcons.ArrowRight, + expensifyIcons.ArrowSplit, + expensifyIcons.DocumentMerge, + expensifyIcons.Exclamation, + expensifyIcons.Export, + expensifyIcons.MoneyBag, + expensifyIcons.Send, + expensifyIcons.Stopwatch, + expensifyIcons.Table, + expensifyIcons.ThumbsDown, + expensifyIcons.ThumbsUp, + expensifyIcons.Trashcan, dismissedHoldUseExplanation, + dismissedRejectUseExplanation, areAllTransactionsFromSubmitter, ]); @@ -886,15 +933,6 @@ function SearchPage({route}: SearchPageProps) { const isPossibleToShowDownloadExportModal = !shouldUseNarrowLayout && isDownloadExportModalVisible && !!createExportAll && !!setIsDownloadExportModalVisible; const {resetVideoPlayerData} = usePlaybackContext(); - const [isSorting, setIsSorting] = useState(false); - - let searchResults; - if (currentSearchResults?.data) { - searchResults = currentSearchResults; - } else if (isSorting) { - searchResults = lastNonEmptySearchResults.current; - } - const metadata = searchResults?.search; const shouldShowFooter = !!metadata?.count || selectedTransactionsKeys.length > 0; diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx index 9c5ee8f2f5c03..6964b89b29824 100644 --- a/src/pages/TransactionDuplicate/Confirmation.tsx +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -143,6 +143,7 @@ function Confirmation() { ; @@ -39,27 +38,9 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {canBeMissing: true}); - const [targetTransaction = getTargetTransactionFromMergeTransaction(mergeTransaction)] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.targetTransactionID}`, { - canBeMissing: true, - }); - const [sourceTransaction = getSourceTransactionFromMergeTransaction(mergeTransaction)] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.sourceTransactionID}`, { - canBeMissing: true, - }); - const targetTransactionThreadReportIDSelector = useCallback( - (reportActionsList: OnyxEntry) => { - const reportActions = Object.values(reportActionsList ?? {}); - const transactionIOUReportAction = mergeTransaction?.targetTransactionID ? getIOUActionForTransactionID(reportActions, mergeTransaction.targetTransactionID) : undefined; - return transactionIOUReportAction?.childReportID; - }, - [mergeTransaction?.targetTransactionID], - ); - const targetTransactionParentReportID = getReportIDForExpense(targetTransaction); - const [targetTransactionThreadReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetTransactionParentReportID}`, { - canBeMissing: true, - selector: targetTransactionThreadReportIDSelector, - }); - const targetTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`]; - const policyID = targetTransactionThreadReport?.policyID; + const {targetTransaction, sourceTransaction, targetTransactionReport} = useMergeTransactions({mergeTransaction}); + + const policyID = targetTransactionReport?.policyID; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); @@ -70,23 +51,9 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); // Build the merged transaction data for display - const mergedTransactionData = useMemo(() => buildMergedTransactionData(targetTransaction, mergeTransaction), [targetTransaction, mergeTransaction]); + const mergedTransactionData = buildMergedTransactionData(targetTransaction, mergeTransaction); - const contextValue = useMemo( - () => ({ - transactionThreadReport: targetTransactionThreadReport, - action: undefined, - report: targetTransactionThreadReport, - checkIfContextMenuActive: () => {}, - onShowContextMenu: () => {}, - isReportArchived: false, - anchor: null, - isDisabled: false, - }), - [targetTransactionThreadReport], - ); - - const handleMergeExpenses = useCallback(() => { + const handleMergeExpenses = () => { if (!targetTransaction || !mergeTransaction || !sourceTransaction) { return; } @@ -106,27 +73,15 @@ function ConfirmationPage({route}: ConfirmationPageProps) { isASAPSubmitBetaEnabled, }); - const reportIDToDismiss = reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? reportID : targetTransactionThreadReportID; + const reportIDToDismiss = reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? reportID : undefined; if (reportID !== targetTransaction.reportID && reportIDToDismiss) { Navigation.dismissModalWithReport({reportID: reportIDToDismiss}); } else { Navigation.dismissModal(); } - }, [ - targetTransaction, - mergeTransaction, - sourceTransaction, - transactionID, - targetTransactionThreadReportID, - policy, - policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - ]); + }; - if (isLoadingOnyxValue(mergeTransactionMetadata) || !targetTransactionThreadReport?.reportID) { + if (isLoadingOnyxValue(mergeTransactionMetadata)) { return ; } @@ -147,17 +102,15 @@ function ConfirmationPage({route}: ConfirmationPageProps) { {translate('transactionMerge.confirmationPage.pageTitle')} - - } - mergeTransactionID={transactionID} - /> - + } + mergeTransactionID={transactionID} + />