diff --git a/assets/images/simple-illustrations/simple-illustration__empty-shelves.svg b/assets/images/simple-illustrations/simple-illustration__empty-shelves.svg new file mode 100644 index 000000000000..f4ac49e4d879 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__empty-shelves.svg @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cspell.json b/cspell.json index aa79b299cf96..43623b13c135 100644 --- a/cspell.json +++ b/cspell.json @@ -8,6 +8,7 @@ "ADDCOMMENT", "Addendums", "ADFS", + "aeiou", "Aeroplan", "águero", "Aircall", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 332cc0d5ccf1..f3f98518c606 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1084,6 +1084,7 @@ const CONST = { REOPEN: 'reopen', EXPORT: 'export', PAY: 'pay', + MERGE: 'merge', }, PRIMARY_ACTIONS: { SUBMIT: 'submit', @@ -1115,6 +1116,7 @@ const CONST = { SPLIT: 'split', VIEW_DETAILS: 'viewDetails', DELETE: 'delete', + MERGE: 'merge', }, ADD_EXPENSE_OPTIONS: { CREATE_NEW_EXPENSE: 'createNewExpense', @@ -3107,6 +3109,7 @@ const CONST = { FinancialForce: 'https://help.expensify.com/articles/expensify-classic/connections/certinia/Connect-To-Certinia', 'Sage Intacct': 'https://help.expensify.com/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct', Certinia: 'https://help.expensify.com/articles/expensify-classic/connections/certinia/Connect-To-Certinia', + MERGE_EXPENSES: 'https://help.expensify.com/articles/new-expensify/reports-and-expenses/Merging-expenses', }, CUSTOM_UNITS: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e7018e49efc1..cb4f85debfeb 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -607,6 +607,7 @@ const ONYXKEYS = { SKIP_CONFIRMATION: 'skipConfirmation_', TRANSACTION_BACKUP: 'transactionsBackup_', SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_', + MERGE_TRANSACTION: 'mergeTransaction_', PRIVATE_NOTES_DRAFT: 'privateNotesDraft_', NEXT_STEP: 'reportNextStep_', @@ -1008,6 +1009,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.TRANSACTION_BACKUP]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; [ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT]: OnyxTypes.Transaction; + [ONYXKEYS.COLLECTION.MERGE_TRANSACTION]: OnyxTypes.MergeTransaction; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.OLD_POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.SELECTED_TAB]: OnyxTypes.SelectedTabRequest; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e79811f5dd9e..452deb59a44c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2076,7 +2076,15 @@ const ROUTES = { TRANSACTION_RECEIPT: { route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?', - getRoute: (reportID: string | undefined, transactionID: string | undefined, readonly = false, isFromReviewDuplicates = false, action?: IOUAction, iouType?: IOUType) => { + getRoute: ( + reportID: string | undefined, + transactionID: string | undefined, + readonly = false, + isFromReviewDuplicates = false, + action?: IOUAction, + iouType?: IOUType, + mergeTransactionID?: string, + ) => { if (!reportID) { Log.warn('Invalid reportID is used to build the TRANSACTION_RECEIPT route'); } @@ -2085,7 +2093,7 @@ const ROUTES = { } return `r/${reportID}/transaction/${transactionID}/receipt${action ? `/${action}` : ''}${iouType ? `/${iouType}` : ''}?readonly=${readonly}${ isFromReviewDuplicates ? '&isFromReviewDuplicates=true' : '' - }` as const; + }${mergeTransactionID ? `&mergeTransactionID=${mergeTransactionID}` : ''}` as const; }, }, @@ -2125,6 +2133,22 @@ const ROUTES = { route: 'r/:threadReportID/duplicates/confirm', getRoute: (threadReportID: string, backTo?: string) => getUrlWithBackToParam(`r/${threadReportID}/duplicates/confirm` as const, backTo), }, + MERGE_TRANSACTION_LIST_PAGE: { + route: 'r/:transactionID/merge', + getRoute: (transactionID: string | undefined, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge` as const, backTo), + }, + MERGE_TRANSACTION_RECEIPT_PAGE: { + route: 'r/:transactionID/merge/receipt', + getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/receipt` as const, backTo), + }, + MERGE_TRANSACTION_DETAILS_PAGE: { + route: 'r/:transactionID/merge/details', + getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/details` as const, backTo), + }, + MERGE_TRANSACTION_CONFIRMATION_PAGE: { + route: 'r/:transactionID/merge/confirmation', + getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/confirmation` as const, backTo), + }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'workspaces/:policyID/accounting/xero/import', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/xero/import` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6e05d7a0fcfa..13158b59b4cd 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -223,6 +223,7 @@ const SCREENS = { DEBUG: 'Debug', ADD_UNREPORTED_EXPENSE: 'AddUnreportedExpense', SCHEDULE_CALL: 'ScheduleCall', + MERGE_TRANSACTION: 'MergeTransaction', }, PUBLIC_CONSOLE_DEBUG: 'Console_Debug', ONBOARDING_MODAL: { @@ -293,6 +294,13 @@ const SCREENS = { CONFIRMATION: 'Transaction_Duplicate_Confirmation', }, + MERGE_TRANSACTION: { + LIST_PAGE: 'Merge_Transaction_List_Page', + RECEIPT_PAGE: 'Merge_Transaction_Receipt_Page', + DETAILS_PAGE: 'Merge_Transaction_Details_Page', + CONFIRMATION_PAGE: 'Merge_Transaction_Confirmation_Page', + }, + IOU_SEND: { ADD_BANK_ACCOUNT: 'IOU_Send_Add_Bank_Account', ADD_DEBIT_CARD: 'IOU_Send_Add_Debit_Card', diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 81b511cce86f..dd5107a5a7f9 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -96,6 +96,7 @@ import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustrat import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg'; import CreditCardsNewGreen from '@assets/images/simple-illustrations/simple-illustration__creditcards--green.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; +import EmptyShelves from '@assets/images/simple-illustrations/simple-illustration__empty-shelves.svg'; import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg'; import Encryption from '@assets/images/simple-illustrations/simple-illustration__encryption.svg'; import EnvelopeReceipt from '@assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg'; @@ -324,4 +325,5 @@ export { ReceiptsStackedOnPin, PaperAirplane, CardReplacementSuccess, + EmptyShelves, }; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ca7d2813624d..873afcb43970 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -16,6 +16,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setupMergeTransactionData} from '@libs/actions/MergeTransaction'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {deleteAppReport, downloadReportPDF, exportReportToCSV, exportReportToPDF, exportToIntegration, markAsManuallyExported, openReport, openUnreportedExpense} from '@libs/actions/Report'; import {queueExportSearchWithTemplate} from '@libs/actions/Search'; @@ -306,6 +307,7 @@ function MoneyReportHeader({ allTransactionsLength: transactions.length, session, onExportFailed: () => setIsDownloadErrorModalVisible(true), + policy, beginExportWithTemplate: (templateName, templateType, transactionIDList) => beginExportWithTemplate(templateName, templateType, transactionIDList), }); @@ -891,6 +893,20 @@ function MoneyReportHeader({ initSplitExpense(currentTransaction); }, }, + [CONST.REPORT.SECONDARY_ACTIONS.MERGE]: { + text: translate('common.merge'), + icon: Expensicons.ArrowCollapse, + value: CONST.REPORT.SECONDARY_ACTIONS.MERGE, + onSelected: () => { + const currentTransaction = transactions.at(0); + if (!currentTransaction) { + return; + } + + setupMergeTransactionData(currentTransaction.transactionID, {targetTransactionID: currentTransaction.transactionID}); + Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(currentTransaction.transactionID, Navigation.getActiveRoute())); + }, + }, [CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE]: { text: translate('iou.changeWorkspace'), icon: Expensicons.Buildings, diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 4613ad5301a7..218c8fee2901 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -13,6 +13,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {deleteMoneyRequest, deleteTrackExpense, initSplitExpense} from '@libs/actions/IOU'; +import {setupMergeTransactionData} from '@libs/actions/MergeTransaction'; import Navigation from '@libs/Navigation/Navigation'; import {getOriginalMessage, getReportActions, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import {getTransactionThreadPrimaryAction} from '@libs/ReportPrimaryActionUtils'; @@ -244,6 +245,19 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre initSplitExpense(transaction); }, }, + [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MERGE]: { + text: translate('common.merge'), + icon: Expensicons.ArrowCollapse, + value: CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MERGE, + onSelected: () => { + if (!transaction) { + return; + } + + setupMergeTransactionData(transaction.transactionID, {targetTransactionID: transaction.transactionID}); + Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(transaction.transactionID, Navigation.getActiveRoute())); + }, + }, [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS]: { value: CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS, text: translate('iou.viewDetails'), diff --git a/src/components/RadioButton.tsx b/src/components/RadioButton.tsx index 1ee885681700..8f16e852bc61 100644 --- a/src/components/RadioButton.tsx +++ b/src/components/RadioButton.tsx @@ -21,12 +21,45 @@ type RadioButtonProps = { /** Should the input be disabled */ disabled?: boolean; + + /** Whether to use new radio button style */ + // See https://expensify.slack.com/archives/C07HPDRELLD/p1752500012040139?thread_ts=1751637205.950179&cid=C07HPDRELLD + shouldUseNewStyle?: boolean; }; -function RadioButton({isChecked, onPress, accessibilityLabel, hasError = false, disabled = false}: RadioButtonProps) { +function RadioButton({isChecked, onPress, accessibilityLabel, hasError = false, disabled = false, shouldUseNewStyle = false}: RadioButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); + if (shouldUseNewStyle) { + return ( + + {isChecked && ( + + )} + + ); + } + return ( ; + + /** Merge transaction ID to show in merge transaction flow */ + mergeTransactionID?: string; }; const receiptImageViolationNames: OnyxTypes.ViolationName[] = [ @@ -121,7 +126,16 @@ const receiptImageViolationNames: OnyxTypes.ViolationName[] = [ const receiptFieldViolationNames: OnyxTypes.ViolationName[] = [CONST.VIOLATIONS.MODIFIED_AMOUNT, CONST.VIOLATIONS.MODIFIED_DATE]; -function MoneyRequestView({allReports, report, policy, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) { +function MoneyRequestView({ + allReports, + report, + policy, + shouldShowAnimatedBackground, + readonly = false, + updatedTransaction, + isFromReviewDuplicates = false, + mergeTransactionID, +}: MoneyRequestViewProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); @@ -181,9 +195,13 @@ function MoneyRequestView({allReports, report, policy, shouldShowAnimatedBackgro const isTransactionScanning = isScanning(updatedTransaction ?? transaction); const didReceiptScanSucceed = hasReceipt && didReceiptScanSucceedTransactionUtils(transaction); const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); - const shouldDisplayTransactionAmount = ((isDistanceRequest && hasRoute) || !!transactionAmount) && transactionAmount !== undefined; - const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(transactionAmount, transactionCurrency) : ''; - const formattedPerAttendeeAmount = shouldDisplayTransactionAmount ? convertToDisplayString(transactionAmount / (transactionAttendees?.length ?? 1), transactionCurrency) : ''; + + const actualAmount = updatedTransaction ? getAmount(updatedTransaction) : 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 / (transactionAttendees?.length ?? 1), actualCurrency) : ''; + const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); const isCardTransaction = isCardTransactionTransactionUtils(transaction); const cardProgramName = getCompanyCardDescription(transaction?.cardName, transaction?.cardID, cardList); @@ -661,6 +679,7 @@ function MoneyRequestView({allReports, report, policy, shouldShowAnimatedBackgro enablePreviewModal readonly={readonly || !canEditReceipt} isFromReviewDuplicates={isFromReviewDuplicates} + mergeTransactionID={mergeTransactionID} /> )} diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 83f2cf3ac536..b02810c758d4 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -61,6 +61,9 @@ type ReportActionItemImageProps = { /** whether or not this report is from review duplicates */ isFromReviewDuplicates?: boolean; + /** Merge transaction ID to show in merge transaction flow */ + mergeTransactionID?: string; + /** Callback to be called on pressing the image */ onPress?: () => void; @@ -88,6 +91,7 @@ function ReportActionItemImage({ readonly = false, shouldMapHaveBorderRadius, isFromReviewDuplicates = false, + mergeTransactionID, onPress, shouldUseFullHeight, }: ReportActionItemImageProps) { @@ -154,7 +158,15 @@ function ReportActionItemImage({ style={[styles.w100, styles.h100, styles.noOutline as ViewStyle]} onPress={() => Navigation.navigate( - ROUTES.TRANSACTION_RECEIPT.getRoute(transactionThreadReport?.reportID ?? report?.reportID, transaction?.transactionID, readonly, isFromReviewDuplicates), + ROUTES.TRANSACTION_RECEIPT.getRoute( + transactionThreadReport?.reportID ?? report?.reportID, + transaction?.transactionID, + readonly, + isFromReviewDuplicates, + undefined, + undefined, + mergeTransactionID, + ), ) } accessibilityLabel={translate('accessibilityHints.viewAttachment')} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index c79418d3cfb5..8dc89e06443f 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -79,6 +79,7 @@ function BaseSelectionList( listEmptyContent, showScrollIndicator = true, showLoadingPlaceholder = false, + LoadingPlaceholderComponent = OptionsListSkeletonView, showConfirmButton = false, isConfirmButtonDisabled = false, shouldUseDefaultTheme = false, @@ -681,7 +682,7 @@ function BaseSelectionList( const renderListEmptyContent = () => { if (showLoadingPlaceholder) { return ( - = { shouldShow?: boolean; }; +type LoadingPlaceholderComponentProps = { + shouldStyleAsTable?: boolean; + fixedNumItems?: number; + speed?: number; +}; + type SectionWithIndexOffset = Section & { /** The initial index of this section given the total number of options in each section's data array */ indexOffset?: number; @@ -661,6 +667,9 @@ type SelectionListProps = Partial & { /** Whether to show the loading placeholder */ showLoadingPlaceholder?: boolean; + /** The component to show when the list is loading */ + LoadingPlaceholderComponent?: React.ComponentType; + /** Whether to show the default confirm button */ showConfirmButton?: boolean; diff --git a/src/components/Skeletons/MergeExpensesSkeleton.tsx b/src/components/Skeletons/MergeExpensesSkeleton.tsx new file mode 100644 index 000000000000..1fb1a647ea80 --- /dev/null +++ b/src/components/Skeletons/MergeExpensesSkeleton.tsx @@ -0,0 +1,90 @@ +import React, {useCallback, useLayoutEffect, useRef} from 'react'; +import {View} from 'react-native'; +import {Rect} from 'react-native-svg'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ItemListSkeletonView from './ItemListSkeletonView'; + +const barHeight = 7; +const longBarWidth = 120; +const mediumBarWidth = 60; +const shortBarWidth = 40; + +type MergeExpensesSkeletonProps = { + fixedNumItems?: number; + speed?: number; +}; + +function MergeExpensesSkeleton({fixedNumItems, speed}: MergeExpensesSkeletonProps) { + const containerRef = useRef(null); + const styles = useThemeStyles(); + const [pageWidth, setPageWidth] = React.useState(0); + useLayoutEffect(() => { + containerRef.current?.measure((x, y, width) => { + setPageWidth(width - 24); + }); + }, []); + + const skeletonItem = useCallback(() => { + return ( + <> + + + + + + + + + + ); + }, [pageWidth]); + + return ( + + + + ); +} + +MergeExpensesSkeleton.displayName = 'MergeExpensesSkeleton'; + +export default MergeExpensesSkeleton; diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index 0f7bbb208fef..23747f77d8fb 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -4,6 +4,7 @@ import type {ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import Checkbox from '@components/Checkbox'; import type {TransactionWithOptionalHighlight} from '@components/MoneyRequestReportView/MoneyRequestReportTransactionList'; +import RadioButton from '@components/RadioButton'; import type {TableColumnSize} from '@components/Search/types'; import ActionCell from '@components/SelectionList/Search/ActionCell'; import DateCell from '@components/SelectionList/Search/DateCell'; @@ -86,14 +87,18 @@ type TransactionItemRowProps = { dateColumnSize: TableColumnSize; amountColumnSize: TableColumnSize; taxAmountColumnSize: TableColumnSize; - onCheckboxPress: (transactionID: string) => void; - shouldShowCheckbox: boolean; + onCheckboxPress?: (transactionID: string) => void; + shouldShowCheckbox?: boolean; columns?: Array>; onButtonPress?: () => void; columnWrapperStyles?: ViewStyle[]; isReportItemChild?: boolean; isActionLoading?: boolean; isInSingleTransactionReport?: boolean; + shouldShowRadioButton?: boolean; + onRadioButtonPress?: (transactionID: string) => void; + shouldShowErrors?: boolean; + shouldHighlightItemWhenSelected?: boolean; isDisabled?: boolean; }; @@ -119,7 +124,7 @@ function TransactionItemRow({ dateColumnSize, amountColumnSize, taxAmountColumnSize, - onCheckboxPress, + onCheckboxPress = () => {}, shouldShowCheckbox = false, columns, onButtonPress = () => {}, @@ -127,6 +132,10 @@ function TransactionItemRow({ isReportItemChild = false, isActionLoading, isInSingleTransactionReport = false, + shouldShowRadioButton = false, + onRadioButtonPress = () => {}, + shouldShowErrors = true, + shouldHighlightItemWhenSelected = true, isDisabled = false, }: TransactionItemRowProps) { const styles = useThemeStyles(); @@ -141,11 +150,11 @@ function TransactionItemRow({ const isTaxAmountColumnWide = taxAmountColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; const bgActiveStyles = useMemo(() => { - if (!isSelected) { + if (!isSelected || !shouldHighlightItemWhenSelected) { return []; } return styles.activeComponentBG; - }, [isSelected, styles.activeComponentBG]); + }, [isSelected, styles.activeComponentBG, shouldHighlightItemWhenSelected]); const merchant = useMemo(() => getMerchantName(transactionItem, translate), [transactionItem, translate]); const description = getDescription(transactionItem); @@ -426,6 +435,17 @@ function TransactionItemRow({ )} + {shouldShowRadioButton && ( + + onRadioButtonPress?.(transactionItem.transactionID)} + accessibilityLabel={CONST.ROLE.RADIO} + shouldUseNewStyle + /> + + )} @@ -443,12 +463,14 @@ function TransactionItemRow({ /> )} - + {shouldShowErrors && ( + + )} {shouldRenderChatBubbleCell && ( - { - onCheckboxPress(transactionItem.transactionID); - }} - accessibilityLabel={CONST.ROLE.CHECKBOX} - isChecked={isSelected} - style={styles.mr1} - wrapperStyle={styles.justifyContentCenter} - /> + {!shouldShowRadioButton && ( + { + onCheckboxPress(transactionItem.transactionID); + }} + accessibilityLabel={CONST.ROLE.CHECKBOX} + isChecked={isSelected} + style={styles.mr1} + wrapperStyle={styles.justifyContentCenter} + /> + )} {columns?.map((column) => columnComponent[column])} + {shouldShowRadioButton && ( + + onRadioButtonPress?.(transactionItem.transactionID)} + accessibilityLabel={CONST.ROLE.RADIO} + shouldUseNewStyle + /> + + )} - + {shouldShowErrors && ( + + )} ); } diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index f5b13602836c..eac0599fbe76 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -3,10 +3,12 @@ import * as Expensicons from '@components/Icon/Expensicons'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchContext} from '@components/Search/SearchContext'; import {deleteMoneyRequest, unholdRequest} from '@libs/actions/IOU'; +import {setupMergeTransactionData} from '@libs/actions/MergeTransaction'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {exportReportToCSV} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID, getOriginalMessage, isDeletedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {isMergeAction} from '@libs/ReportSecondaryActionUtils'; import { canDeleteCardTransactionByLiabilityType, canDeleteTransaction, @@ -21,7 +23,7 @@ import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {OriginalMessageIOU, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; +import type {OriginalMessageIOU, Policy, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; @@ -31,6 +33,7 @@ import useReportIsArchived from './useReportIsArchived'; const HOLD = 'HOLD'; const UNHOLD = 'UNHOLD'; const MOVE = 'MOVE'; +const MERGE = 'MERGE'; function useSelectedTransactionsActions({ report, @@ -38,6 +41,7 @@ function useSelectedTransactionsActions({ allTransactionsLength, session, onExportFailed, + policy, beginExportWithTemplate, }: { report?: Report; @@ -45,6 +49,7 @@ function useSelectedTransactionsActions({ allTransactionsLength: number; session?: Session; onExportFailed?: () => void; + policy?: Policy; beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[]) => void; }) { const {selectedTransactionIDs, clearSelectedTransactions} = useSearchContext(); @@ -257,6 +262,26 @@ function useSelectedTransactionsActions({ }); } + // In phase 1, we only show merge action if report is eligible for merge and only one transaction is selected + const canMergeTransaction = selectedTransactions.length === 1 && report && isMergeAction(report, selectedTransactions, policy); + if (canMergeTransaction) { + options.push({ + text: translate('common.merge'), + icon: Expensicons.ArrowCollapse, + value: MERGE, + onSelected: () => { + const targetTransaction = selectedTransactions.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 = Object.values(selectedTransactions).every((transaction) => { const canRemoveTransaction = canDeleteCardTransactionByLiabilityType(transaction); const action = getIOUActionForTransactionID(reportActions, transaction.transactionID); @@ -290,6 +315,7 @@ function useSelectedTransactionsActions({ iouType, session?.accountID, showDeleteModal, + policy, beginExportWithTemplate, integrationsExportTemplates, ]); diff --git a/src/languages/de.ts b/src/languages/de.ts index ed6c1f598675..a59e44c3a5de 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -633,6 +633,7 @@ const translations = { getTheApp: 'Hole dir die App', scanReceiptsOnTheGo: 'Scannen Sie Belege von Ihrem Telefon aus', headsUp: 'Achtung!', + merge: 'Zusammenführen', unstableInternetConnection: 'Instabile Internetverbindung. Bitte überprüfe dein Netzwerk und versuche es erneut.', }, supportalNoAccess: { @@ -1357,6 +1358,31 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `Übermittelt an ${name}`, moveExpenses: () => ({one: 'Ausgabe verschieben', other: 'Ausgaben verschieben'}), }, + transactionMerge: { + listPage: { + header: 'Ausgaben zusammenführen', + noEligibleExpenseFound: 'Keine geeigneten Ausgaben gefunden', + noEligibleExpenseFoundSubtitle: `Du hast keine Ausgaben, die mit dieser zusammengeführt werden können. Erfahre mehr über geeignete Ausgaben.`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `Wähle eine geeignete Ausgabe zum Zusammenführen ${reportName}.`, + }, + receiptPage: { + header: 'Beleg auswählen', + pageTitle: 'Wähle den Beleg, den du behalten möchtest:', + }, + detailsPage: { + header: 'Details auswählen', + pageTitle: 'Wähle die Details, die du behalten möchtest:', + noDifferences: 'Keine Unterschiede zwischen den Transaktionen gefunden', + pleaseSelectError: ({field}: {field: string}) => `Bitte wähle ein(e) ${field}`, + selectAllDetailsError: 'Wähle alle Details, bevor du fortfährst.', + }, + confirmationPage: { + header: 'Details bestätigen', + pageTitle: 'Bestätige die Details, die du behalten möchtest. Die nicht behaltenen Details werden gelöscht.', + confirmButton: 'Ausgaben zusammenführen', + }, + }, share: { shareToExpensify: 'Teilen mit Expensify', messageInputLabel: 'Nachricht', diff --git a/src/languages/en.ts b/src/languages/en.ts index ef7b9e4908b5..68782c797b07 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1,6 +1,7 @@ import {CONST as COMMON_CONST} from 'expensify-common'; import startCase from 'lodash/startCase'; import type {OnboardingCompanySize, OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; @@ -623,6 +624,7 @@ const translations = { getTheApp: 'Get the app', scanReceiptsOnTheGo: 'Scan receipts from your phone', headsUp: 'Heads up!', + merge: 'Merge', unstableInternetConnection: 'Unstable internet connection. Please check your network and try again.', }, supportalNoAccess: { @@ -1340,6 +1342,34 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `Submits to ${name}`, moveExpenses: () => ({one: 'Move expense', other: 'Move expenses'}), }, + transactionMerge: { + listPage: { + header: 'Merge expenses', + noEligibleExpenseFound: 'No eligible expenses found', + noEligibleExpenseFoundSubtitle: `You don't have any expenses that can be merged with this one. Learn more about eligible expenses.`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `Select an eligible expense to merge with ${reportName}.`, + }, + receiptPage: { + header: 'Select receipt', + pageTitle: 'Select the receipt you want to keep:', + }, + detailsPage: { + header: 'Select details', + pageTitle: 'Select the details you want to keep:', + noDifferences: 'No differences found between the transactions', + pleaseSelectError: ({field}: {field: string}) => { + const article = StringUtils.startsWithVowel(field) ? 'an' : 'a'; + return `Please select ${article} ${field}`; + }, + selectAllDetailsError: 'Select all details before continuing.', + }, + confirmationPage: { + header: 'Confirm details', + pageTitle: "Confirm the details you're keeping. The details you don't keep will be deleted.", + confirmButton: 'Merge expenses', + }, + }, share: { shareToExpensify: 'Share to Expensify', messageInputLabel: 'Message', diff --git a/src/languages/es.ts b/src/languages/es.ts index e8f9f1bf89eb..1dcb88e64df5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -614,6 +614,7 @@ const translations = { getTheApp: 'Descarga la app', scanReceiptsOnTheGo: 'Escanea recibos desde tu teléfono', headsUp: '¡Atención!', + merge: 'Fusionar', unstableInternetConnection: 'Conexión a internet inestable. Por favor, revisa tu red e inténtalo de nuevo.', }, supportalNoAccess: { @@ -1338,6 +1339,31 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `Se envía a ${name}`, moveExpenses: () => ({one: 'Mover gasto', other: 'Mover gastos'}), }, + transactionMerge: { + listPage: { + header: 'Fusionar gastos', + noEligibleExpenseFound: 'No se encontraron gastos válidos', + noEligibleExpenseFoundSubtitle: `No tienes ningún gasto que pueda fusionarse con éste. Obtén más información sobre gastos válidos.`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `Selecciona un gasto válido con el que fusionar ${reportName}.`, + }, + receiptPage: { + header: 'Selecciona el comprobante', + pageTitle: 'Selecciona el comprobante que deseas conservar:', + }, + detailsPage: { + header: 'Selecciona los detalles', + pageTitle: 'Selecciona los detalles que deseas conservar:', + noDifferences: 'No se encontraron diferencias entre las transacciones', + pleaseSelectError: ({field}: {field: string}) => `Por favor, selecciona un(a) ${field}`, + selectAllDetailsError: 'Selecciona todos los detalles antes de continuar.', + }, + confirmationPage: { + header: 'Confirma los detalles', + pageTitle: 'Confirma los detalles que conservarás. Los detalles que no conserves serán eliminados.', + confirmButton: 'Fusionar gastos', + }, + }, share: { shareToExpensify: 'Compartir para Expensify', messageInputLabel: 'Mensaje', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a5fc1643ff78..8ad9fd1ce49f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -633,6 +633,7 @@ const translations = { getTheApp: "Obtenez l'application", scanReceiptsOnTheGo: 'Numérisez les reçus depuis votre téléphone', headsUp: 'Attention !', + merge: 'Fusionner', unstableInternetConnection: 'Connexion Internet instable. Veuillez vérifier votre réseau et réessayer.', }, supportalNoAccess: { @@ -1360,6 +1361,31 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `Soumet à ${name}`, moveExpenses: () => ({one: 'Déplacer la dépense', other: 'Déplacer les dépenses'}), }, + transactionMerge: { + listPage: { + header: 'Fusionner les dépenses', + noEligibleExpenseFound: 'Aucune dépense éligible trouvée', + noEligibleExpenseFoundSubtitle: `Vous n’avez aucune dépense pouvant être fusionnée avec celle-ci. En savoir plus sur les dépenses éligibles.`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `Sélectionnez une dépense éligible à fusionner ${reportName}.`, + }, + receiptPage: { + header: 'Sélectionner le reçu', + pageTitle: 'Sélectionnez le reçu à conserver :', + }, + detailsPage: { + header: 'Sélectionner les détails', + pageTitle: 'Sélectionnez les détails à conserver :', + noDifferences: 'Aucune différence trouvée entre les transactions', + pleaseSelectError: ({field}: {field: string}) => `Veuillez sélectionner un/une ${field}`, + selectAllDetailsError: 'Sélectionnez tous les détails avant de continuer.', + }, + confirmationPage: { + header: 'Confirmer les détails', + pageTitle: 'Confirmez les détails que vous gardez. Les autres seront supprimés.', + confirmButton: 'Fusionner les dépenses', + }, + }, share: { shareToExpensify: 'Partager sur Expensify', messageInputLabel: 'Message', diff --git a/src/languages/it.ts b/src/languages/it.ts index 1371ec2a2989..0d2ddb752d80 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -633,6 +633,7 @@ const translations = { getTheApp: "Scarica l'app", scanReceiptsOnTheGo: 'Scansiona le ricevute dal tuo telefono', headsUp: 'Attenzione!', + merge: 'Unisci', unstableInternetConnection: 'Connessione Internet instabile. Controlla la tua rete e riprova.', }, supportalNoAccess: { @@ -1353,6 +1354,31 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `Invia a ${name}`, moveExpenses: () => ({one: 'Sposta spesa', other: 'Sposta spese'}), }, + transactionMerge: { + listPage: { + header: 'Unisci spese', + noEligibleExpenseFound: 'Nessuna spesa idonea trovata', + noEligibleExpenseFoundSubtitle: `Non hai spese che possono essere unite a questa. Scopri di più sulle spese idonee.`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `Seleziona una spesa idonea da unire ${reportName}.`, + }, + receiptPage: { + header: 'Seleziona ricevuta', + pageTitle: 'Seleziona la ricevuta da conservare:', + }, + detailsPage: { + header: 'Seleziona dettagli', + pageTitle: 'Seleziona i dettagli da conservare:', + noDifferences: 'Nessuna differenza trovata tra le transazioni', + pleaseSelectError: ({field}: {field: string}) => `Seleziona un/a ${field}`, + selectAllDetailsError: 'Seleziona tutti i dettagli prima di continuare.', + }, + confirmationPage: { + header: 'Conferma i dettagli', + pageTitle: 'Conferma i dettagli che vuoi conservare. Quelli non mantenuti saranno eliminati.', + confirmButton: 'Unisci spese', + }, + }, share: { shareToExpensify: 'Condividi su Expensify', messageInputLabel: 'Messaggio', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index aebcef05c0a9..a2333b815b40 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -633,6 +633,7 @@ const translations = { getTheApp: 'アプリを入手', scanReceiptsOnTheGo: '携帯電話から領収書をスキャンする', headsUp: 'ご注意ください!', + merge: 'マージ', unstableInternetConnection: 'インターネット接続が不安定です。ネットワークを確認してもう一度お試しください。', }, supportalNoAccess: { @@ -1354,6 +1355,31 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `${name}に送信`, moveExpenses: () => ({one: '経費を移動', other: '経費を移動'}), }, + transactionMerge: { + listPage: { + header: '経費をマージ', + noEligibleExpenseFound: 'マージ対象となる経費が見つかりません', + noEligibleExpenseFoundSubtitle: `この経費とマージできる経費がありません。マージ可能な経費について詳しくはこちら。`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `マージ対象の経費を選択してください ${reportName}.`, + }, + receiptPage: { + header: '領収書を選択', + pageTitle: '保存する領収書を選んでください:', + }, + detailsPage: { + header: '詳細を選択', + pageTitle: '保存する詳細を選んでください:', + noDifferences: 'トランザクション間に差異はありません', + pleaseSelectError: ({field}: {field: string}) => `${field} を選択してください`, + selectAllDetailsError: '続行する前にすべての詳細を選択してください。', + }, + confirmationPage: { + header: '詳細を確認', + pageTitle: '保持する詳細を確認してください。保持しない詳細は削除されます。', + confirmButton: '経費をマージ', + }, + }, share: { shareToExpensify: 'Expensifyに共有', messageInputLabel: 'メッセージ', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d43856e80f63..51ff7d1d18d0 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -632,6 +632,7 @@ const translations = { getTheApp: 'Download de app', scanReceiptsOnTheGo: 'Scan bonnetjes vanaf je telefoon', headsUp: 'Let op!', + merge: 'Samenvoegen', unstableInternetConnection: 'Onstabiele internetverbinding. Controleer je netwerk en probeer het opnieuw.', }, supportalNoAccess: { @@ -1355,6 +1356,31 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `Dient in bij ${name}`, moveExpenses: () => ({one: 'Verplaats uitgave', other: 'Verplaats uitgaven'}), }, + transactionMerge: { + listPage: { + header: 'Kosten samenvoegen', + noEligibleExpenseFound: 'Geen in aanmerking komende kosten gevonden', + noEligibleExpenseFoundSubtitle: `Je hebt geen kosten die samengevoegd kunnen worden met deze. Meer informatie over in aanmerking komende kosten.`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `Selecteer een geschikte kost om te combineren ${reportName}.`, + }, + receiptPage: { + header: 'Ontvangstbewijs selecteren', + pageTitle: 'Kies het ontvangstbewijs dat je wilt behouden:', + }, + detailsPage: { + header: 'Details selecteren', + pageTitle: 'Selecteer de details die je wilt behouden:', + noDifferences: 'Geen verschillen gevonden tussen de transacties', + pleaseSelectError: ({field}: {field: string}) => `Selecteer een ${field}`, + selectAllDetailsError: 'Selecteer alle details voordat je doorgaat.', + }, + confirmationPage: { + header: 'Bevestig details', + pageTitle: 'Bevestig de details die je wilt bewaren. Niet bewaarde details worden verwijderd.', + confirmButton: 'Kosten samenvoegen', + }, + }, share: { shareToExpensify: 'Delen met Expensify', messageInputLabel: 'Bericht', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d8977ee43551..cf32c155c337 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -633,6 +633,7 @@ const translations = { getTheApp: 'Pobierz aplikację', scanReceiptsOnTheGo: 'Skanuj paragony za pomocą telefonu', headsUp: 'Uwaga!', + merge: 'Scal', unstableInternetConnection: 'Niestabilne połączenie internetowe. Sprawdź swoją sieć i spróbuj ponownie.', }, supportalNoAccess: { @@ -1352,6 +1353,31 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `Przesyła do ${name}`, moveExpenses: () => ({one: 'Przenieś wydatek', other: 'Przenieś wydatki'}), }, + transactionMerge: { + listPage: { + header: 'Scal wydatki', + noEligibleExpenseFound: 'Nie znaleziono kwalifikujących się wydatków', + noEligibleExpenseFoundSubtitle: `Nie masz wydatków, które można scalić z tym. Dowiedz się więcej o kwalifikujących się wydatkach.`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `Wybierz kwalifikujący się wydatek do scalenia ${reportName}.`, + }, + receiptPage: { + header: 'Wybierz paragon', + pageTitle: 'Wybierz paragon, który chcesz zachować:', + }, + detailsPage: { + header: 'Wybierz szczegóły', + pageTitle: 'Wybierz szczegóły, które chcesz zachować:', + noDifferences: 'Nie znaleziono różnic między transakcjami', + pleaseSelectError: ({field}: {field: string}) => `Proszę wybrać ${field}`, + selectAllDetailsError: 'Wybierz wszystkie szczegóły przed kontynuowaniem.', + }, + confirmationPage: { + header: 'Potwierdź szczegóły', + pageTitle: 'Potwierdź szczegóły, które zachowujesz. Niezachowane zostaną usunięte.', + confirmButton: 'Scal wydatki', + }, + }, share: { shareToExpensify: 'Udostępnij do Expensify', messageInputLabel: 'Wiadomość', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c81252be63bb..8777c3fb24ca 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -633,6 +633,7 @@ const translations = { getTheApp: 'Obtenha o aplicativo', scanReceiptsOnTheGo: 'Digitalize recibos com seu celular', headsUp: 'Atenção!', + merge: 'Mesclar', unstableInternetConnection: 'Conexão de internet instável. Verifique sua rede e tente novamente.', }, supportalNoAccess: { @@ -1353,6 +1354,31 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `Envia para ${name}`, moveExpenses: () => ({one: 'Mover despesa', other: 'Mover despesas'}), }, + transactionMerge: { + listPage: { + header: 'Mesclar despesas', + noEligibleExpenseFound: 'Nenhuma despesa elegível encontrada', + noEligibleExpenseFoundSubtitle: `Você não tem despesas que possam ser mescladas com esta. Saiba mais sobre despesas elegíveis.`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => + `Selecione uma despesa elegível para mesclar ${reportName}.`, + }, + receiptPage: { + header: 'Selecionar recibo', + pageTitle: 'Selecione o recibo que deseja manter:', + }, + detailsPage: { + header: 'Selecionar detalhes', + pageTitle: 'Selecione os detalhes que deseja manter:', + noDifferences: 'Nenhuma diferença encontrada entre as transações', + pleaseSelectError: ({field}: {field: string}) => `Por favor selecione um(a) ${field}`, + selectAllDetailsError: 'Selecione todos os detalhes antes de continuar.', + }, + confirmationPage: { + header: 'Confirmar detalhes', + pageTitle: 'Confirme os detalhes que deseja manter. Os demais serão excluídos.', + confirmButton: 'Mesclar despesas', + }, + }, share: { shareToExpensify: 'Compartilhar no Expensify', messageInputLabel: 'Mensagem', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 799557f2ca9f..e17435e2ff07 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -632,6 +632,7 @@ const translations = { getTheApp: '获取应用程序', scanReceiptsOnTheGo: '用手机扫描收据', headsUp: '\u6CE8\u610F\uFF01', + merge: '合并', unstableInternetConnection: '互联网连接不稳定。请检查你的网络,然后重试。', }, supportalNoAccess: { @@ -1339,6 +1340,30 @@ const translations = { submitsTo: ({name}: SubmitsToParams) => `提交给${name}`, moveExpenses: () => ({one: '移动费用', other: '移动费用'}), }, + transactionMerge: { + listPage: { + header: '合并费用', + noEligibleExpenseFound: '未找到可合并的费用', + noEligibleExpenseFoundSubtitle: `您没有可与此合并的费用。了解更多关于可合并费用的信息。`, + selectTransactionToMerge: ({reportName}: {reportName: string}) => `选择一个可合并的费用 ${reportName}.`, + }, + receiptPage: { + header: '选择收据', + pageTitle: '选择您想保留的收据:', + }, + detailsPage: { + header: '选择详情', + pageTitle: '选择您想保留的详情:', + noDifferences: '发现交易无差异', + pleaseSelectError: ({field}: {field: string}) => `请选择一个${field}`, + selectAllDetailsError: '继续前请选取所有详情。', + }, + confirmationPage: { + header: '确认详情', + pageTitle: '确认您保留的详情。未保留的详情将被删除。', + confirmButton: '合并费用', + }, + }, share: { shareToExpensify: '分享到Expensify', messageInputLabel: '消息', diff --git a/src/libs/API/parameters/GetTransactionsForMergingParams.ts b/src/libs/API/parameters/GetTransactionsForMergingParams.ts new file mode 100644 index 000000000000..a8d3d2f5c7b8 --- /dev/null +++ b/src/libs/API/parameters/GetTransactionsForMergingParams.ts @@ -0,0 +1,5 @@ +type GetTransactionsForMergingParams = { + transactionID: string; +}; + +export default GetTransactionsForMergingParams; diff --git a/src/libs/API/parameters/MergeTransactionParams.ts b/src/libs/API/parameters/MergeTransactionParams.ts new file mode 100644 index 000000000000..9fb0c04ebdb1 --- /dev/null +++ b/src/libs/API/parameters/MergeTransactionParams.ts @@ -0,0 +1,33 @@ +type MergeTransactionParams = { + /** Transaction ID we're keeping */ + transactionID: string; + + /** ID of the transaction we're merging into that will be deleted */ + transactionIDList: string[]; + + /** Created date */ + created: string; + + /** Merchant which user want to keep */ + merchant: string; + + /** Category which user want to keep */ + category: string; + + /** Tag which user want to keep */ + tag: string; + + /** Description which user want to keep */ + comment: string; + + /** Whether the transaction is reimbursable */ + reimbursable: boolean; + + /** Whether the transaction is billable */ + billable: boolean; + + /** The receiptID we want to keep */ + receiptID: number | undefined; +}; + +export default MergeTransactionParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 4626c848eec0..45c99e9565dd 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -1,3 +1,4 @@ +export type {default as GetTransactionsForMergingParams} from './GetTransactionsForMergingParams'; export type {default as ImportMultiLevelTagsParams} from './ImportMultiLevelTagsParams'; export type {default as CleanPolicyTagsParams} from './CleanPolicyTagsParams'; export type {default as ActivatePhysicalExpensifyCardParams} from './ActivatePhysicalExpensifyCardParams'; @@ -258,6 +259,7 @@ export type {default as SendInvoiceParams} from './SendInvoiceParams'; export type {default as PayInvoiceParams} from './PayInvoiceParams'; export type {default as MarkAsCashParams} from './MarkAsCashParams'; export type {default as MergeDuplicatesParams} from './MergeDuplicatesParams'; +export type {default as MergeTransactionParams} from './MergeTransactionParams'; export type {default as ResolveDuplicatesParams} from './ResolveDuplicatesParams'; export type {default as UpdateSubscriptionTypeParams} from './UpdateSubscriptionTypeParams'; export type {default as SignUpUserParams} from './SignUpUserParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 20739d73c738..f807bbb61887 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -327,6 +327,7 @@ const WRITE_COMMANDS = { MARK_AS_CASH: 'MarkAsCash', MERGE_DUPLICATES: 'MergeDuplicates', RESOLVE_DUPLICATES: 'ResolveDuplicates', + MERGE_TRANSACTION: 'Transaction_Merge', UPDATE_SUBSCRIPTION_TYPE: 'UpdateSubscriptionType', SIGN_UP_USER: 'SignUpUser', UPDATE_SUBSCRIPTION_AUTO_RENEW: 'UpdateSubscriptionAutoRenew', @@ -832,6 +833,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams; [WRITE_COMMANDS.MERGE_DUPLICATES]: Parameters.MergeDuplicatesParams; [WRITE_COMMANDS.RESOLVE_DUPLICATES]: Parameters.ResolveDuplicatesParams; + [WRITE_COMMANDS.MERGE_TRANSACTION]: Parameters.MergeTransactionParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_TYPE]: Parameters.UpdateSubscriptionTypeParams; [WRITE_COMMANDS.SIGN_UP_USER]: Parameters.SignUpUserParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_AUTO_RENEW]: Parameters.UpdateSubscriptionAutoRenewParams; @@ -1081,6 +1083,7 @@ const READ_COMMANDS = { CALCULATE_BILL_NEW_DOT: 'CalculateBillNewDot', OPEN_UNREPORTED_EXPENSES_PAGE: 'OpenUnreportedExpensesPage', GET_GUIDE_CALL_AVAILABILITY_SCHEDULE: 'GetGuideCallAvailabilitySchedule', + GET_TRANSACTIONS_FOR_MERGING: 'GetTransactionsForMerging', } as const; type ReadCommand = ValueOf; @@ -1157,6 +1160,7 @@ type ReadCommandParameters = { [READ_COMMANDS.CALCULATE_BILL_NEW_DOT]: null; [READ_COMMANDS.OPEN_UNREPORTED_EXPENSES_PAGE]: Parameters.OpenUnreportedExpensesPageParams; [READ_COMMANDS.GET_GUIDE_CALL_AVAILABILITY_SCHEDULE]: Parameters.GetGuideCallAvailabilityScheduleParams; + [READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING]: Parameters.GetTransactionsForMergingParams; }; const SIDE_EFFECT_REQUEST_COMMANDS = { diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts new file mode 100644 index 000000000000..a2ab3b302747 --- /dev/null +++ b/src/libs/MergeTransactionUtils.ts @@ -0,0 +1,283 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {TupleToUnion} from 'type-fest'; +import CONST from '@src/CONST'; +import type {MergeTransaction, Transaction} from '@src/types/onyx'; +import {getIOUActionForReportID} from './ReportActionsUtils'; +import {findSelfDMReportID} from './ReportUtils'; +import {getAmount, getBillable, getCategory, getCurrency, getDescription, getMerchant, getReimbursable, getTag, isCardTransaction} from './TransactionUtils'; + +const RECEIPT_SOURCE_URL = 'https://www.expensify.com/receipts/'; + +// Define the specific merge fields we want to handle +const MERGE_FIELDS = ['amount', 'currency', 'merchant', 'category', 'tag', 'description', 'reimbursable', 'billable'] as const; +type MergeFieldKey = TupleToUnion; +type MergeValueType = string | number | boolean; +type MergeValue = { + value: MergeValueType; + currency?: string; +}; + +const MERGE_FIELDS_UTILS = { + amount: { + translationKey: 'iou.amount', + getDataFn: (transaction: Transaction, isFromExpenseReport: boolean) => getAmount(transaction, isFromExpenseReport), + }, + currency: { + translationKey: 'iou.currency', + getDataFn: getCurrency, + }, + merchant: { + translationKey: 'common.merchant', + getDataFn: getMerchant, + }, + category: { + translationKey: 'common.category', + getDataFn: getCategory, + }, + tag: { + translationKey: 'common.tag', + getDataFn: getTag, + }, + description: { + translationKey: 'common.description', + getDataFn: getDescription, + }, + reimbursable: { + translationKey: 'common.reimbursable', + getDataFn: getReimbursable, + }, + billable: { + translationKey: 'common.billable', + getDataFn: getBillable, + }, +}; + +/** + * Fills the receipt.source for a transaction if it's missing + * Workaround while wait BE to fix the receipt.source + * @param transaction - The transaction to update the receipt source for + * @returns The updated transaction with receipt.source filled if it was missing + */ +function fillMissingReceiptSource(transaction: Transaction) { + // If receipt.source already exists, no need to modify + if (!transaction.receipt || !!transaction.receipt?.source || !transaction.filename) { + return transaction; + } + + return { + ...transaction, + receipt: { + ...transaction.receipt, + source: `${RECEIPT_SOURCE_URL}${transaction.filename}`, + }, + }; +} + +const getTransactionFromMergeTransaction = (mergeTransaction: OnyxEntry, transactionID: string) => { + if (!mergeTransaction?.eligibleTransactions) { + return undefined; + } + const transaction = mergeTransaction.eligibleTransactions.find((eligibleTransaction) => eligibleTransaction.transactionID === transactionID); + return transaction ? fillMissingReceiptSource(transaction) : transaction; +}; + +/** + * Get the source transaction from a merge transaction + * @param mergeTransaction - The merge transaction to get the source transaction from + * @returns The source transaction or null if it doesn't exist + */ +const getSourceTransactionFromMergeTransaction = (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 + * @returns True if both transactions have a receipt + */ +function shouldNavigateToReceiptReview(transactions: Array>): boolean { + return transactions.every((transaction) => transaction?.receipt?.receiptID); +} + +// Check if whether merge value is truly "empty" (null, undefined, or empty string) +// For boolean fields, false is a valid value, not an empty value +function isEmptyMergeValue(value: unknown) { + return value === null || value === undefined || value === ''; +} + +/** + * Get the value of a specific merge field from a transaction + * @param transaction - The transaction to extract the field value from + * @param field - The merge field key to get the value for + * @returns The value of the specified field from the transaction + */ +function getMergeFieldValue(transaction: OnyxEntry, field: MergeFieldKey) { + if (!transaction) { + return ''; + } + + // Handle amount field separately as it requires the second parameter + if (field === 'amount') { + const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + return MERGE_FIELDS_UTILS[field].getDataFn(transaction, !isUnreportedExpense); + } + + return MERGE_FIELDS_UTILS[field].getDataFn(transaction); +} + +/** + * Get the translation key for a specific merge field + * @param field - The merge field key to get the translation key for + * @returns The translation key string for the specified field + */ +function getMergeFieldTranslationKey(field: MergeFieldKey) { + return MERGE_FIELDS_UTILS[field].translationKey; +} + +/** + * 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 + * @returns mergeableData and conflictFields + */ +function getMergeableDataAndConflictFields(targetTransaction: OnyxEntry, sourceTransaction: OnyxEntry) { + const conflictFields: string[] = []; + const mergeableData: Record = {}; + + MERGE_FIELDS.forEach((field) => { + // Currency field is handled by the amount field + if (field === 'currency') { + return; + } + + const targetValue = getMergeFieldValue(targetTransaction, field); + const sourceValue = getMergeFieldValue(sourceTransaction, field); + + const isTargetValueEmpty = isEmptyMergeValue(targetValue); + const isSourceValueEmpty = isEmptyMergeValue(sourceValue); + + if (isTargetValueEmpty || isSourceValueEmpty || targetValue === sourceValue) { + if (field === 'amount' && getMergeFieldValue(targetTransaction, 'currency') !== getMergeFieldValue(sourceTransaction, 'currency')) { + conflictFields.push('amount'); + } else { + mergeableData[field] = isTargetValueEmpty ? sourceValue : targetValue; + } + } else { + conflictFields.push(field); + } + }); + + return {mergeableData, conflictFields}; +} + +/** + * Get the report ID for an expense, if it's unreported, we'll return the self DM report ID + */ +function getReportIDForExpense(transaction: OnyxEntry) { + if (!transaction) { + return undefined; + } + + const isUnreportedExpense = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + + if (isUnreportedExpense) { + return findSelfDMReportID(); + } + + return transaction.reportID; +} + +/** + * Get the report ID for the transaction thread of a transaction + * @param transaction - The transaction to get the report ID for + * @returns The report ID for the transaction thread + */ +function getTransactionThreadReportID(transaction: OnyxEntry) { + const iouActionOfTargetTransaction = getIOUActionForReportID(getReportIDForExpense(transaction), transaction?.transactionID); + return iouActionOfTargetTransaction?.childReportID; +} + +/** + * Build the merged transaction data for display by combining target transaction with merge transaction updates + * @param targetTransaction - The target transaction to merge into + * @param mergeTransaction - The merge transaction containing the updates + * @returns The merged transaction data or null if required data is missing + */ +function buildMergedTransactionData(targetTransaction: OnyxEntry, mergeTransaction: OnyxEntry): Transaction | null { + if (!targetTransaction || !mergeTransaction) { + return null; + } + + return { + ...targetTransaction, + amount: mergeTransaction.amount, + modifiedAmount: mergeTransaction.amount, + modifiedCurrency: mergeTransaction.currency, + merchant: mergeTransaction.merchant, + modifiedMerchant: mergeTransaction.merchant, + category: mergeTransaction.category, + tag: mergeTransaction.tag, + comment: { + ...targetTransaction.comment, + comment: mergeTransaction.description, + }, + reimbursable: mergeTransaction.reimbursable, + billable: mergeTransaction.billable, + filename: mergeTransaction.receipt?.source?.split('/').pop(), + receipt: mergeTransaction.receipt, + }; +} + +/** + * Determines the correct target and source transaction IDs for merging based on transaction types. + * + * Rules: + * - If one transaction is a card transaction, it becomes the target (card transactions take priority) + * - If both are cash transactions, the first parameter becomes the target + * - Users can only merge two cash expenses or one cash/one card expense + * - Users cannot merge two card expenses + * + * @param targetTransaction - The first transaction in the merge operation + * @param sourceTransaction - The second transaction in the merge operation + * @returns An object containing the determined targetTransactionID and sourceTransactionID + */ +function selectTargetAndSourceTransactionIDsForMerge(originalTargetTransaction: OnyxEntry, originalSourceTransaction: OnyxEntry) { + if (isCardTransaction(originalSourceTransaction)) { + return {targetTransactionID: originalSourceTransaction?.transactionID, sourceTransactionID: originalTargetTransaction?.transactionID}; + } + + return {targetTransactionID: originalTargetTransaction?.transactionID, sourceTransactionID: originalSourceTransaction?.transactionID}; +} + +export { + getSourceTransactionFromMergeTransaction, + getTargetTransactionFromMergeTransaction, + shouldNavigateToReceiptReview, + getMergeableDataAndConflictFields, + getMergeFieldValue, + getMergeFieldTranslationKey, + buildMergedTransactionData, + selectTargetAndSourceTransactionIDsForMerge, + isEmptyMergeValue, + fillMissingReceiptSource, + getTransactionThreadReportID, +}; + +export type {MergeFieldKey, MergeValueType, MergeValue}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 58e1ae6a08b9..b0fcb276f589 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -11,6 +11,7 @@ import type { EditRequestNavigatorParamList, EnablePaymentsNavigatorParamList, FlagCommentNavigatorParamList, + MergeTransactionNavigatorParamList, MissingPersonalDetailsParamList, MoneyRequestNavigatorParamList, NewChatNavigatorParamList, @@ -716,6 +717,13 @@ const TransactionDuplicateStackNavigator = createModalStackNavigator require('../../../../pages/TransactionDuplicate/Confirmation').default, }); +const MergeTransactionStackNavigator = createModalStackNavigator({ + [SCREENS.MERGE_TRANSACTION.LIST_PAGE]: () => require('../../../../pages/TransactionMerge/MergeTransactionsListPage').default, + [SCREENS.MERGE_TRANSACTION.RECEIPT_PAGE]: () => require('../../../../pages/TransactionMerge/ReceiptReviewPage').default, + [SCREENS.MERGE_TRANSACTION.DETAILS_PAGE]: () => require('../../../../pages/TransactionMerge/DetailsReviewPage').default, + [SCREENS.MERGE_TRANSACTION.CONFIRMATION_PAGE]: () => require('../../../../pages/TransactionMerge/ConfirmationPage').default, +}); + const SearchReportModalStackNavigator = createModalStackNavigator( { [SCREENS.SEARCH.REPORT_RHP]: () => require('../../../../pages/home/ReportScreen').default, @@ -840,4 +848,5 @@ export { ConsoleModalStackNavigator, AddUnreportedExpenseModalStackNavigator, ScheduleCallModalStackNavigator, + MergeTransactionStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 03752df588dc..8267dd03385f 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -197,6 +197,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE} component={ModalStackNavigators.TransactionDuplicateStackNavigator} /> + ['config'] = { }, }, }, + [SCREENS.RIGHT_MODAL.MERGE_TRANSACTION]: { + screens: { + [SCREENS.MERGE_TRANSACTION.LIST_PAGE]: ROUTES.MERGE_TRANSACTION_LIST_PAGE.route, + [SCREENS.MERGE_TRANSACTION.RECEIPT_PAGE]: ROUTES.MERGE_TRANSACTION_RECEIPT_PAGE.route, + [SCREENS.MERGE_TRANSACTION.DETAILS_PAGE]: ROUTES.MERGE_TRANSACTION_DETAILS_PAGE.route, + [SCREENS.MERGE_TRANSACTION.CONFIRMATION_PAGE]: ROUTES.MERGE_TRANSACTION_CONFIRMATION_PAGE.route, + }, + }, [SCREENS.RIGHT_MODAL.SPLIT_DETAILS]: { screens: { [SCREENS.SPLIT_DETAILS.ROOT]: ROUTES.SPLIT_BILL_DETAILS.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ccd16ad6b5b6..9eff582798be 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1716,6 +1716,25 @@ type TransactionDuplicateNavigatorParamList = { }; }; +type MergeTransactionNavigatorParamList = { + [SCREENS.MERGE_TRANSACTION.LIST_PAGE]: { + transactionID: string; + backTo?: Routes; + }; + [SCREENS.MERGE_TRANSACTION.RECEIPT_PAGE]: { + transactionID: string; + backTo?: Routes; + }; + [SCREENS.MERGE_TRANSACTION.DETAILS_PAGE]: { + transactionID: string; + backTo?: Routes; + }; + [SCREENS.MERGE_TRANSACTION.CONFIRMATION_PAGE]: { + transactionID: string; + backTo?: Routes; + }; +}; + type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SETTINGS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TWO_FACTOR_AUTH]: NavigatorScreenParams; @@ -1760,6 +1779,7 @@ type RightModalNavigatorParamList = { [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.ADD_UNREPORTED_EXPENSE]: NavigatorScreenParams<{reportId: string | undefined}>; [SCREENS.RIGHT_MODAL.SCHEDULE_CALL]: NavigatorScreenParams; + [SCREENS.RIGHT_MODAL.MERGE_TRANSACTION]: NavigatorScreenParams; }; type TravelNavigatorParamList = { @@ -2102,6 +2122,7 @@ type AuthScreensParamList = SharedScreensParamList & { isFromReviewDuplicates?: string; action?: IOUAction; iouType?: IOUType; + mergeTransactionID?: string; }; [SCREENS.CONNECTION_COMPLETE]: undefined; [NAVIGATORS.SHARE_MODAL_NAVIGATOR]: NavigatorScreenParams; @@ -2336,4 +2357,5 @@ export type { SetParamsAction, WorkspacesTabNavigatorName, TestToolsModalModalNavigatorParamList, + MergeTransactionNavigatorParamList, }; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 1ea23262e4d4..a3f428340577 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -34,6 +34,7 @@ import { isHoldCreator, isInvoiceReport as isInvoiceReportUtils, isIOUReport as isIOUReportUtils, + isMoneyRequestReportEligibleForMerge, isOpenReport as isOpenReportUtils, isPayer as isPayerUtils, isProcessingReport as isProcessingReportUtils, @@ -524,6 +525,30 @@ function isReopenAction(report: Report, policy?: Policy): boolean { return true; } +/** + * 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) { + return false; + } + + const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isReceiptBeingScanned(transaction)); + + if (isAnyReceiptBeingScanned) { + return false; + } + + if (isSelfDMReportUtils(parentReport)) { + return true; + } + + const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; + + return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin); +} + function isRemoveHoldAction(report: Report, chatReport: OnyxEntry, reportTransactions: Transaction[], reportActions?: ReportAction[], policy?: Policy): boolean { const isReportOnHold = reportTransactions.some(isOnHoldTransactionUtils); @@ -629,6 +654,10 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.SPLIT); } + if (isMergeAction(report, reportTransactions, policy)) { + options.push(CONST.REPORT.SECONDARY_ACTIONS.MERGE); + } + options.push(CONST.REPORT.SECONDARY_ACTIONS.EXPORT); options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF); @@ -693,6 +722,10 @@ function getSecondaryTransactionThreadActions( options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SPLIT); } + if (isMergeAction(parentReport, [reportTransaction], policy)) { + options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MERGE); + } + options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS); if (isDeleteAction(parentReport, [reportTransaction], reportActions ?? [])) { @@ -701,4 +734,4 @@ function getSecondaryTransactionThreadActions( return options; } -export {getSecondaryReportActions, getSecondaryTransactionThreadActions, isDeleteAction, getSecondaryExportReportActions, isSplitAction}; +export {getSecondaryReportActions, getSecondaryTransactionThreadActions, isDeleteAction, isMergeAction, getSecondaryExportReportActions, isSplitAction}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3474b4f80373..ff6eaaa2d118 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2500,6 +2500,39 @@ function canDeleteTransaction(moneyRequestReport: OnyxEntry, isReportArc return canAddOrDeleteTransactions(moneyRequestReport, isReportArchived); } +/** + * Determines whether a money request report is eligible for merging transactions based on the user's role and permissions. + * Rules: + * - **Admins**: reports that are in "Open" or "Processing" status + * - **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 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); + + if (!isMoneyRequestReport(report)) { + return false; + } + + const isManager = isReportManager(report); + const isSubmitter = isReportOwner(report); + + if (isAdmin) { + return isOpenReport(report) || isProcessingReport(report); + } + + if (isSubmitter) { + return isOpenReport(report) || (isIOUReport(report) && isProcessingReport(report)) || isAwaitingFirstLevelApproval(report); + } + + return isManager && isExpenseReport(report) && isProcessingReport(report); +} + /** * Checks whether the card transaction support deleting based on liability type */ @@ -11670,6 +11703,7 @@ export { getNextApproverAccountID, isWorkspaceTaskReport, isWorkspaceThread, + isMoneyRequestReportEligibleForMerge, getReportStatusTranslation, }; diff --git a/src/libs/StringUtils/index.ts b/src/libs/StringUtils/index.ts index 6c9be0d3dab0..85cf14f313a7 100644 --- a/src/libs/StringUtils/index.ts +++ b/src/libs/StringUtils/index.ts @@ -173,6 +173,15 @@ function countWhiteSpaces(str: string): number { return (str.match(/\s/g) ?? []).length; } +/** + * Check if the string starts with a vowel + * @param str - The input string + * @returns True if the string starts with a vowel, false otherwise + */ +function startsWithVowel(str: string): boolean { + return /^[aeiouAEIOU]/.test(str); +} + export default { sanitizeString, isEmptyString, @@ -190,4 +199,5 @@ export default { getUTF8ByteLength, decodeUnicode, countWhiteSpaces, + startsWithVowel, }; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 992635e27df1..b6b6583cf890 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -943,6 +943,10 @@ function getTransactionViolations(transaction: OnyxEntry !isViolationDismissed(transaction, violation)); } +function getTransactionViolationsOfTransaction(transactionID: string) { + return allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; +} + /** * Check if there is pending rter violation in transactionViolations. */ @@ -1975,6 +1979,7 @@ export { isDemoTransaction, shouldShowViolation, isUnreportedAndHasInvalidDistanceRateTransaction, + getTransactionViolationsOfTransaction, }; export type {TransactionChanges}; diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts new file mode 100644 index 000000000000..ac2799962bc2 --- /dev/null +++ b/src/libs/actions/MergeTransaction.ts @@ -0,0 +1,222 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {GetTransactionsForMergingParams} from '@libs/API/parameters'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {getReportOrDraftReport, getReportTransactions, isMoneyRequestReportEligibleForMerge, isReportManager} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import {getTransactionViolationsOfTransaction, isCardTransaction} from '@src/libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MergeTransaction, Policy, Report, Transaction} from '@src/types/onyx'; + +/** + * Setup merge transaction data for merging flow + */ +function setupMergeTransactionData(transactionID: string, values: Partial) { + Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values); +} + +/** + * Sets merge transaction data for a specific transaction + */ +function setMergeTransactionKey(transactionID: string, values: Partial) { + Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values); +} + +/** + * Fetches eligible transactions for merging + */ +function getTransactionsForMerging(transactionID: string) { + const parameters: GetTransactionsForMergingParams = { + transactionID, + }; + + API.read(READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING, parameters); +} + +function areTransactionsEligibleForMerge(transaction1: Transaction, transaction2: Transaction) { + // Do not allow merging two card transactions + return !isCardTransaction(transaction1) || !isCardTransaction(transaction2); +} + +/** + * Fetches eligible transactions for merging locally + * This is FE version of READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call + */ + +function getTransactionsForMergingLocally( + targetTransactionID: string, + transactions: OnyxCollection, + policy: OnyxEntry, + report: OnyxEntry, + currentUserLogin: string | undefined, +) { + const transactionsArray = Object.values(transactions ?? {}); + const targetTransaction = transactionsArray.find((transaction) => transaction?.transactionID === targetTransactionID); + const isAdmin = isPolicyAdmin(policy, currentUserLogin); + const isManager = isReportManager(report); + + let eligibleTransactions: Transaction[] = []; + + // In phase 1: + // for managers and admins: we have decided to only return transaction from the target transaction report; + // for submitter: can see all transactions that you're also a submitter + if (!targetTransaction) { + eligibleTransactions = []; + } else if (isAdmin || isManager) { + const reportTransactions = getReportTransactions(report?.reportID); + eligibleTransactions = reportTransactions.filter((transaction): transaction is Transaction => { + if (!transaction || transaction.transactionID === targetTransactionID) { + return false; + } + return areTransactionsEligibleForMerge(targetTransaction, transaction); + }); + } else { + eligibleTransactions = transactionsArray.filter((transaction): transaction is Transaction => { + if (!transaction || transaction.transactionID === targetTransactionID) { + return false; + } + + const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + return ( + areTransactionsEligibleForMerge(targetTransaction, transaction) && + (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, false))) + ); + }); + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransactionID}`, { + eligibleTransactions, + }); +} + +/** + * Merges two transactions by updating the target transaction with selected fields and deleting the source transaction + */ +function mergeTransactionRequest(mergeTransactionID: string, mergeTransaction: MergeTransaction, targetTransaction: Transaction, sourceTransaction: Transaction) { + const isUnreportedExpense = !targetTransaction.reportID || targetTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + + // If the target transaction we're keeping is unreported, the amount needs to be positive. Otherwise for expense reports it needs to be the opposite sign. + const finalAmount = isUnreportedExpense ? Math.abs(mergeTransaction.amount) : -mergeTransaction.amount; + + // Call the merge transaction action + const params = { + transactionID: mergeTransaction.targetTransactionID, + transactionIDList: [mergeTransaction.sourceTransactionID], + created: targetTransaction.created, + merchant: mergeTransaction.merchant, + amount: finalAmount, + currency: mergeTransaction.currency, + category: mergeTransaction.category, + comment: mergeTransaction.description, + billable: mergeTransaction.billable, + reimbursable: mergeTransaction.reimbursable, + tag: mergeTransaction.tag, + receiptID: mergeTransaction.receipt?.receiptID, + reportID: targetTransaction.reportID, + }; + + // Optimistic update the target transaction with the new values + const optimisticTargetTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, + value: { + merchant: params.merchant, + category: params.category, + tag: params.tag, + comment: { + comment: params.comment, + }, + reimbursable: params.reimbursable, + billable: params.billable, + // Update receipt if receiptID is provided + ...(params.receiptID && { + receipt: { + source: mergeTransaction.receipt?.source ?? targetTransaction.receipt?.source, + receiptID: params.receiptID, + }, + }), + }, + }; + const failureTargetTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, + value: targetTransaction, + }; + + // Optimistic delete the source transaction and also delete its report if it was a single expense report + const optimisticSourceTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`, + value: null, + }; + const failureSourceTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`, + value: sourceTransaction, + }; + const transactionsOfSourceReport = getReportTransactions(sourceTransaction.reportID); + const optimisticSourceReportData: OnyxUpdate[] = + transactionsOfSourceReport.length === 1 + ? [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`, + value: null, + }, + ] + : []; + + const failureSourceReportData: OnyxUpdate[] = + transactionsOfSourceReport.length === 1 + ? [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`, + value: getReportOrDraftReport(sourceTransaction.reportID), + }, + ] + : []; + + // Optimistic delete the merge transaction + const optimisticMergeTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, + value: null, + }; + + // Optimistic delete duplicated transaction violations + const optimisticTransactionViolations: OnyxUpdate[] = [targetTransaction.transactionID, sourceTransaction.transactionID].map((id) => { + const violations = getTransactionViolationsOfTransaction(id); + + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`, + value: violations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION), + }; + }); + const failureTransactionViolations: OnyxUpdate[] = [targetTransaction.transactionID, sourceTransaction.transactionID].map((id) => { + const violations = getTransactionViolationsOfTransaction(id); + + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`, + value: violations, + }; + }); + + const optimisticData: OnyxUpdate[] = [ + optimisticTargetTransactionData, + optimisticSourceTransactionData, + ...optimisticSourceReportData, + optimisticMergeTransactionData, + ...optimisticTransactionViolations, + ]; + + const failureData: OnyxUpdate[] = [failureTargetTransactionData, failureSourceTransactionData, ...failureSourceReportData, ...failureTransactionViolations]; + + API.write(WRITE_COMMANDS.MERGE_TRANSACTION, params, {optimisticData, failureData}); +} + +export {setupMergeTransactionData, setMergeTransactionKey, getTransactionsForMerging, getTransactionsForMergingLocally, mergeTransactionRequest}; diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx new file mode 100644 index 000000000000..ce08116e6fbc --- /dev/null +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -0,0 +1,124 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +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'; +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 Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Transaction} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +type ConfirmationPageProps = PlatformStackScreenProps; + +function ConfirmationPage({route}: ConfirmationPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [isMergingExpenses, setIsMergingExpenses] = useState(false); + + const {transactionID, backTo} = route.params; + + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); + const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {canBeMissing: false}); + 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 targetTransactionThreadReportID = getTransactionThreadReportID(targetTransaction); + const targetTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`]; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${targetTransactionThreadReport?.policyID}`, {canBeMissing: true}); + + // 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 handleMergeExpenses = useCallback(() => { + if (!targetTransaction || !mergeTransaction || !sourceTransaction) { + return; + } + + setIsMergingExpenses(true); + mergeTransactionRequest(transactionID, mergeTransaction, targetTransaction, sourceTransaction); + Navigation.dismissModal(); + }, [targetTransaction, mergeTransaction, sourceTransaction, transactionID]); + + if (isLoadingOnyxValue(mergeTransactionMetadata) || !targetTransactionThreadReport?.reportID) { + return ; + } + + return ( + + + { + Navigation.goBack(backTo); + }} + /> + + + {translate('transactionMerge.confirmationPage.pageTitle')} + + + } + mergeTransactionID={transactionID} + /> + + + +