diff --git a/src/ROUTES.ts b/src/ROUTES.ts index dbd2cf69ae8d..001fba86e934 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -951,8 +951,8 @@ const ROUTES = { getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/tax-code` as const, }, TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE: { - route: 'r/:threadReportID/duplicates/confirm', - getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/confirm` as const, + route: 'r/:threadReportID/duplicates/review/description', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/description` as const, }, TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE: { route: 'r/:threadReportID/duplicates/review/reimbursable', @@ -962,7 +962,10 @@ const ROUTES = { route: 'r/:threadReportID/duplicates/review/billable', getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/billable` as const, }, - + TRANSACTION_DUPLICATE_CONFIRMATION_PAGE: { + route: 'r/:threadReportID/duplicates/confirm', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/confirm` as const, + }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'settings/workspaces/:policyID/accounting/xero/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 24e4ed0b1c5a..eddf11d95e4f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -199,6 +199,7 @@ const SCREENS = { TAX_CODE: 'Transaction_Duplicate_Tax_Code', REIMBURSABLE: 'Transaction_Duplicate_Reimburable', BILLABLE: 'Transaction_Duplicate_Billable', + CONFIRMATION: 'Transaction_Duplicate_Confirmation', }, IOU_SEND: { diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 6d37396c8e59..45174c3316be 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -296,7 +296,7 @@ function MoneyRequestPreviewContent({ } else if ('reimbursable' in comparisonResult.change) { Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.getRoute(route.params?.threadReportID)); } else { - // Navigation to confirm screen will be done in seperate PR + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_CONFIRMATION_PAGE.getRoute(route.params?.threadReportID)); } }; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 16d20853aa09..93f09bcfc9f8 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -78,10 +78,16 @@ type MoneyRequestViewOnyxPropsWithoutTransaction = { type MoneyRequestViewPropsWithoutTransaction = MoneyRequestViewOnyxPropsWithoutTransaction & { /** The report currently being looked at */ - report: OnyxTypes.Report; + report: 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; + + /** Updated transaction to show in duplicate transaction flow */ + updatedTransaction?: OnyxEntry; }; type MoneyRequestViewProps = MoneyRequestViewTransactionOnyxProps & MoneyRequestViewPropsWithoutTransaction; @@ -109,6 +115,8 @@ function MoneyRequestView({ transactionViolations, shouldShowAnimatedBackground, distanceRates, + readonly = false, + updatedTransaction, }: MoneyRequestViewProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -120,7 +128,7 @@ function MoneyRequestView({ selector: (chatReportValue) => chatReportValue && {reportID: chatReportValue.reportID, errorFields: chatReportValue.errorFields}, }); - const parentReportAction = parentReportActions?.[report.parentReportActionID ?? '-1']; + const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; const isTrackExpense = ReportUtils.isTrackExpenseReport(report); const {canUseViolations, canUseP2PDistanceRequests} = usePermissions(isTrackExpense ? CONST.IOU.TYPE.TRACK : undefined); const moneyRequestReport = parentReport; @@ -148,14 +156,17 @@ function MoneyRequestView({ const isInvoice = ReportUtils.isInvoiceReport(moneyRequestReport); const isPaidReport = ReportActionsUtils.isPayAction(parentReportAction); const taxRates = policy?.taxRates; - const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transactionTaxAmount, transactionCurrency); + + const formattedTaxAmount = updatedTransaction?.taxAmount + ? CurrencyUtils.convertToDisplayString(updatedTransaction?.taxAmount, transactionCurrency) + : CurrencyUtils.convertToDisplayString(transactionTaxAmount, transactionCurrency); const taxRatesDescription = taxRates?.name; - const taxRateTitle = TransactionUtils.getTaxName(policy, transaction); + const taxRateTitle = updatedTransaction ? TransactionUtils.getTaxName(policy, updatedTransaction) : TransactionUtils.getTaxName(policy, transaction); // Flags for allowing or disallowing editing an expense const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID); - const isCancelled = moneyRequestReport && moneyRequestReport.isCancelledIOU; + const isCancelled = moneyRequestReport && moneyRequestReport?.isCancelledIOU; // Used for non-restricted fields such as: description, category, tag, billable, etc. const canEdit = ReportActionsUtils.isMoneyRequestAction(parentReportAction) && ReportUtils.canEditMoneyRequest(parentReportAction); @@ -227,7 +238,7 @@ function MoneyRequestView({ if (newBillable === TransactionUtils.getBillable(transaction)) { return; } - IOU.updateMoneyRequestBillable(transaction?.transactionID ?? '-1', report?.reportID, newBillable, policy, policyTagList, policyCategories); + IOU.updateMoneyRequestBillable(transaction?.transactionID ?? '-1', report?.reportID ?? '-1', newBillable, policy, policyTagList, policyCategories); }, [transaction, report, policy, policyTagList, policyCategories], ); @@ -257,7 +268,6 @@ function MoneyRequestView({ if (hasReceipt) { receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction); } - const pendingAction = transaction?.pendingAction; const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; @@ -282,6 +292,10 @@ function MoneyRequestView({ const {isError, translationPath} = fieldChecks[field] ?? {}; + if (readonly) { + return ''; + } + // Return form errors if there are any if (hasErrors && isError && translationPath) { return translate(translationPath); @@ -295,7 +309,7 @@ function MoneyRequestView({ return ''; }, - [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, hasViolations, translate, getViolationsForField], + [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, readonly, hasErrors, hasViolations, translate, getViolationsForField], ); const distanceRequestFields = canUseP2PDistanceRequests ? ( @@ -307,7 +321,9 @@ function MoneyRequestView({ interactive={canEditDistance} shouldShowRightIcon={canEditDistance} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID))} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) + } /> {/* TODO: correct the pending field action https://github.com/Expensify/App/issues/36987 */} @@ -332,7 +348,7 @@ function MoneyRequestView({ interactive={canEditDistance} shouldShowRightIcon={canEditDistance} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID))} + onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'))} /> ); @@ -368,7 +384,7 @@ function MoneyRequestView({ tagListName: name, }, PolicyUtils.hasDependentTags(policy, policyTagList), - TransactionUtils.getTagForDisplay(transaction, index), + TransactionUtils.getTagForDisplay(updatedTransaction ?? transaction, index), ); return ( - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction?.transactionID ?? '', report.reportID)) + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction?.transactionID ?? '', report?.reportID ?? '-1')) } brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={tagError} @@ -412,7 +428,7 @@ function MoneyRequestView({ } const isCreateChatErrored = !!report?.errorFields?.createChat; - if ((isCreateChatErrored || !!report.isOptimisticReport) && parentReportAction) { + if ((isCreateChatErrored || !!report?.isOptimisticReport) && parentReportAction) { const urlToNavigateBack = IOU.cleanUpMoneyRequest(transaction.transactionID, parentReportAction, true); Navigation.goBack(urlToNavigateBack); return; @@ -428,7 +444,7 @@ function MoneyRequestView({ } } Transaction.clearError(transaction.transactionID); - ReportActions.clearAllRelatedReportActionErrors(report.reportID, parentReportAction); + ReportActions.clearAllRelatedReportActionErrors(report?.reportID ?? '-1', parentReportAction); }} > {hasReceipt && ( @@ -457,7 +473,7 @@ function MoneyRequestView({ CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', - report.reportID, + report?.reportID ?? '-1', Navigation.getActiveRouteWithoutParams(), ), ) @@ -473,9 +489,11 @@ function MoneyRequestView({ titleIcon={Expensicons.Checkmark} description={amountDescription} titleStyle={styles.textHeadlineH2} - interactive={canEditAmount} + interactive={canEditAmount && !readonly} shouldShowRightIcon={canEditAmount} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID))} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) + } brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('amount')} /> @@ -484,12 +502,12 @@ function MoneyRequestView({ - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID)) + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) } wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -503,12 +521,12 @@ function MoneyRequestView({ - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID)) + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) } wrapperStyle={[styles.taskDescriptionMenuItem]} brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -521,10 +539,12 @@ function MoneyRequestView({ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID))} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1' ?? '-1')) + } brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('date')} /> @@ -533,12 +553,12 @@ function MoneyRequestView({ - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID)) + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) } brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('category')} @@ -561,11 +581,11 @@ function MoneyRequestView({ - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID)) + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) } brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('tax')} @@ -577,11 +597,13 @@ function MoneyRequestView({ - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report.reportID)) + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1'), + ) } /> @@ -612,7 +634,7 @@ function MoneyRequestView({ accessibilityLabel={translate('common.billable')} isOn={!!transactionBillable} onToggle={saveBillable} - disabled={!canEdit} + disabled={!canEdit || readonly} /> )} @@ -625,30 +647,30 @@ MoneyRequestView.displayName = 'MoneyRequestView'; export default withOnyx({ policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? ''}`, }, policyCategories: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report.policyID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID ?? ''}`, }, policyTagList: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID ?? ''}`, }, parentReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? ''}`, }, parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '-1'}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report?.parentReportID : '-1'}`, canEvict: false, }, distanceRates: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, selector: (policy: OnyxEntry) => DistanceRequestUtils.getMileageRates(policy, true), }, })( withOnyx({ transaction: { key: ({report, parentReportActions}) => { - const parentReportAction = parentReportActions?.[report.parentReportActionID ?? '-1']; + const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; const originalMessage = parentReportAction && ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction) : undefined; const transactionID = originalMessage?.IOUTransactionID ?? -1; @@ -657,7 +679,7 @@ export default withOnyx { - const parentReportAction = parentReportActions?.[report.parentReportActionID ?? '-1']; + const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; const originalMessage = parentReportAction && ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction) : undefined; const transactionID = originalMessage?.IOUTransactionID ?? -1; diff --git a/src/hooks/useReviewDuplicatesNavigation.tsx b/src/hooks/useReviewDuplicatesNavigation.tsx index f92abe63c852..e14731024c17 100644 --- a/src/hooks/useReviewDuplicatesNavigation.tsx +++ b/src/hooks/useReviewDuplicatesNavigation.tsx @@ -41,7 +41,7 @@ function useReviewDuplicatesNavigation(stepNames: string[], currentScreenName: S Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.getRoute(threadReportID)); break; default: - // Navigation to confirm screen will be done in seperate PR + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_CONFIRMATION_PAGE.getRoute(threadReportID)); break; } }; diff --git a/src/languages/en.ts b/src/languages/en.ts index 682b2fff587f..992e608fdb54 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3968,6 +3968,8 @@ export default { categoryToKeep: 'Choose which category to keep', isTransactionBillable: 'Choose if transaction is billable', keepThisOne: 'Keep this one', + confirmDetails: `Confirm the details you're keeping`, + confirmDuplicatesInfo: `The duplicate requests you don't keep will be held for the member to delete`, hold: 'Hold', }, violationDismissal: { diff --git a/src/languages/es.ts b/src/languages/es.ts index a2e6a570f6fd..df149124594e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4487,6 +4487,8 @@ export default { categoryToKeep: 'Elige qué categoría quieres conservar', isTransactionBillable: 'Elige si la transacción es facturable', keepThisOne: 'Mantener éste', + confirmDetails: 'Confirma los detalles que conservas', + confirmDuplicatesInfo: 'Los duplicados que no conserves se guardarán para que el usuario los elimine', hold: 'Bloqueado', }, violationDismissal: { diff --git a/src/libs/API/parameters/TransactionMergeParams.ts b/src/libs/API/parameters/TransactionMergeParams.ts new file mode 100644 index 000000000000..9e2516e2637f --- /dev/null +++ b/src/libs/API/parameters/TransactionMergeParams.ts @@ -0,0 +1,17 @@ +type TransactionMergeParams = { + transactionID: string; + transactionIDList: string[]; + created: string; + merchant: string; + amount: number; + currency: string; + category: string; + comment: string; + billable: boolean; + reimbursable: boolean; + tag: string; + receiptID: number; + reportID: string; +}; + +export default TransactionMergeParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index a5598b2eeca9..53705e1f502d 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -235,6 +235,7 @@ export type {default as SearchParams} from './Search'; 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 TransactionMergeParams} from './TransactionMergeParams'; export type {default as UpdateSubscriptionTypeParams} from './UpdateSubscriptionTypeParams'; export type {default as SignUpUserParams} from './SignUpUserParams'; export type {default as UpdateSubscriptionAutoRenewParams} from './UpdateSubscriptionAutoRenewParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f4bdf2bcb0d9..d94a1f3f9292 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -231,6 +231,7 @@ const WRITE_COMMANDS = { SEND_INVOICE: 'SendInvoice', PAY_INVOICE: 'PayInvoice', MARK_AS_CASH: 'MarkAsCash', + TRANSACTION_MERGE: 'Transaction_Merge', UPDATE_SUBSCRIPTION_TYPE: 'UpdateSubscriptionType', SIGN_UP_USER: 'SignUpUser', UPDATE_SUBSCRIPTION_AUTO_RENEW: 'UpdateSubscriptionAutoRenew', @@ -549,6 +550,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SEND_INVOICE]: Parameters.SendInvoiceParams; [WRITE_COMMANDS.PAY_INVOICE]: Parameters.PayInvoiceParams; [WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams; + [WRITE_COMMANDS.TRANSACTION_MERGE]: Parameters.TransactionMergeParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_TYPE]: Parameters.UpdateSubscriptionTypeParams; [WRITE_COMMANDS.SIGN_UP_USER]: Parameters.SignUpUserParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_AUTO_RENEW]: Parameters.UpdateSubscriptionAutoRenewParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8a8411eff1c5..514e93962333 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -492,6 +492,7 @@ const TransactionDuplicateStackNavigator = createModalStackNavigator require('../../../../pages/TransactionDuplicate/ReviewTaxCode').default, [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: () => require('../../../../pages/TransactionDuplicate/ReviewBillable').default, [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: () => require('../../../../pages/TransactionDuplicate/ReviewReimbursable').default, + [SCREENS.TRANSACTION_DUPLICATE.CONFIRMATION]: () => require('../../../../pages/TransactionDuplicate/Confirmation').default, }); const SearchReportModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c32e94d65962..44355cbbe955 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -6,6 +6,7 @@ import NoDropZone from '@components/DragAndDrop/NoDropZone'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {abandonReviewDuplicateTransactions} from '@libs/actions/Transaction'; import {isSafari} from '@libs/Browser'; import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; @@ -19,7 +20,7 @@ type RightModalNavigatorProps = StackScreenProps(); -function RightModalNavigator({navigation}: RightModalNavigatorProps) { +function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { const styles = useThemeStyles(); const styleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -51,6 +52,19 @@ function RightModalNavigator({navigation}: RightModalNavigatorProps) { { + if ( + // @ts-expect-error There is something wrong with a types here and it's don't see the params list + navigation.getState().routes.find((routes) => routes.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR)?.params?.screen === + SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE || + route.params?.screen !== SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE + ) { + return; + } + abandonReviewDuplicateTransactions(); + }, + }} id={NAVIGATORS.RIGHT_MODAL_NAVIGATOR} > ['config'] = { path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.route, exact: true, }, + [SCREENS.TRANSACTION_DUPLICATE.CONFIRMATION]: { + path: ROUTES.TRANSACTION_DUPLICATE_CONFIRMATION_PAGE.route, + exact: true, + }, }, }, [SCREENS.RIGHT_MODAL.SPLIT_DETAILS]: { diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 34b078f7040e..752fadc7b2c1 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -9,6 +9,7 @@ import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, ReviewDuplicates, T import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {IOURequestType} from './actions/IOU'; +import type {TransactionMergeParams} from './API/parameters'; import {isCorporateCard, isExpensifyCard} from './CardUtils'; import {getCurrencyDecimals} from './CurrencyUtils'; import DateUtils from './DateUtils'; @@ -857,7 +858,7 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia const change: Record = {}; const fieldsToCompare: FieldsToCompare = { - merchant: ['merchant', 'modifiedMerchant'], + merchant: ['modifiedMerchant', 'merchant'], category: ['category'], tag: ['tag'], description: ['comment'], @@ -866,7 +867,20 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia reimbursable: ['reimbursable'], }; - const getDifferentValues = (items: Array>, keys: Array) => [...new Set(items.map((item) => keys.map((key) => item?.[key])).flat())]; + const getDifferentValues = (items: Array>, keys: Array) => [ + ...new Set( + items + .map((item) => { + // Prioritize modifiedMerchant over merchant + if (keys.includes('modifiedMerchant' as keyof Transaction) && keys.includes('merchant' as keyof Transaction)) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return item?.modifiedMerchant || item?.merchant; + } + return keys.map((key) => item?.[key]); + }) + .flat(), + ), + ]; for (const fieldName in fieldsToCompare) { if (Object.prototype.hasOwnProperty.call(fieldsToCompare, fieldName)) { @@ -913,6 +927,37 @@ function getTransactionID(threadReportID: string): string { return IOUTransactionID; } +function buildNewTransactionAfterReviewingDuplicates(reviewDuplicateTransaction: OnyxEntry): Partial { + const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${reviewDuplicateTransaction?.transactionID}`] ?? undefined; + const {duplicates, ...restReviewDuplicateTransaction} = reviewDuplicateTransaction ?? {}; + + return { + ...originalTransaction, + ...restReviewDuplicateTransaction, + modifiedMerchant: reviewDuplicateTransaction?.merchant, + merchant: reviewDuplicateTransaction?.merchant, + comment: {comment: reviewDuplicateTransaction?.description}, + }; +} + +function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry, originalTransaction: Partial): TransactionMergeParams { + return { + amount: -getAmount(originalTransaction as OnyxEntry, false), + reportID: originalTransaction?.reportID ?? '', + receiptID: originalTransaction?.receipt?.receiptID ?? 0, + currency: getCurrency(originalTransaction as OnyxEntry), + created: getFormattedCreated(originalTransaction as OnyxEntry), + transactionID: reviewDuplicates?.transactionID ?? '', + transactionIDList: reviewDuplicates?.duplicates ?? [], + billable: reviewDuplicates?.billable ?? false, + reimbursable: reviewDuplicates?.reimbursable ?? false, + category: reviewDuplicates?.category ?? '', + tag: reviewDuplicates?.tag ?? '', + merchant: reviewDuplicates?.merchant ?? '', + comment: reviewDuplicates?.description ?? '', + }; +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -983,6 +1028,8 @@ export { getTransaction, compareDuplicateTransactionFields, getTransactionID, + buildNewTransactionAfterReviewingDuplicates, + buildTransactionsMergeParams, getReimbursable, }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 1295d0924617..6c8f88649e5e 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -24,6 +24,7 @@ import type { StartSplitBillParams, SubmitReportParams, TrackExpenseParams, + TransactionMergeParams, UnapproveExpenseReportParams, UpdateMoneyRequestParams, } from '@libs/API/parameters'; @@ -7351,6 +7352,75 @@ function getIOURequestPolicyID(transaction: OnyxEntry, re return workspaceSender?.policyID ?? report?.policyID ?? '-1'; } +/** Merge several transactions into one by updating the fields of the one we want to keep and deleting the rest */ +function mergeDuplicates(params: TransactionMergeParams) { + const originalSelectedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`]; + + const optimisticTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`, + value: { + ...originalSelectedTransaction, + billable: params.billable, + comment: { + comment: params.comment, + }, + category: params.category, + created: params.created, + currency: params.currency, + modifiedMerchant: params.merchant, + reimbursable: params.reimbursable, + tag: params.tag, + }, + }; + + const failureTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`, + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + value: originalSelectedTransaction as OnyxTypes.Transaction, + }; + + const optimisticTransactionDuplicatesData: OnyxUpdate[] = params.transactionIDList.map((id) => ({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`, + value: null, + })); + + const failureTransactionDuplicatesData: OnyxUpdate[] = params.transactionIDList.map((id) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`, + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + value: allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`] as OnyxTypes.Transaction, + })); + + const optimisticTransactionViolations: OnyxUpdate[] = [...params.transactionIDList, params.transactionID].map((id) => { + const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${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[] = [...params.transactionIDList, params.transactionID].map((id) => { + const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? []; + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`, + value: violations, + }; + }); + + const optimisticData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + optimisticData.push(optimisticTransactionData, ...optimisticTransactionDuplicatesData, ...optimisticTransactionViolations); + failureData.push(failureTransactionData, ...failureTransactionDuplicatesData, ...failureTransactionViolations); + + API.write(WRITE_COMMANDS.TRANSACTION_MERGE, params, {optimisticData, failureData}); +} + export { adjustRemainingSplitShares, approveMoneyRequest, @@ -7418,5 +7488,6 @@ export { updateMoneyRequestTag, updateMoneyRequestTaxAmount, updateMoneyRequestTaxRate, + mergeDuplicates, }; export type {GPSPoint as GpsPoint, IOURequestType}; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 50b129f53295..cb721173261e 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -392,6 +392,10 @@ function setReviewDuplicatesKey(values: Partial) { }); } +function abandonReviewDuplicateTransactions() { + Onyx.set(ONYXKEYS.REVIEW_DUPLICATES, null); +} + function clearError(transactionID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {errors: null, errorFields: {route: null}}); } @@ -470,5 +474,6 @@ export { markAsCash, dismissDuplicateTransactionViolation, setReviewDuplicatesKey, + abandonReviewDuplicateTransactions, openDraftDistanceExpense, }; diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx new file mode 100644 index 000000000000..8d2ece49c417 --- /dev/null +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -0,0 +1,96 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +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 useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import variables from '@styles/variables'; +import * as IOU from '@src/libs/actions/IOU'; +import * as ReportActionsUtils from '@src/libs/ReportActionsUtils'; +import * as TransactionUtils from '@src/libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Transaction} from '@src/types/onyx'; + +function Confirmation() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const route = useRoute>(); + const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); + const transaction = useMemo(() => TransactionUtils.buildNewTransactionAfterReviewingDuplicates(reviewDuplicates), [reviewDuplicates]); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`); + const reportAction = Object.values(reportActions ?? {}).find( + (action) => ReportActionsUtils.isMoneyRequestAction(action) && ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID === reviewDuplicates?.transactionID, + ); + + const transactionsMergeParams = useMemo(() => TransactionUtils.buildTransactionsMergeParams(reviewDuplicates, transaction), [reviewDuplicates, transaction]); + const mergeDuplicates = useCallback(() => { + IOU.mergeDuplicates(transactionsMergeParams); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportAction?.childReportID ?? '-1')); + }, [reportAction?.childReportID, transactionsMergeParams]); + + const contextValue = useMemo( + () => ({ + transactionThreadReport: report, + action: reportAction, + report, + checkIfContextMenuActive: () => {}, + reportNameValuePairs: undefined, + anchor: null, + }), + [report, reportAction], + ); + + return ( + + + + + + {translate('violations.confirmDetails')} + + {translate('violations.confirmDuplicatesInfo')} + + {/* We need that provider here becuase MoneyRequestView component require that */} + + } + /> + +