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 f5b654206dced..95072e08e2cb1 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6697,6 +6697,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 318727dbb1cb8..bb3f65c6452c0 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2767,16 +2767,14 @@ const ROUTES = { TRANSACTION_RECEIPT: { route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?', - getRoute: (reportID: string | undefined, transactionID: string | undefined, readonly = false, isFromReviewDuplicates = false, mergeTransactionID?: string) => { + getRoute: (reportID: string | undefined, transactionID: string | undefined, readonly = false, mergeTransactionID?: string) => { if (!reportID) { Log.warn('Invalid reportID is used to build the TRANSACTION_RECEIPT route'); } if (!transactionID) { Log.warn('Invalid transactionID is used to build the TRANSACTION_RECEIPT route'); } - return `r/${reportID}/transaction/${transactionID}/receipt?readonly=${readonly}${ - isFromReviewDuplicates ? '&isFromReviewDuplicates=true' : '' - }${mergeTransactionID ? `&mergeTransactionID=${mergeTransactionID}` : ''}` as const; + return `r/${reportID}/transaction/${transactionID}/receipt?readonly=${readonly}${mergeTransactionID ? `&mergeTransactionID=${mergeTransactionID}` : ''}` as const; }, }, @@ -2835,28 +2833,40 @@ const ROUTES = { getRoute: (threadReportID: string, backTo?: string) => getUrlWithBackToParam(`r/${threadReportID}/duplicates/confirm` as const, backTo), }, MERGE_TRANSACTION_LIST_PAGE: { - route: 'r/:transactionID/merge', + route: 'merge/:transactionID', - // 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, isOnSearch = false) => { + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + const url = getUrlWithBackToParam(`merge/${transactionID}` as const, backTo); + return isOnSearch ? (`${url}&isOnSearch=true` as const) : url; + }, }, MERGE_TRANSACTION_RECEIPT_PAGE: { - route: 'r/:transactionID/merge/receipt', + route: 'merge/:transactionID/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, isOnSearch = false) => { + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + const url = getUrlWithBackToParam(`merge/${transactionID}/receipt` as const, backTo); + return isOnSearch ? (`${url}&isOnSearch=true` as const) : url; + }, }, MERGE_TRANSACTION_DETAILS_PAGE: { - route: 'r/:transactionID/merge/details', + route: 'merge/:transactionID/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, isOnSearch = false) => { + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + const url = getUrlWithBackToParam(`merge/${transactionID}/details` as const, backTo); + return isOnSearch ? (`${url}&isOnSearch=true` as const) : url; + }, }, MERGE_TRANSACTION_CONFIRMATION_PAGE: { - route: 'r/:transactionID/merge/confirmation', + route: 'merge/:transactionID/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, isOnSearch = false) => { + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + const url = getUrlWithBackToParam(`merge/${transactionID}/confirmation` as const, backTo); + return isOnSearch ? (`${url}&isOnSearch=true` as const) : url; + }, }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'workspaces/:policyID/accounting/xero/import', diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 91d910a8b2430..d88e6590af394 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), @@ -424,6 +424,7 @@ function MoneyReportHeader({ ); const [offlineModalVisible, setOfflineModalVisible] = useState(false); + const isOnSearch = route.name.toLowerCase().startsWith('search'); const {options: originalSelectedTransactionsOptions, handleDeleteTransactions} = useSelectedTransactionsActions({ report: moneyRequestReport, reportActions, @@ -433,6 +434,7 @@ function MoneyReportHeader({ onExportOffline: () => setOfflineModalVisible(true), policy, beginExportWithTemplate: (templateName, templateType, transactionIDList, policyID) => beginExportWithTemplate(templateName, templateType, transactionIDList, policyID), + isOnSearch, }); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); @@ -1254,8 +1256,7 @@ function MoneyReportHeader({ return; } - setupMergeTransactionData(currentTransaction.transactionID, {targetTransactionID: currentTransaction.transactionID}); - Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(currentTransaction.transactionID, Navigation.getActiveRoute())); + setupMergeTransactionDataAndNavigate(currentTransaction.transactionID, [currentTransaction], localeCompare); }, }, [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE]: { diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 97a9ea7885a66..aa54901e0c9b1 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(); @@ -400,8 +400,8 @@ 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())); + const isOnSearch = route.name.toLowerCase().startsWith('search'); + setupMergeTransactionDataAndNavigate(transaction.transactionID, [transaction], localeCompare, [], false, isOnSearch); }, }, [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE]: { diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index ef50ba5332714..f8d4347c71ac9 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, @@ -59,9 +59,6 @@ type MoneyRequestReceiptViewProps = { /** Whether we should show Money Request with disabled all fields */ readonly?: boolean; - /** whether or not this report is from review duplicates */ - isFromReviewDuplicates?: boolean; - /** Updated transaction to show in duplicate & merge transaction flow */ updatedTransaction?: OnyxEntry; @@ -91,7 +88,6 @@ function MoneyRequestReceiptView({ report, readonly = false, updatedTransaction, - isFromReviewDuplicates = false, fillSpace = false, mergeTransactionID, isDisplayedInWideRHP = false, @@ -112,7 +108,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 +159,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 +209,7 @@ function MoneyRequestReceiptView({ [transaction?.errors, parentReportAction?.errors], ); - const dismissReceiptError = useCallback(() => { + const dismissReceiptError = () => { if (!report?.reportID) { return; } @@ -248,19 +244,7 @@ function MoneyRequestReceiptView({ } navigateToConciergeChatAndDeleteReport(report.reportID, true, true); } - }, [ - transaction, - chatReport, - parentReportAction, - linkedTransactionID, - report?.reportID, - iouReport, - chatIOUReport, - isChatIOUReportArchived, - errorsWithoutReportCreation, - reportCreationError, - isInNarrowPaneModal, - ]); + }; let receiptStyle: StyleProp; @@ -352,7 +336,6 @@ function MoneyRequestReceiptView({ transaction={updatedTransaction ?? transaction} enablePreviewModal readonly={readonly || !canEditReceipt} - isFromReviewDuplicates={isFromReviewDuplicates} mergeTransactionID={mergeTransactionID} report={report} onLoad={() => setIsLoading(false)} diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index b5563498bbe38..b8f80e1053c8e 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -9,6 +9,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import {useSearchContext} from '@components/Search/SearchContext'; import Switch from '@components/Switch'; import Text from '@components/Text'; import ViolationMessages from '@components/ViolationMessages'; @@ -50,13 +51,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, @@ -64,7 +64,7 @@ import { isReportApproved, isReportInGroupPolicy, isSettled as isSettledReportUtils, - isTrackExpenseReport, + isTrackExpenseReportNew, shouldEnableNegative, } from '@libs/ReportUtils'; import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; @@ -109,7 +109,9 @@ type MoneyRequestViewProps = { allReports: OnyxCollection; /** The report currently being looked at */ - report: OnyxEntry; + transactionThreadReport?: OnyxEntry; + + parentReportID?: string; /** Policy that the report belongs to */ expensePolicy: OnyxEntry; @@ -143,7 +145,8 @@ const perDiemPoliciesSelector = (policies: OnyxCollection) => function MoneyRequestView({ allReports, - report, + transactionThreadReport, + parentReportID, expensePolicy, shouldShowAnimatedBackground, readonly = false, @@ -163,23 +166,32 @@ 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 searchContext = useSearchContext(); + const searchHash = searchContext?.currentSearchHash ?? CONST.DEFAULT_NUMBER_ID; + const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${searchHash}`, {canBeMissing: true}); + + // When this component is used when merging from the search page, we might not have the parent report stored in the main collection + let [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, {canBeMissing: true}); + parentReport = parentReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; + + 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 (updatedTransaction) { + transaction = updatedTransaction; + } + const isExpenseUnreported = isExpenseUnreportedTransactionUtils(transaction); const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); const [policiesWithPerDiem] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { @@ -201,13 +213,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}); @@ -224,7 +235,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) { @@ -254,13 +265,10 @@ 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); + const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction, !!mergeTransactionID); const isMapDistanceRequest = isDistanceRequest && !isManualDistanceRequest; const isTransactionScanning = isScanning(updatedTransaction ?? transaction); const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); @@ -275,9 +283,9 @@ function MoneyRequestView({ const formattedPerAttendeeAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount / (actualAttendees?.length ?? 1), actualCurrency) : ''; const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); - const isCardTransaction = isCardTransactionTransactionUtils(transaction); + const isManagedCardTransaction = isCardTransactionTransactionUtils(transaction); const cardProgramName = getCompanyCardDescription(transaction?.cardName, transaction?.cardID, cardList); - const shouldShowCard = isCardTransaction && cardProgramName; + const shouldShowCard = isManagedCardTransaction && cardProgramName; const taxRates = policy?.taxRates; const formattedTaxAmount = updatedTransaction?.taxAmount @@ -298,10 +306,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); @@ -328,8 +336,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; @@ -350,16 +358,16 @@ function MoneyRequestView({ const shouldShowReimbursable = (isPolicyExpenseChat || isExpenseUnreported) && (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && - !isCardTransaction && + !isManagedCardTransaction && !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, @@ -385,58 +393,47 @@ 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 (isManagedCardTransaction) { if (transactionPostedDate) { dateDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.posted')} ${transactionPostedDate}`; } @@ -473,84 +470,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; @@ -572,7 +538,7 @@ function MoneyRequestView({ shouldShowRightIcon={canEditDistance} titleStyle={styles.flex1} onPress={() => { - if (!transaction?.transactionID || !report?.reportID) { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } @@ -583,13 +549,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} @@ -604,7 +582,7 @@ function MoneyRequestView({ shouldShowRightIcon={canEditDistanceRate} titleStyle={styles.flex1} onPress={() => { - if (!transaction?.transactionID || !report?.reportID) { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } @@ -614,7 +592,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} @@ -641,9 +625,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 ?? ''); @@ -710,11 +692,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} @@ -728,10 +717,9 @@ 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; const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(updatedTransaction ?? transaction); // 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. @@ -739,7 +727,8 @@ function MoneyRequestView({ 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 ; } @@ -750,10 +739,9 @@ function MoneyRequestView({ {(wideRHPRouteKeys.length === 0 || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( )} @@ -783,7 +771,7 @@ function MoneyRequestView({ interactive={canEditAmount} shouldShowRightIcon={canEditAmount} onPress={() => { - if (!transaction?.transactionID || !report?.reportID) { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } @@ -793,7 +781,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} @@ -812,7 +808,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]} @@ -835,7 +837,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]} @@ -856,7 +864,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} @@ -875,13 +889,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, }), ); @@ -892,14 +906,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(), + ), ); } }} @@ -933,7 +953,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} @@ -953,7 +979,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} @@ -974,7 +1006,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} @@ -1035,7 +1067,7 @@ function MoneyRequestView({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - if (!canEditReport) { + if (!canEditReport || !transactionThreadReport) { return; } if (shouldNavigateToUpgradePath) { @@ -1044,7 +1076,7 @@ function MoneyRequestView({ iouType, action: CONST.IOU.ACTION.EDIT, transactionID: transaction?.transactionID, - reportID: report.reportID, + reportID: transactionThreadReport?.reportID, upgradePath: CONST.UPGRADE_PATHS.REPORTS, }), ); @@ -1055,7 +1087,7 @@ function MoneyRequestView({ CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, - report.reportID, + transactionThreadReport?.reportID, getReportRHPActiveRoute() || lastVisitedPath, ), ); @@ -1075,9 +1107,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..cb22afebb5921 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'; @@ -102,7 +103,6 @@ function ReportActionItemImage({ isSingleImage = true, readonly = false, shouldMapHaveBorderRadius, - isFromReviewDuplicates = false, mergeTransactionID, onPress, shouldUseFullHeight, @@ -185,10 +185,9 @@ 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, mergeTransactionID, ), ) diff --git a/src/hooks/useMergeTransactions.ts b/src/hooks/useMergeTransactions.ts new file mode 100644 index 0000000000000..4ed29fd4d7938 --- /dev/null +++ b/src/hooks/useMergeTransactions.ts @@ -0,0 +1,74 @@ +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); + + let [targetTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${targetTransaction?.reportID}`, { + canBeMissing: true, + }); + let [sourceTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`, { + canBeMissing: true, + }); + + // If we're on search and main collection reports are not available, get them from the search snapshot + if (searchHash && currentSearchResults?.data) { + targetTransactionReport = targetTransactionReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${targetTransaction?.reportID}`]; + sourceTransactionReport = sourceTransactionReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`]; + } + + return { + targetTransaction, + sourceTransaction, + targetTransactionReport, + sourceTransactionReport, + }; +} + +export default useMergeTransactions; diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 7ef4a8b8c5fda..a50965e1e33f8 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, @@ -51,6 +51,7 @@ function useSelectedTransactionsActions({ onExportOffline, policy, beginExportWithTemplate, + isOnSearch, }: { report?: Report; reportActions: ReportAction[]; @@ -60,6 +61,7 @@ function useSelectedTransactionsActions({ onExportOffline?: () => void; policy?: Policy; beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => void; + isOnSearch?: boolean; }) { const {isOffline} = useNetworkWithOfflineStatus(); const {selectedTransactionIDs, clearSelectedTransactions, currentSearchHash, selectedTransactions: selectedTransactionsMeta} = useSearchContext(); @@ -128,9 +130,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); @@ -295,6 +298,19 @@ function useSelectedTransactionsActions({ return canMoveExpense; }); + const canMergeTransaction = selectedTransactionsList.length < 3 && report && policy && isMergeActionForSelectedTransactions(selectedTransactionsList, [report], [policy]); + if (canMergeTransaction) { + const transactionID = selectedTransactionsList.at(0)?.transactionID; + if (transactionID) { + options.push({ + text: translate('common.merge'), + icon: expensifyIcons.ArrowCollapse, + value: MERGE, + onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, selectedTransactionsList, localeCompare, [], false, isOnSearch), + }); + } + } + const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); if (canSelectedExpensesBeMoved && canUserPerformWriteAction && !hasTransactionsFromMultipleOwners) { options.push({ @@ -326,26 +342,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); @@ -367,6 +363,7 @@ function useSelectedTransactionsActions({ } return options; }, [ + session?.email, selectedTransactionIDs, report, selectedTransactionsList, @@ -390,7 +387,17 @@ 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, + isOnSearch, ]); return { diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index f880c38291fc8..e99d3dc5e070e 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -964,6 +964,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) case 'cardNumber': case 'taxValue': case 'groupCurrency': + case 'transactionType': + case 'transactionThreadReportID': return validateString(value); case 'created': case 'modifiedCreated': @@ -1075,10 +1077,12 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) participants: CONST.RED_BRICK_ROAD_PENDING_ACTION, receipt: CONST.RED_BRICK_ROAD_PENDING_ACTION, reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + transactionThreadReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION, routes: CONST.RED_BRICK_ROAD_PENDING_ACTION, transactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, tag: CONST.RED_BRICK_ROAD_PENDING_ACTION, + transactionType: CONST.RED_BRICK_ROAD_PENDING_ACTION, isFromGlobalCreate: CONST.RED_BRICK_ROAD_PENDING_ACTION, taxRate: CONST.RED_BRICK_ROAD_PENDING_ACTION, parentTransactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index b07fa6adccae7..561f922c72fd1 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -4,17 +4,33 @@ 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 ONYXKEYS from '@src/ONYXKEYS'; +import type {MergeTransaction, Policy, Report, SearchResults, 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, + isFromCreditCardImport, + isMerchantMissing, + isPerDiemRequest, + isScanning, + isTransactionPendingDelete, +} from './TransactionUtils'; const RECEIPT_SOURCE_URL = 'https://www.expensify.com/receipts/'; @@ -86,32 +102,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 @@ -176,19 +166,41 @@ function getMergeFields(targetTransaction: OnyxEntry) { return MERGE_FIELDS.filter((field) => !excludeFields.includes(field)); } +function getTransactionsAndReportsFromSearch( + searchResults: SearchResults, + transactionIDs: string[], +): { + transactions: Transaction[]; + reports: Report[]; + policies: Policy[]; +} { + const transaction1 = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(0)}`]; + const transaction2 = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(1)}`]; + + return { + transactions: [transaction1, transaction2].filter((transaction) => !!transaction), + reports: [searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction1?.reportID}`], searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction2?.reportID}`]].filter( + (report) => !!report, + ), + policies: [searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${transaction1?.policyID}`], searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${transaction2?.policyID}`]].filter( + (policy) => !!policy, + ), + }; +} + /** * 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,8 +219,8 @@ 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); - if (isManagedCardTransaction(targetTransaction) || isTargetExpenseSplit) { + const isTargetExpenseSplit = isExpenseSplit(targetTransaction); + if (isFromCreditCardImport(targetTransaction) || isTargetExpenseSplit) { mergeableData[field] = targetValue; mergeableData.currency = getCurrency(targetTransaction); if (isTargetExpenseSplit) { @@ -241,7 +253,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); @@ -251,7 +263,7 @@ function getMergeableDataAndConflictFields( // Use the reimbursable flag coming from card transactions automatically // See https://github.com/Expensify/App/issues/69598 - if (field === 'reimbursable' && isManagedCardTransaction(targetTransaction)) { + if (field === 'reimbursable' && isFromCreditCardImport(targetTransaction)) { mergeableData[field] = targetValue; continue; } @@ -271,7 +283,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); @@ -304,6 +316,9 @@ function getReportIDForExpense(transaction: OnyxEntry) { * @returns The report ID for the transaction thread */ function getTransactionThreadReportID(transaction: OnyxEntry) { + if (transaction?.transactionThreadReportID) { + return transaction.transactionThreadReportID; + } const iouActionOfTargetTransaction = getIOUActionForReportID(getReportIDForExpense(transaction), transaction?.transactionID); return iouActionOfTargetTransaction?.childReportID; } @@ -346,6 +361,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 (isFromCreditCardImport(transaction1) && isFromCreditCardImport(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) && isFromCreditCardImport(transaction2)) || (isPerDiemRequest(transaction2) && isFromCreditCardImport(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 +425,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 (isFromCreditCardImport(sourceTransaction) || (isExpenseSplit(sourceTransaction) && !isFromCreditCardImport(targetTransaction))) { return {targetTransaction: sourceTransaction, sourceTransaction: targetTransaction}; } @@ -377,7 +444,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 +467,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 +494,7 @@ function buildMergeFieldsData( sourceTransaction: Transaction | undefined, mergeTransaction: MergeTransaction | null | undefined, translate: LocaleContextProps['translate'], + reports: Array> = [], ): MergeFieldData[] { if (!targetTransaction || !sourceTransaction) { return []; @@ -436,12 +508,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 +530,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 +545,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 +573,7 @@ function getRateFromMerchant(merchant: string | undefined): string { } export { - getSourceTransactionFromMergeTransaction, - getTargetTransactionFromMergeTransaction, + getTransactionFromMergeTransaction, shouldNavigateToReceiptReview, getMergeableDataAndConflictFields, getMergeFieldValue, @@ -510,9 +587,11 @@ export { buildMergeFieldsData, getReportIDForExpense, getMergeFieldErrorText, - MERGE_FIELDS, + areTransactionsEligibleForMerge, getRateFromMerchant, getMergeFieldUpdatedValues, + getTransactionsAndReportsFromSearch, + MERGE_FIELDS, }; export type {MergeFieldKey, MergeFieldData, MergeTransactionUpdateValues}; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 4279ef36d2731..fbcbaa7bc198b 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2143,21 +2143,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; + isOnSearch?: boolean; }; [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; + isOnSearch?: boolean; }; [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; + isOnSearch?: boolean; }; [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; + isOnSearch?: boolean; }; }; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 09aab102c7167..0b31677f955d8 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, @@ -565,26 +566,16 @@ 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; } - // Do not show merge action for transactions with negative amounts - const transactionDetails = getTransactionDetails(reportTransactions.at(0)); - if (transactionDetails) { - const transactionAmount = transactionDetails?.amount; - if (transactionAmount < 0) { - return false; - } - } - const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isReceiptBeingScanned(transaction)); if (isAnyReceiptBeingScanned) { @@ -604,6 +595,58 @@ function isMergeAction(parentReport: Report, reportTransactions: Transaction[], return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin); } +function isMergeActionForSelectedTransactions(transactions: Transaction[], reports: Report[], policies: Policy[], currentUserAccountID?: number) { + if ([transactions, reports, policies].some((collection) => collection?.length > 2)) { + return false; + } + + // Prevent Merge from showing for admins/managers when selecting transactions + // belonging to different users + if (transactions.length === 2) { + const transactionReportData = transactions.map((transaction) => ({ + transaction, + report: reports.find((r) => r.reportID === transaction.reportID), + isUnreported: transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, + })); + + const [first, second] = transactionReportData; + + // Check if both transactions are unreported (in this case they must belong to current user) + const areBothUnreported = first.isUnreported && second.isUnreported; + + // Check if both reports have the same owner accountID (when both are reported) + const haveSameOwner = !first.isUnreported && !second.isUnreported && first.report?.ownerAccountID === second.report?.ownerAccountID; + + // Check if it's a mix of reported/unreported and the reported transaction belongs to current user + const isMixedAndValid = + (first.isUnreported && !second.isUnreported && second.report?.ownerAccountID === currentUserAccountID) || + (!first.isUnreported && second.isUnreported && first.report?.ownerAccountID === currentUserAccountID); + + if (!areBothUnreported && !haveSameOwner && !isMixedAndValid) { + return false; + } + } + + // All reports must be in an editable state by the current user to allow merging + const policyMap = new Map(policies.map((p) => [p?.id, p])); + const allReportsEligible = reports.every((report) => { + if (!report) { + return true; + } + if (!report?.policyID) { + return true; + } + + const policy = policyMap.get(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, @@ -784,7 +827,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); } @@ -881,4 +924,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 d296921a24fc6..0c891e4a30573 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13,7 +13,6 @@ import type {ColorValue} from 'react-native'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {SvgProps} from 'react-native-svg'; -import type {OriginalMessageChangePolicy, OriginalMessageExportIntegration, OriginalMessageModifiedExpense, OriginalMessageMovedTransaction} from 'src/types/onyx/OriginalMessage'; import type {SetRequired, TupleToUnion, ValueOf} from 'type-fest'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; // eslint-disable-next-line no-restricted-imports @@ -68,7 +67,14 @@ import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; import type {OriginalMessageExportedToIntegration} from '@src/types/onyx/OldDotAction'; import type Onboarding from '@src/types/onyx/Onboarding'; import type {ErrorFields, Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type { + OriginalMessageChangeLog, + OriginalMessageChangePolicy, + OriginalMessageExportIntegration, + OriginalMessageModifiedExpense, + OriginalMessageMovedTransaction, + PaymentMethodType, +} from '@src/types/onyx/OriginalMessage'; import type {Status, Timezone} from '@src/types/onyx/PersonalDetails'; import type {AllConnectionName, ConnectionName} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participants, Participant as ReportParticipant} from '@src/types/onyx/Report'; @@ -1237,8 +1243,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 +2801,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; @@ -4595,7 +4601,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( @@ -5099,17 +5105,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 @@ -7070,6 +7076,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); @@ -7100,6 +7108,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 ?? ''; @@ -11046,7 +11055,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/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 5263c0e7839db..46ce7b1c3b7fb 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1261,7 +1261,8 @@ function getTransactionsSections( const shouldShowBlankTo = !report || isOpenExpenseReport(report); const transactionViolations = getTransactionViolations(allViolations, transactionItem, currentUserEmail, currentAccountID ?? CONST.DEFAULT_NUMBER_ID, report, policy); // Use Map.get() for faster lookups with default values - const from = reportAction?.actorAccountID ? (personalDetailsMap.get(reportAction.actorAccountID.toString()) ?? emptyPersonalDetails) : emptyPersonalDetails; + const fromAccountID = reportAction?.actorAccountID ?? report?.ownerAccountID; + const from = fromAccountID ? (personalDetailsMap.get(fromAccountID.toString()) ?? emptyPersonalDetails) : emptyPersonalDetails; const to = getToFieldValueForTransaction(transactionItem, report, data.personalDetailsList, reportAction); const {formattedFrom, formattedTo, formattedTotal, formattedMerchant, date, submitted, approved, posted} = getTransactionItemCommonFormattedProperties( diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 4182c6677626a..2f26037222b45 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -181,9 +181,9 @@ function isMapDistanceRequest(transaction: OnyxEntry): boolean { return hasDistanceCustomUnit(transaction); } -function isManualDistanceRequest(transaction: OnyxEntry): boolean { +function isManualDistanceRequest(transaction: OnyxEntry, isUpdatedMergeTransaction = false): boolean { // This is used during the expense creation flow before the transaction has been saved to the server - if (lodashHas(transaction, 'iouRequestType')) { + if (lodashHas(transaction, 'iouRequestType') && !isUpdatedMergeTransaction) { return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; } @@ -1139,10 +1139,19 @@ function isManagedCardTransaction(transaction: OnyxEntry): boolean * This includes managed cards (Expensify/Company cards) and personal cards imported via bank connection. */ function isFromCreditCardImport(transaction: OnyxEntry): boolean { + // This can be set in transactions found in the search snapshot + if (transaction?.transactionType === CONST.SEARCH.TRANSACTION_TYPE.CARD) { + return true; + } + if (transaction?.bank === CONST.COMPANY_CARDS.BANK_NAME.UPLOAD) { return false; } + if (transaction?.cardName === CONST.EXPENSE.TYPE.CASH_CARD_NAME) { + return false; + } + if (isManagedCardTransaction(transaction)) { return true; } @@ -2303,7 +2312,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..a8379b16e6e65 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -1,34 +1,29 @@ 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 type {MergeTransaction, Policy, PolicyCategories, PolicyTagLists, Report, Transaction} from '@src/types/onyx'; +import ROUTES from '@src/ROUTES'; +import type {CardList, MergeTransaction, Policy, PolicyCategories, PolicyTagLists, Report, Transaction} from '@src/types/onyx'; import {getUpdateMoneyRequestParams, getUpdateTrackExpenseParams} from './IOU'; import type {UpdateMoneyRequestData} from './IOU'; @@ -46,71 +41,88 @@ 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( + navigationTransactionID: string, + transactions: Transaction[], + localeCompare: LocaleContextProps['localeCompare'], + searchReports?: Report[], + isSelectingSourceTransaction?: boolean, + isOnSearch?: boolean, +) { + if (!transactions.length || transactions.length > 2) { + return; } - // Do not allow merging two split expenses - if (isExpenseSplit(transaction1, originalTransaction1) && isExpenseSplit(transaction2, originalTransaction2)) { - return false; + if (transactions.length === 1) { + const transaction = transactions.at(0); + if (transaction) { + setupMergeTransactionData(navigationTransactionID, {targetTransactionID: transaction.transactionID}); + Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(transaction.transactionID, Navigation.getActiveRoute(), isOnSearch)); + return; + } } - // Do not allow merging two $0 transactions - if (getAmount(transaction1, false, false) === 0 && getAmount(transaction2, false, false) === 0) { - return false; + const {targetTransaction, sourceTransaction} = selectTargetAndSourceTransactionsForMerge(transactions.at(0), transactions.at(1)); + if (!targetTransaction || !sourceTransaction) { + return; } - // Do not allow merging a per diem and a card transaction - if ((isPerDiemRequest(transaction1) && isManagedCardTransaction(transaction2)) || (isPerDiemRequest(transaction2) && isManagedCardTransaction(transaction1))) { - return false; + const setupData = {targetTransactionID: targetTransaction?.transactionID, sourceTransactionID: sourceTransaction?.transactionID}; + if (isSelectingSourceTransaction) { + setMergeTransactionKey(navigationTransactionID, setupData); + } else { + setupMergeTransactionData(navigationTransactionID, setupData); } + if (shouldNavigateToReceiptReview([targetTransaction, sourceTransaction])) { + // Navigate to the receipt review page if both transactions have a receipt + Navigation.navigate(ROUTES.MERGE_TRANSACTION_RECEIPT_PAGE.getRoute(navigationTransactionID, Navigation.getActiveRoute(), isOnSearch)); + } else { + const receipt = targetTransaction.receipt?.receiptID ? targetTransaction.receipt : sourceTransaction.receipt; + if (receipt) { + setMergeTransactionKey(navigationTransactionID, {receipt}); + } - // 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; - } + // 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(navigationTransactionID, mergeableData); + Navigation.navigate(ROUTES.MERGE_TRANSACTION_CONFIRMATION_PAGE.getRoute(navigationTransactionID, Navigation.getActiveRoute(), isOnSearch)); + return; + } - if (isDistanceRequest(transaction1) !== isDistanceRequest(transaction2)) { - return false; + Navigation.navigate(ROUTES.MERGE_TRANSACTION_DETAILS_PAGE.getRoute(navigationTransactionID, Navigation.getActiveRoute(), isOnSearch)); } +} + +/** + * 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))) ); }); @@ -133,6 +145,7 @@ function getTransactionsForMerging({ policy: OnyxEntry; report: OnyxEntry; currentUserLogin: string | undefined; + cardList?: CardList; }) { const transactionID = targetTransaction.transactionID; @@ -147,15 +160,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 +175,7 @@ function getTransactionsForMerging({ } if (isOffline) { - getTransactionsForMergingLocally(transactionID, targetTransaction, transactions); + getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, isAdmin); } else { getTransactionsForMergingFromAPI(transactionID); } @@ -448,4 +458,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 e658f0cd8a351..a1dacb19f31a1 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, @@ -59,10 +60,12 @@ import {setTransactionReport} from '@libs/actions/Transaction'; import {setNameValuePair} from '@libs/actions/User'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {navigateToParticipantPage} from '@libs/IOUUtils'; +import {getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; 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 +157,7 @@ function SearchPage({route}: SearchPageProps) { 'ThumbsUp', 'ThumbsDown', 'ArrowRight', + 'ArrowCollapse', 'Stopwatch', 'Exclamation', 'SmartScan', @@ -362,6 +366,7 @@ function SearchPage({route}: SearchPageProps) { ) as PaymentData[]; payMoneyRequestOnSearch(hash, paymentData); + // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { clearSelectedTransactions(); @@ -370,6 +375,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) { @@ -661,6 +674,22 @@ function SearchPage({route}: SearchPageProps) { }); } + if (selectedTransactionsKeys.length < 3 && searchResults?.search.type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && searchResults?.data) { + const {transactions, reports, policies: transactionPolicies} = getTransactionsAndReportsFromSearch(searchResults, selectedTransactionsKeys); + + if (isMergeActionForSelectedTransactions(transactions, reports, transactionPolicies, currentUserPersonalDetails.accountID)) { + const transactionID = transactions.at(0)?.transactionID; + if (transactionID) { + options.push({ + text: translate('common.merge'), + icon: expensifyIcons.ArrowCollapse, + value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE, + onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, transactions, localeCompare, reports, false, true), + }); + } + } + } + const ownerAccountIDs = new Set(); let hasUnknownOwner = false; for (const id of selectedTransactionsKeys) { @@ -755,11 +784,13 @@ function SearchPage({route}: SearchPageProps) { return options; }, [ + searchResults, selectedTransactionsKeys, status, hash, selectedTransactions, translate, + localeCompare, areAllMatchingItemsSelected, isOffline, selectedReports, @@ -774,7 +805,6 @@ function SearchPage({route}: SearchPageProps) { csvExportLayouts, clearSelectedTransactions, beginExportWithTemplate, - dismissedRejectUseExplanation, bulkPayButtonOptions, onBulkPaySelected, allReports, @@ -782,9 +812,23 @@ 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, + currentUserPersonalDetails?.accountID, ]); const handleDeleteExpenses = () => { @@ -896,15 +940,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..ddecdb155b3c5 100644 --- a/src/pages/TransactionDuplicate/Confirmation.tsx +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -142,11 +142,11 @@ function Confirmation() { } /> diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index cf6e928d9191b..69941ec4aa09f 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -9,23 +9,22 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MoneyRequestView from '@components/ReportActionItem/MoneyRequestView'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; +import useMergeTransactions from '@hooks/useMergeTransactions'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {mergeTransactionRequest} from '@libs/actions/MergeTransaction'; -import {buildMergedTransactionData, getReportIDForExpense, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction} from '@libs/MergeTransactionUtils'; +import {buildMergedTransactionData} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; -import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type {ReportActions, Transaction} from '@src/types/onyx'; +import type {Transaction} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; type ConfirmationPageProps = PlatformStackScreenProps; @@ -35,31 +34,13 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const styles = useThemeStyles(); const [isMergingExpenses, setIsMergingExpenses] = useState(false); - const {transactionID, backTo} = route.params; + const {transactionID, isOnSearch, backTo} = route.params; 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 contextValue = useMemo( - () => ({ - transactionThreadReport: targetTransactionThreadReport, - action: undefined, - report: targetTransactionThreadReport, - checkIfContextMenuActive: () => {}, - onShowContextMenu: () => {}, - isReportArchived: false, - anchor: null, - isDisabled: false, - }), - [targetTransactionThreadReport], - ); + const mergedTransactionData = buildMergedTransactionData(targetTransaction, mergeTransaction); - const handleMergeExpenses = useCallback(() => { + const handleMergeExpenses = () => { if (!targetTransaction || !mergeTransaction || !sourceTransaction) { return; } @@ -106,27 +73,17 @@ function ConfirmationPage({route}: ConfirmationPageProps) { isASAPSubmitBetaEnabled, }); - const reportIDToDismiss = reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? reportID : targetTransactionThreadReportID; - if (reportID !== targetTransaction.reportID && reportIDToDismiss) { + const reportIDToDismiss = reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? reportID : undefined; + + // If we're on search, dismiss the modal and stay on search + if (!isOnSearch && reportIDToDismiss && reportID !== targetTransaction.reportID) { 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 +104,15 @@ function ConfirmationPage({route}: ConfirmationPageProps) { {translate('transactionMerge.confirmationPage.pageTitle')} - - } - mergeTransactionID={transactionID} - /> - + } + mergeTransactionID={transactionID} + />