From 4da3f843752dcf5f05ea58c991c24f52c25032c4 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 22 Oct 2025 18:52:05 +0100 Subject: [PATCH 001/100] Add merge option when selecting transactions in the report page --- src/pages/Search/SearchPage.tsx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 52eb31f0516f..178171c2a772 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -35,6 +35,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {confirmReadyToOpenApp} from '@libs/actions/App'; +import {setMergeTransactionKey, setupMergeTransactionData} from '@libs/actions/MergeTransaction'; import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, @@ -380,6 +381,7 @@ function SearchPage({route}: SearchPageProps) { }, }); } + const shouldEnableExpenseBulk = selectedReports.length ? selectedReports.every( (report) => report.allActions.includes(CONST.SEARCH.ACTION_TYPES.PAY) && report.policyID && getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods), @@ -476,13 +478,32 @@ function SearchPage({route}: SearchPageProps) { if (canAllTransactionsBeMoved && !hasMultipleOwners) { options.push({ text: translate('iou.moveExpenses', {count: selectedTransactionsKeys.length}), - icon: Expensicons.DocumentMerge, + icon: Expensicons.ArrowCollapse, value: CONST.SEARCH.BULK_ACTION_TYPES.CHANGE_REPORT, shouldCloseModalOnSelect: true, onSelected: () => Navigation.navigate(ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP), }); } + const canMergeTransactions = selectedTransactionsKeys.length < 3; + if (canMergeTransactions) { + options.push({ + text: translate('common.merge'), + icon: Expensicons.ArrowCollapse, + value: 'MERGE', + onSelected: () => { + if (selectedTransactionsKeys.length === 1) { + // Same logic as money report view + } else { + const targetTransactionID = selectedTransactionsKeys[0]; + const sourceTransactionID = selectedTransactionsKeys[1]; + setupMergeTransactionData(targetTransactionID, {targetTransactionID, sourceTransactionID}); + Navigation.navigate(ROUTES.MERGE_TRANSACTION_DETAILS_PAGE.getRoute(targetTransactionID, Navigation.getActiveRoute())); + } + }, + }); + } + const shouldShowDeleteOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canDelete); if (shouldShowDeleteOption) { From ace5e72b632d9028c5c735bc4ed35b63d7e827b7 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Thu, 23 Oct 2025 20:52:53 +0100 Subject: [PATCH 002/100] wip refactoring --- .../TransactionMerge/ConfirmationPage.tsx | 2 + .../TransactionMerge/DetailsReviewPage.tsx | 4 + .../TransactionMerge/MoneyRequestView.tsx | 768 ++++++++++++++++++ 3 files changed, 774 insertions(+) create mode 100644 src/pages/TransactionMerge/MoneyRequestView.tsx diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index b454f5777d33..83c46a696e55 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -85,6 +85,8 @@ function ConfirmationPage({route}: ConfirmationPageProps) { }, [targetTransaction, mergeTransaction, sourceTransaction, transactionID, targetTransactionThreadReportID, policy, policyTags, policyCategories]); if (isLoadingOnyxValue(mergeTransactionMetadata) || !targetTransactionThreadReport?.reportID) { + console.log('isLoadingOnyxValue', isLoadingOnyxValue(mergeTransactionMetadata)); + console.log('!transactionThreadReportID', !targetTransactionThreadReport?.reportID); return ; } diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index dc1ed159ca46..34bc1f8f4ea1 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -84,6 +84,9 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { const [conflictFields, setConflictFields] = useState([]); const [isCheckingDataBeforeGoNext, setIsCheckingDataBeforeGoNext] = useState(false); + console.log(targetTransaction); + console.log(sourceTransaction); + useEffect(() => { if (!transactionID || !targetTransaction || !sourceTransaction) { return; @@ -119,6 +122,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { } // We need to wait for report to be loaded completely, avoid still optimistic loading if (!targetTransactionThreadReport?.reportID) { + console.log('returning'); return; } diff --git a/src/pages/TransactionMerge/MoneyRequestView.tsx b/src/pages/TransactionMerge/MoneyRequestView.tsx new file mode 100644 index 000000000000..d94fe03e5bee --- /dev/null +++ b/src/pages/TransactionMerge/MoneyRequestView.tsx @@ -0,0 +1,768 @@ +import {Str} from 'expensify-common'; +import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; +import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import ViolationMessages from '@components/ViolationMessages'; +import {WideRHPContext} from '@components/WideRHPContextProvider'; +import useActiveRoute from '@hooks/useActiveRoute'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import usePrevious from '@hooks/usePrevious'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionViolations from '@hooks/useTransactionViolations'; +import type {ViolationField} from '@hooks/useViolations'; +import useViolations from '@hooks/useViolations'; +import {getCompanyCardDescription} from '@libs/CardUtils'; +import {isCategoryMissing} from '@libs/CategoryUtils'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getReportIDForExpense} from '@libs/MergeTransactionUtils'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import Parser from '@libs/Parser'; +import {getLengthOfTag, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils, isTaxTrackingEnabled} from '@libs/PolicyUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; +import type {TransactionDetails} from '@libs/ReportUtils'; +import { + canEditFieldOfMoneyRequest, + canEditMoneyRequest, + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + getReportName, + getReportOrDraftReport, + getTransactionDetails, + getTripIDFromTransactionParentReportID, + isInvoiceReport, + isPaidGroupPolicy, + isReportApproved, + isReportInGroupPolicy, + isSettled as isSettledReportUtils, + isTrackExpenseReport, + shouldEnableNegative, +} from '@libs/ReportUtils'; +import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; +import { + getBillable, + getCurrency, + getDescription, + getDistanceInMeters, + getFormattedCreated, + getOriginalTransactionWithSplitInfo, + getReimbursable, + getTagForDisplay, + getTaxName, + hasMissingSmartscanFields, + hasReservationList, + hasRoute as hasRouteTransactionUtils, + isManagedCardTransaction as isCardTransactionTransactionUtils, + isDistanceRequest as isDistanceRequestTransactionUtils, + isExpenseUnreported as isExpenseUnreportedTransactionUtils, + isManualDistanceRequest as isManualDistanceRequestTransactionUtils, + isPerDiemRequest as isPerDiemRequestTransactionUtils, + isScanning, + shouldShowAttendees as shouldShowAttendeesTransactionUtils, +} from '@libs/TransactionUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import Navigation from '@navigation/Navigation'; +import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; +import {initSplitExpense, updateMoneyRequestBillable, updateMoneyRequestReimbursable} from '@userActions/IOU'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; + +type MoneyRequestViewProps = { + /** All the data of the report collection */ + allReports: OnyxCollection; + + /** The report currently being looked at */ + report: OnyxEntry; + + /** Policy that the report belongs to */ + expensePolicy: OnyxEntry; + + /** Whether we should display the animated banner above the component */ + shouldShowAnimatedBackground: boolean; + + /** Whether we should show Money Request with disabled all fields */ + readonly?: boolean; + + /** whether this report is from review duplicates */ + isFromReviewDuplicates?: boolean; + + /** Updated transaction to show in duplicate & merge transaction flow */ + updatedTransaction?: OnyxEntry; + + /** Merge transaction ID to show in merge transaction flow */ + mergeTransactionID?: string; +}; + +function MoneyRequestView({ + allReports, + expensePolicy, + shouldShowAnimatedBackground, + readonly = false, + updatedTransaction, + isFromReviewDuplicates = false, + mergeTransactionID, +}: MoneyRequestViewProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + const {isOffline} = useNetwork(); + const {translate, toLocaleDigit} = useLocalize(); + const {getReportRHPActiveRoute} = useActiveRoute(); + const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); + + const isFromMergeTransaction = !!mergeTransactionID; + const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); + // If the expense is unreported the policy should be the user's default policy, otherwise it should be the policy the expense was made for + + const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${updatedTransaction?.reportID}`]; + const allPolicyTags = usePolicyTags(); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {canBeMissing: true}); + + const { + created: transactionDate, + amount: transactionAmount, + attendees: transactionAttendees, + taxAmount: transactionTaxAmount, + currency: transactionCurrency, + comment: transactionDescription, + merchant: transactionMerchant, + reimbursable: transactionReimbursable, + billable: transactionBillable, + category: transactionCategory, + tag: transactionTag, + originalAmount: transactionOriginalAmount, + originalCurrency: transactionOriginalCurrency, + postedDate: transactionPostedDate, + } = useMemo>(() => getTransactionDetails(updatedTransaction, undefined, undefined, true) ?? {}, [updatedTransaction]); + + const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const isDistanceRequest = isDistanceRequestTransactionUtils(updatedTransaction); + const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(updatedTransaction); + const isMapDistanceRequest = isDistanceRequest && !isManualDistanceRequest; + const isPerDiemRequest = isPerDiemRequestTransactionUtils(updatedTransaction); + const isTransactionScanning = isScanning(updatedTransaction ?? updatedTransaction); + const hasRoute = hasRouteTransactionUtils(updatedTransaction, isDistanceRequest); + + const actualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees; + + // Use the updated transaction amount in merge flow to have correct positive/negative sign + const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount; + const actualCurrency = updatedTransaction ? getCurrency(updatedTransaction) : transactionCurrency; + const shouldDisplayTransactionAmount = ((isDistanceRequest && hasRoute) || !!actualAmount) && actualAmount !== undefined; + const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount, actualCurrency) : ''; + const formattedPerAttendeeAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount / (actualAttendees?.length ?? 1), actualCurrency) : ''; + + const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); + const isCardTransaction = isCardTransactionTransactionUtils(updatedTransaction); + const cardProgramName = getCompanyCardDescription(updatedTransaction?.cardName, updatedTransaction?.cardID, cardList); + const shouldShowCard = isCardTransaction && cardProgramName; + + const formattedTaxAmount = updatedTransaction?.taxAmount ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), transactionCurrency) : 0; + + const taxRatesDescription = taxRates?.name; + const taxRateTitle = getTaxName(policy, updatedTransaction) : getTaxName(policy, transaction); + + const actualTransactionDate = getFormattedCreated(updatedTransaction); + const fallbackTaxRateTitle = updatedTransaction?.taxValue; + + const category = transactionCategory ?? ''; + const categoryForDisplay = isCategoryMissing(category) ? '' : category; + + // Flags for showing categories and tags + // transactionCategory can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldShowCategory = isPolicyExpenseChat && (categoryForDisplay || hasEnabledOptions(policyCategories ?? {})); + // transactionTag can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldShowTag = isPolicyExpenseChat && (transactionTag || hasEnabledTags(policyTagLists)); + const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true) || !!updatedTransaction?.billable); + const isCurrentTransactionReimbursableDifferentFromPolicyDefault = + policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; + const shouldShowReimbursable = + isPolicyExpenseChat && (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && !isCardTransaction && !isInvoice; + const canEditReimbursable = isEditable && canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, undefined, isChatReportArchived); + const shouldShowAttendees = useMemo(() => shouldShowAttendeesTransactionUtils(iouType, policy), [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 hasViolations = useCallback( + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => + getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0, + [getViolationsForField], + ); + + let amountDescription = `${translate('iou.amount')}`; + let dateDescription = `${translate('common.date')}`; + + const {unit, rate} = DistanceRequestUtils.getRate({transaction, policy}); + const distance = getDistanceInMeters(transactionBackup ?? transaction, unit); + const currency = transactionCurrency ?? CONST.CURRENCY.USD; + const isCustomUnitOutOfPolicy = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && !rate); + const rateToDisplay = isCustomUnitOutOfPolicy ? translate('common.rateOutOfPolicy') : DistanceRequestUtils.getRateForDisplay(unit, rate, currency, translate, toLocaleDigit, isOffline); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate); + let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; + let amountTitle = formattedTransactionAmount ? formattedTransactionAmount.toString() : ''; + if (isTransactionScanning) { + merchantTitle = translate('iou.receiptStatusTitle'); + amountTitle = translate('iou.receiptStatusTitle'); + } + + const updatedTransactionDescription = useMemo(() => { + if (!updatedTransaction) { + return undefined; + } + return getDescription(updatedTransaction ?? null); + }, [updatedTransaction]); + 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); + }, + [transaction, report?.reportID, policy, policyTagList, policyCategories], + ); + + 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); + }, + [transaction, report, policy, policyTagList, policyCategories], + ); + + if (isCardTransaction) { + if (transactionPostedDate) { + dateDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.posted')} ${transactionPostedDate}`; + } + if (formattedOriginalAmount) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.original')} ${formattedOriginalAmount}`; + } + if (isCancelled) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; + } + } else { + if (!isDistanceRequest && !isPerDiemRequest) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.cash')}`; + } + if (isCancelled) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; + } else if (isApproved) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.approved')}`; + } else if (shouldShowPaid) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.settledExpensify')}`; + } + } + if (isExpenseSplit) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.split')}`; + } + + const hasErrors = hasMissingSmartscanFields(transaction); + const pendingAction = transaction?.pendingAction; + // 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'); + } + + // Return violations if there are any + if (field !== 'merchant' && hasViolations(field, data, policyHasDependentTags, tagValue)) { + const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); + const firstViolation = violations.at(0); + + if (firstViolation) { + return ViolationsUtils.getViolationTranslation(firstViolation, translate, canEdit); + } + } + + return ''; + }, + [ + transactionAmount, + isSettled, + isCancelled, + isPolicyExpenseChat, + isEmptyMerchant, + transactionDate, + readonly, + hasErrors, + hasViolations, + translate, + getViolationsForField, + canEditAmount, + canEditDate, + canEditMerchant, + canEdit, + isCustomUnitOutOfPolicy, + ], + ); + + const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; + const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; + const amountCopyValue = !canEditAmount ? amountTitle : undefined; + const descriptionCopyValue = useMemo(() => { + if (canEdit) { + return undefined; + } + + const descriptionHTML = updatedTransactionDescription ?? transactionDescription; + if (!descriptionHTML) { + return undefined; + } + + return Parser.htmlToText(descriptionHTML); + }, [canEdit, transactionDescription, updatedTransactionDescription]); + const merchantCopyValue = !canEditMerchant ? updatedMerchantTitle : undefined; + const dateCopyValue = !canEditDate ? transactionDate : undefined; + const categoryValue = updatedTransaction?.category ?? categoryForDisplay; + const categoryCopyValue = !canEdit ? categoryValue : undefined; + const cardCopyValue = cardProgramName; + const taxRateValue = taxRateTitle ?? fallbackTaxRateTitle; + const taxRateCopyValue = !canEditTaxFields ? taxRateValue : undefined; + const taxAmountTitle = formattedTaxAmount ? formattedTaxAmount.toString() : ''; + const taxAmountCopyValue = !canEditTaxFields ? taxAmountTitle : undefined; + + const distanceRequestFields = ( + <> + + { + if (!transaction?.transactionID || !report?.reportID) { + return; + } + + if (isExpenseSplit) { + initSplitExpense(transaction); + return; + } + + if (isManualDistanceRequest) { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ); + return; + } + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ); + }} + copyValue={distanceCopyValue} + copyable={!!distanceCopyValue} + /> + + + { + if (!transaction?.transactionID || !report?.reportID) { + return; + } + + if (isExpenseSplit) { + initSplitExpense(transaction); + return; + } + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ); + }} + brickRoadIndicator={getErrorForField('customUnitRateID') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('customUnitRateID')} + copyValue={distanceRateCopyValue} + copyable={!!distanceRateCopyValue} + /> + + + ); + + const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTagList); + + const previousTransactionTag = usePrevious(transactionTag); + + const [previousTag, setPreviousTag] = useState(undefined); + const [currentTransactionTag, setCurrentTransactionTag] = useState(undefined); + + useEffect(() => { + if (transactionTag === previousTransactionTag) { + return; + } + setPreviousTag(previousTransactionTag); + setCurrentTransactionTag(transactionTag); + }, [transactionTag, previousTransactionTag]); + + const getAttendeesTitle = useMemo(() => { + return Array.isArray(actualAttendees) ? actualAttendees.map((item) => item?.displayName ?? item?.login).join(', ') : ''; + }, [transactionAttendees]); + const attendeesCopyValue = !canEdit ? getAttendeesTitle : undefined; + + const previousTagLength = getLengthOfTag(previousTag ?? ''); + const currentTagLength = getLengthOfTag(currentTransactionTag ?? ''); + + const tagList = policyTagLists.map(({name, orderWeight, tags}, index) => { + const tagForDisplay = getTagForDisplay(updatedTransaction ?? transaction, index); + let shouldShow = false; + if (hasDependentTags) { + if (index === 0) { + shouldShow = true; + } else { + const prevTagValue = getTagForDisplay(transaction, index - 1); + shouldShow = !!prevTagValue; + } + } else { + shouldShow = !!tagForDisplay || hasEnabledOptions(tags); + } + + if (!shouldShow) { + return null; + } + + const tagError = getErrorForField( + 'tag', + { + tagListIndex: index, + tagListName: name, + }, + hasDependentTags, + tagForDisplay, + ); + const tagCopyValue = !canEdit ? tagForDisplay : undefined; + + return ( + + previousTagLength} + description={name ?? translate('common.tag')} + title={tagForDisplay} + numberOfLinesTitle={2} + interactive={canEdit} + shouldShowRightIcon={canEdit} + titleStyle={styles.flex1} + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction.transactionID, report.reportID, getReportRHPActiveRoute()), + ); + }} + brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={tagError} + shouldShowBasicTitle + shouldShowDescriptionOnTop + copyValue={tagCopyValue} + copyable={!!tagCopyValue} + /> + + ); + }); + + const actualParentReport = isFromMergeTransaction ? getReportOrDraftReport(getReportIDForExpense(updatedTransaction)) : parentReport; + const shouldShowReport = !!parentReportID || !!actualParentReport; + const reportCopyValue = !canEditReport ? getReportName(actualParentReport) || actualParentReport?.reportName : undefined; + + // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const {wideRHPRouteKeys} = useContext(WideRHPContext); + + if (updatedTransaction?.transactionID) { + return ; + } + + return ( + + {shouldShowAnimatedBackground && } + <> + {(wideRHPRouteKeys.length === 0 || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( + + )} + {isCustomUnitOutOfPolicy && isPerDiemRequest && ( + + + + {translate('violations.customUnitOutOfPolicy')} + + + )} + + + + + + + {isManualDistanceRequest || (isMapDistanceRequest && updatedTransaction?.comment?.waypoints) ? ( + distanceRequestFields + ) : ( + + + + )} + + + + {!!shouldShowCategory && ( + + + + )} + {shouldShowTag && tagList} + {!!shouldShowCard && ( + + + + )} + {shouldShowTax && ( + + + + )} + {shouldShowTax && ( + + + + )} + {shouldShowAttendees && ( + + 1 && formattedPerAttendeeAmount + ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` + : '' + }`} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + shouldRenderAsHTML + copyValue={attendeesCopyValue} + copyable={!!attendeesCopyValue} + /> + + )} + {shouldShowReimbursable && ( + + + {Str.UCFirst(translate('iou.reimbursable'))} + + + + )} + {shouldShowBillable && ( + + + {translate('common.billable')} + + + + )} + {shouldShowReport && ( + + + + )} + {/* Note: "View trip details" should be always the last item */} + {shouldShowViewTripDetails && ( + { + const reservations = updatedTransaction?.receipt?.reservationList?.length ?? 0; + if (reservations > 1) { + Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(updatedTransaction?.reportID, updatedTransaction?.transactionID, getReportRHPActiveRoute())); + } + Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(updatedTransaction?.reportID, updatedTransaction?.transactionID, '0', 0, getReportRHPActiveRoute())); + }} + /> + )} + + + ); +} + +MoneyRequestView.displayName = 'MoneyRequestView'; + +export default MoneyRequestView; From b235b1cabb94de6729e68ba1b16159a24e898759 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Sat, 25 Oct 2025 19:05:27 +0100 Subject: [PATCH 003/100] Create new read only MoneyRequestView --- .../TransactionMerge/ConfirmationPage.tsx | 64 ++-- .../TransactionMerge/DetailsReviewPage.tsx | 46 +-- .../MergeTransactionsListContent.tsx | 7 - .../TransactionMerge/MoneyRequestView.tsx | 341 +++++------------- 4 files changed, 105 insertions(+), 353 deletions(-) diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 83c46a696e55..b107e003ffe5 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, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -6,7 +6,6 @@ import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; 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'; @@ -14,16 +13,18 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {mergeTransactionRequest} from '@libs/actions/MergeTransaction'; -import {buildMergedTransactionData, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction, getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; +import {mergeTransactionRequest, setMergeTransactionKey} from '@libs/actions/MergeTransaction'; +import {buildMergedTransactionData, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction} 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 {Transaction} from '@src/types/onyx'; +import type {ReportActions, Transaction} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import MoneyRequestView from './MoneyRequestView'; type ConfirmationPageProps = PlatformStackScreenProps; @@ -43,9 +44,8 @@ function ConfirmationPage({route}: ConfirmationPageProps) { canBeMissing: true, }); - const targetTransactionThreadReportID = getTransactionThreadReportID(targetTransaction); - const targetTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`]; - const policyID = targetTransactionThreadReport?.policyID; + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${mergeTransaction?.reportID}`]; + const policyID = report?.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}); @@ -53,40 +53,22 @@ function ConfirmationPage({route}: ConfirmationPageProps) { // 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], - ); + useEffect(() => { + setMergeTransactionKey(transactionID, mergedTransactionData); + }, [mergedTransactionData, transactionID]); const handleMergeExpenses = useCallback(() => { if (!targetTransaction || !mergeTransaction || !sourceTransaction) { return; } - const reportID = mergeTransaction.reportID; setIsMergingExpenses(true); mergeTransactionRequest({mergeTransactionID: transactionID, mergeTransaction, targetTransaction, sourceTransaction, policy, policyTags, policyCategories}); - const reportIDToDismiss = reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? reportID : targetTransactionThreadReportID; - if (reportID !== targetTransaction.reportID && reportIDToDismiss) { - Navigation.dismissModalWithReport({reportID: reportIDToDismiss}); - } else { - Navigation.dismissModal(); - } - }, [targetTransaction, mergeTransaction, sourceTransaction, transactionID, targetTransactionThreadReportID, policy, policyTags, policyCategories]); + Navigation.dismissModal(); + }, [targetTransaction, mergeTransaction, sourceTransaction, transactionID, policy, policyTags, policyCategories]); - if (isLoadingOnyxValue(mergeTransactionMetadata) || !targetTransactionThreadReport?.reportID) { - console.log('isLoadingOnyxValue', isLoadingOnyxValue(mergeTransactionMetadata)); - console.log('!transactionThreadReportID', !targetTransactionThreadReport?.reportID); + if (isLoadingOnyxValue(mergeTransactionMetadata)) { return ; } @@ -107,17 +89,13 @@ function ConfirmationPage({route}: ConfirmationPageProps) { {translate('transactionMerge.confirmationPage.pageTitle')} - - } - mergeTransactionID={transactionID} - /> - + } + />