diff --git a/src/CONST.ts b/src/CONST.ts index 00f2245a55c0..a02675d58c0e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3875,6 +3875,7 @@ const CONST = { TAX_REQUIRED: 'taxRequired', HOLD: 'hold', }, + REVIEW_DUPLICATES_ORDER: ['merchant', 'category', 'tag', 'description', 'taxCode', 'billable', 'reimbursable'], /** Context menu types */ CONTEXT_MENU_TYPES: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a70d6e7502ae..48d46e13bddc 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -866,6 +866,34 @@ const ROUTES = { route: 'r/:threadReportID/duplicates/review', getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review` as const, }, + TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE: { + route: 'r/:threadReportID/duplicates/review/merchant', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/merchant` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE: { + route: 'r/:threadReportID/duplicates/review/category', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/category` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE: { + route: 'r/:threadReportID/duplicates/review/tag', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/tag` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE: { + route: 'r/:threadReportID/duplicates/review/tax-code', + 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, + }, + TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE: { + route: 'r/:threadReportID/duplicates/review/reimbursable', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/reimbursable` as const, + }, + TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE: { + route: 'r/:threadReportID/duplicates/review/billable', + getRoute: (threadReportID: string) => `r/${threadReportID}/duplicates/review/billable` as const, + }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'settings/workspaces/:policyID/accounting/xero/import', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index e12ccfdab072..c15b4ed7b468 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -186,6 +186,13 @@ const SCREENS = { TRANSACTION_DUPLICATE: { REVIEW: 'Transaction_Duplicate_Review', + MERCHANT: 'Transaction_Duplicate_Merchant', + CATEGORY: 'Transaction_Duplicate_Category', + TAG: 'Transaction_Duplicate_Tag', + DESCRIPTION: 'Transaction_Duplicate_Description', + TAX_CODE: 'Transaction_Duplicate_Tax_Code', + REIMBURSABLE: 'Transaction_Duplicate_Reimburable', + BILLABLE: 'Transaction_Duplicate_Billable', }, IOU_SEND: { diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 9e31dc110579..896432708aff 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -1,9 +1,11 @@ +import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; import truncate from 'lodash/truncate'; import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import Icon from '@components/Icon'; @@ -27,6 +29,8 @@ import ControlSelection from '@libs/ControlSelection'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as IOUUtils from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -41,6 +45,7 @@ import * as Report from '@userActions/Report'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -72,7 +77,7 @@ function MoneyRequestPreviewContent({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); - const route = useRoute(); + const route = useRoute>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const sessionAccountID = session?.accountID; @@ -126,6 +131,9 @@ function MoneyRequestPreviewContent({ const showCashOrCard = isCardTransaction ? translate('iou.card') : translate('iou.cash'); const shouldShowHoldMessage = !(isSettled && !isSettlementOrApprovalPartial) && isOnHold; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params?.threadReportID}`); + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + const reviewingTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; // Get transaction violations for given transaction id from onyx, find duplicated transactions violations and get duplicates const duplicates = useMemo( () => @@ -264,6 +272,29 @@ function MoneyRequestPreviewContent({ [shouldShowSplitShare, isPolicyExpenseChat, action.actorAccountID, participantAccountIDs.length, transaction?.comment?.splits, requestAmount, requestCurrency, sessionAccountID], ); + const navigateToReviewFields = () => { + const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID); + const allTransactionIDsDuplicates = [reviewingTransactionID, ...duplicates].filter((id) => id !== transaction?.transactionID); + Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates: allTransactionIDsDuplicates, transactionID: transaction?.transactionID ?? ''}); + if ('merchant' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(route.params?.threadReportID)); + } else if ('category' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.getRoute(route.params?.threadReportID)); + } else if ('tag' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.getRoute(route.params?.threadReportID)); + } else if ('description' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.getRoute(route.params?.threadReportID)); + } else if ('taxCode' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.getRoute(route.params?.threadReportID)); + } else if ('billable' in comparisonResult.change) { + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.getRoute(route.params?.threadReportID)); + } 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 + } + }; + const childContainer = ( { - Transaction.setReviewDuplicatesKey(transaction?.transactionID ?? '', duplicates); - }} + onPress={navigateToReviewFields} /> )} diff --git a/src/hooks/useReviewDuplicatesNavigation.tsx b/src/hooks/useReviewDuplicatesNavigation.tsx new file mode 100644 index 000000000000..f92abe63c852 --- /dev/null +++ b/src/hooks/useReviewDuplicatesNavigation.tsx @@ -0,0 +1,52 @@ +import {useEffect, useMemo, useState} from 'react'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +type StepName = 'description' | 'merchant' | 'category' | 'billable' | 'tag' | 'taxCode' | 'reimbursable'; + +function useReviewDuplicatesNavigation(stepNames: string[], currentScreenName: StepName, threadReportID: string) { + const [nextScreen, setNextScreen] = useState(currentScreenName); + const [currentScreenIndex, setCurrentScreenIndex] = useState(0); + const intersection = useMemo(() => CONST.REVIEW_DUPLICATES_ORDER.filter((element) => stepNames.includes(element)), [stepNames]); + + useEffect(() => { + const currentIndex = intersection.indexOf(currentScreenName); + const nextScreenIndex = currentIndex + 1; + setCurrentScreenIndex(currentIndex); + setNextScreen(intersection[nextScreenIndex] ?? ''); + }, [currentScreenName, intersection]); + + const navigateToNextScreen = () => { + switch (nextScreen) { + case 'merchant': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(threadReportID)); + break; + case 'category': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.getRoute(threadReportID)); + break; + case 'tag': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.getRoute(threadReportID)); + break; + case 'description': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.getRoute(threadReportID)); + break; + case 'taxCode': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.getRoute(threadReportID)); + break; + case 'reimbursable': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.getRoute(threadReportID)); + break; + case 'billable': + Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.getRoute(threadReportID)); + break; + default: + // Navigation to confirm screen will be done in seperate PR + break; + } + }; + + return {navigateToNextScreen, currentScreenIndex}; +} + +export default useReviewDuplicatesNavigation; diff --git a/src/languages/en.ts b/src/languages/en.ts index 38c272294ad9..e833e57b42fd 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3515,6 +3515,14 @@ export default { taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'Tax'} no longer valid`, taxRateChanged: 'Tax rate was modified', taxRequired: 'Missing tax rate', + none: 'None', + taxCodeToKeep: 'Choose which tax code to keep', + tagToKeep: 'Choose which tag to keep', + isTransactionReimbursable: 'Choose if transaction is reimbursable', + merchantToKeep: 'Choose which merchant to keep', + descriptionToKeep: 'Choose which description to keep', + categoryToKeep: 'Choose which category to keep', + isTransactionBillable: 'Choose if transaction is billable', keepThisOne: 'Keep this one', hold: 'Hold', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 06c761215151..75532eae378b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4023,6 +4023,14 @@ export default { taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'El impuesto'} ya no es válido`, taxRateChanged: 'La tasa de impuesto fue modificada', taxRequired: 'Falta la tasa de impuesto', + none: 'Ninguno', + taxCodeToKeep: 'Elige qué tasa de impuesto quieres conservar', + tagToKeep: 'Elige qué etiqueta quieres conservar', + isTransactionReimbursable: 'Elige si la transacción es reembolsable', + merchantToKeep: 'Elige qué comerciante quieres conservar', + descriptionToKeep: 'Elige qué descripción quieres conservar', + categoryToKeep: 'Elige qué categoría quieres conservar', + isTransactionBillable: 'Elige si la transacción es facturable', keepThisOne: 'Mantener éste', hold: 'Bloqueado', }, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 4fd6251ec644..770bece2b17d 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -410,6 +410,13 @@ const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({ const TransactionDuplicateStackNavigator = createModalStackNavigator({ [SCREENS.TRANSACTION_DUPLICATE.REVIEW]: () => require('../../../../pages/TransactionDuplicate/Review').default, + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: () => require('../../../../pages/TransactionDuplicate/ReviewMerchant').default, + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: () => require('../../../../pages/TransactionDuplicate/ReviewCategory').default, + [SCREENS.TRANSACTION_DUPLICATE.TAG]: () => require('../../../../pages/TransactionDuplicate/ReviewTag').default, + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: () => require('../../../../pages/TransactionDuplicate/ReviewDescription').default, + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: () => require('../../../../pages/TransactionDuplicate/ReviewTaxCode').default, + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: () => require('../../../../pages/TransactionDuplicate/ReviewBillable').default, + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: () => require('../../../../pages/TransactionDuplicate/ReviewReimbursable').default, }); const SearchReportModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 3f4896e0c5d2..607266675802 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -762,6 +762,34 @@ const config: LinkingOptions['config'] = { path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_PAGE.route, exact: true, }, + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_CATEGORY_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.TAG]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAG_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_DESCRIPTION_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_TAX_CODE_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_REIMBURSABLE_PAGE.route, + exact: true, + }, + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: { + path: ROUTES.TRANSACTION_DUPLICATE_REVIEW_BILLABLE_PAGE.route, + exact: true, + }, }, }, [SCREENS.RIGHT_MODAL.SPLIT_DETAILS]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ac9710b65d19..7e042a0d1374 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -818,6 +818,27 @@ type TransactionDuplicateNavigatorParamList = { [SCREENS.TRANSACTION_DUPLICATE.REVIEW]: { threadReportID: string; }; + [SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.CATEGORY]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.TAG]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.DESCRIPTION]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.TAX_CODE]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.BILLABLE]: { + threadReportID: string; + }; + [SCREENS.TRANSACTION_DUPLICATE.REIMBURSABLE]: { + threadReportID: string; + }; }; type LeftModalNavigatorParamList = { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 65aaf4c9de0a..81eca1070e4f 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -24,6 +24,7 @@ import {parseHtmlToText} from './OnyxAwareParser'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import type {OptimisticIOUReportAction, PartialReportAction} from './ReportUtils'; import StringUtils from './StringUtils'; +// eslint-disable-next-line import/no-cycle import * as TransactionUtils from './TransactionUtils'; type LastVisibleMessage = { diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index c8bfa316ca15..8e1854950715 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -1,10 +1,11 @@ import lodashHas from 'lodash/has'; +import lodashIsEqual from 'lodash/isEqual'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; 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'; @@ -14,6 +15,8 @@ import * as Localize from './Localize'; import * as NumberUtils from './NumberUtils'; import Permissions from './Permissions'; import {getCleanedTagName, getCustomUnitRate} from './PolicyUtils'; +// eslint-disable-next-line import/no-cycle +import * as ReportActionsUtils from './ReportActionsUtils'; let allTransactions: OnyxCollection = {}; Onyx.connect({ @@ -817,6 +820,101 @@ function getTransaction(transactionID: string): OnyxEntry { return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; } +type FieldsToCompare = Record>; +type FieldsToChange = { + category?: Array; + merchant?: Array; + tag?: Array; + description?: Array; + taxCode?: Array; + billable?: Array; + reimbursable?: Array; +}; + +/** + * This function compares fields of duplicate transactions and determines which fields should be kept and which should be changed. + * + * @returns An object with two properties: 'keep' and 'change'. + * 'keep' is an object where each key is a field name and the value is the value of that field in the transaction that should be kept. + * 'change' is an object where each key is a field name and the value is an array of different values of that field in the duplicate transactions. + * + * The function works as follows: + * 1. It fetches the transaction violations for the given transaction ID. + * 2. It finds the duplicate transactions. + * 3. It creates two empty objects, 'keep' and 'change'. + * 4. It defines the fields to compare in the transactions. + * 5. It iterates over the fields to compare. For each field: + * - If the field is 'description', it checks if all comments are equal, exist, or are empty. If so, it keeps the first transaction's comment. Otherwise, it finds the different values and adds them to 'change'. + * - For other fields, it checks if all fields are equal. If so, it keeps the first transaction's field value. Otherwise, it finds the different values and adds them to 'change'. + * 6. It returns the 'keep' and 'change' objects. + */ + +function compareDuplicateTransactionFields(transactionID: string): {keep: Partial; change: FieldsToChange} { + const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const duplicates = transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? []; + const transactions = [transactionID, ...duplicates].map((item) => getTransaction(item)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const keep: Record = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const change: Record = {}; + + const fieldsToCompare: FieldsToCompare = { + merchant: ['merchant', 'modifiedMerchant'], + category: ['category'], + tag: ['tag'], + description: ['comment'], + taxCode: ['taxCode'], + billable: ['billable'], + reimbursable: ['reimbursable'], + }; + + const getDifferentValues = (items: Array>, keys: Array) => [...new Set(items.map((item) => keys.map((key) => item?.[key])).flat())]; + + for (const fieldName in fieldsToCompare) { + if (Object.prototype.hasOwnProperty.call(fieldsToCompare, fieldName)) { + const keys = fieldsToCompare[fieldName]; + const firstTransaction = transactions[0]; + const isFirstTransactionCommentEmptyObject = typeof firstTransaction?.comment === 'object' && firstTransaction?.comment.comment === ''; + + if (fieldName === 'description') { + const allCommentsAreEqual = transactions.every((item) => lodashIsEqual(item?.comment, firstTransaction?.comment)); + const allCommentsExist = transactions.every((item) => !!item?.comment.comment === !!firstTransaction?.comment.comment); + const allCommentsAreEmpty = isFirstTransactionCommentEmptyObject && transactions.every((item) => item?.comment === undefined); + + if (allCommentsAreEqual || allCommentsExist || allCommentsAreEmpty) { + keep[fieldName] = firstTransaction?.comment.comment ?? firstTransaction?.comment; + } else { + const differentValues = getDifferentValues(transactions, keys); + if (differentValues.length > 0) { + change[fieldName] = differentValues; + } + } + } else { + const allFieldsAreEqual = transactions.every((item) => keys.every((key) => item?.[key] === firstTransaction?.[key])); + + if (allFieldsAreEqual) { + keep[fieldName] = firstTransaction?.[keys[0]]; + } else { + const differentValues = getDifferentValues(transactions, keys); + if (differentValues.length > 0) { + change[fieldName] = differentValues; + } + } + } + } + } + + return {keep, change}; +} + +function getTransactionID(threadReportID: string): string { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`] ?? null; + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + const IOUTransactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID ?? '-1' : '-1'; + + return IOUTransactionID; +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -885,6 +983,8 @@ export { isCustomUnitRateIDForP2P, getRateID, getTransaction, + compareDuplicateTransactionFields, + getTransactionID, getReimbursable, }; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 3166d0dfcb8f..1a14da0efe14 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -13,7 +13,7 @@ import {buildOptimisticDismissedViolationReportAction} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, RecentWaypoint, ReportAction, ReportActions, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {PersonalDetails, RecentWaypoint, ReportAction, ReportActions, ReviewDuplicates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {OnyxData} from '@src/types/onyx/Request'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; @@ -369,11 +369,9 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss }); } -function setReviewDuplicatesKey(transactionID: string, transactionIDs: string[]) { +function setReviewDuplicatesKey(values: Partial) { Onyx.merge(`${ONYXKEYS.REVIEW_DUPLICATES}`, { - [transactionID]: { - duplicates: transactionIDs, - }, + ...values, }); } diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index 4881765ff76f..398ea8ae336b 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -1,6 +1,8 @@ import React from 'react'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import ReportActionItem from '@pages/home/report/ReportActionItem'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -12,6 +14,7 @@ type DuplicateTransactionItemProps = { }; function DuplicateTransactionItem(props: DuplicateTransactionItemProps) { + const styles = useThemeStyles(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.transaction?.reportID}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`); @@ -26,18 +29,20 @@ function DuplicateTransactionItem(props: DuplicateTransactionItemProps) { } return ( - + + + ); } diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx index 8dd610bbd0be..00b80fecf824 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionsList.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type {FlatListProps, ScrollViewProps} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FlatList from '@components/FlatList'; +import useThemeStyles from '@hooks/useThemeStyles'; import type {Transaction} from '@src/types/onyx'; import DuplicateTransactionItem from './DuplicateTransactionItem'; @@ -23,12 +24,15 @@ const maintainVisibleContentPosition: ScrollViewProps['maintainVisibleContentPos }; function DuplicateTransactionsList({transactions}: DuplicateTransactionsListProps) { + const styles = useThemeStyles(); + return ( ); } diff --git a/src/pages/TransactionDuplicate/ReviewBillable.tsx b/src/pages/TransactionDuplicate/ReviewBillable.tsx new file mode 100644 index 000000000000..1f9be45f2cf0 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewBillable.tsx @@ -0,0 +1,54 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewBillable() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'billable', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.billable?.map((billable) => ({ + text: billable ? translate('common.yes') : translate('common.no'), + value: billable, + })), + [compareResult.change.billable, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({billable: data.value as boolean}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewBillable.displayName = 'ReviewBillable'; + +export default ReviewBillable; diff --git a/src/pages/TransactionDuplicate/ReviewCategory.tsx b/src/pages/TransactionDuplicate/ReviewCategory.tsx new file mode 100644 index 000000000000..7d55de2e6a7c --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewCategory.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewCategory() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'category', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.category?.map((category) => + !category + ? {text: translate('violations.none'), value: undefined} + : { + text: category, + value: category, + }, + ), + [compareResult.change.category, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({category: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewCategory.displayName = 'ReviewCategory'; + +export default ReviewCategory; diff --git a/src/pages/TransactionDuplicate/ReviewDescription.tsx b/src/pages/TransactionDuplicate/ReviewDescription.tsx new file mode 100644 index 000000000000..787957b0e7e2 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewDescription.tsx @@ -0,0 +1,57 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewDescription() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'description', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.description?.map((description) => + !description?.comment + ? {text: translate('violations.none'), value: ''} + : { + text: description.comment, + value: description.comment, + }, + ), + [compareResult.change.description, translate], + ); + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({description: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewDescription.displayName = 'ReviewDescription'; + +export default ReviewDescription; diff --git a/src/pages/TransactionDuplicate/ReviewFields.tsx b/src/pages/TransactionDuplicate/ReviewFields.tsx new file mode 100644 index 000000000000..3c513c55e817 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewFields.tsx @@ -0,0 +1,91 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +type FieldItemType = { + text: string; + value: string | boolean; + keyForList: string; +}; + +type ReviewFieldsProps = { + /* Step Names which are displayed in stepper */ + stepNames: string[]; + + /* Label which is displyed to describe current step */ + label: string; + + /* Values to choose from */ + options: Array<{text: string; value: string | boolean | undefined}> | undefined; + + /* Current index */ + index: number; + + /* Callback to what should happen after selecting row */ + onSelectRow: (item: FieldItemType) => void; +}; + +function ReviewFields({stepNames, label, options, index, onSelectRow}: ReviewFieldsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + let falsyCount = 0; + const filteredOptions = options?.filter((name) => { + if (name.text !== translate('violations.none')) { + return true; + } + falsyCount++; + return falsyCount <= 1; + }); + + const sections = useMemo( + () => + filteredOptions?.map((option) => ({ + text: option.text, + keyForList: option.text, + value: option.value ?? '', + })), + [filteredOptions], + ); + + return ( + + {stepNames.length > 1 && ( + + + + )} + + + {label} + + + + ); +} + +ReviewFields.displayName = 'ReviewFields'; + +export default ReviewFields; +export type {FieldItemType}; diff --git a/src/pages/TransactionDuplicate/ReviewMerchant.tsx b/src/pages/TransactionDuplicate/ReviewMerchant.tsx new file mode 100644 index 000000000000..b4a38ac5c527 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewMerchant.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewMerchant() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'merchant', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.merchant?.map((merchant) => + !merchant + ? {text: translate('violations.none'), value: undefined} + : { + text: merchant, + value: merchant, + }, + ), + [compareResult.change.merchant, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({merchant: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewMerchant.displayName = 'ReviewMerchant'; + +export default ReviewMerchant; diff --git a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx new file mode 100644 index 000000000000..1ff187213a0c --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx @@ -0,0 +1,54 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewReimbursable() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'reimbursable', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.reimbursable?.map((reimbursable) => ({ + text: reimbursable ? translate('common.yes') : translate('common.no'), + value: reimbursable, + })), + [compareResult.change.reimbursable, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({reimbursable: data.value as boolean}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewReimbursable.displayName = 'ReviewReimbursable'; + +export default ReviewReimbursable; diff --git a/src/pages/TransactionDuplicate/ReviewTag.tsx b/src/pages/TransactionDuplicate/ReviewTag.tsx new file mode 100644 index 000000000000..192434678a78 --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewTag.tsx @@ -0,0 +1,58 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type SCREENS from '@src/SCREENS'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewTag() { + const route = useRoute>(); + const {translate} = useLocalize(); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'tag', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.tag?.map((tag) => + !tag + ? {text: translate('violations.none'), value: undefined} + : { + text: tag, + value: tag, + }, + ), + [compareResult.change.tag, translate], + ); + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({tag: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewTag.displayName = 'ReviewTag'; + +export default ReviewTag; diff --git a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx new file mode 100644 index 000000000000..77ca169969fc --- /dev/null +++ b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx @@ -0,0 +1,64 @@ +import type {RouteProp} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; +import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import ReviewDescription from './ReviewDescription'; +import type {FieldItemType} from './ReviewFields'; +import ReviewFields from './ReviewFields'; + +function ReviewTaxRate() { + const route = useRoute>(); + const {translate} = useLocalize(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`); + const policy = PolicyUtils.getPolicy(report?.policyID ?? ''); + const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); + const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'taxCode', route.params.threadReportID ?? ''); + const options = useMemo( + () => + compareResult.change.taxCode?.map((taxID) => + !taxID + ? {text: translate('violations.none'), value: undefined} + : { + text: PolicyUtils.getTaxByID(policy, taxID)?.name ?? '', + value: taxID, + }, + ), + [compareResult.change.taxCode, policy, translate], + ); + + const onSelectRow = (data: FieldItemType) => { + if (data.value !== undefined) { + setReviewDuplicatesKey({taxCode: data.value as string}); + } + navigateToNextScreen(); + }; + + return ( + + + + + ); +} + +ReviewTaxRate.displayName = 'ReviewTaxRate'; + +export default ReviewTaxRate;