diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0bca7c4b749f..f50ae74d8b1f 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1371,6 +1371,14 @@ const CONST = { DUPLICATE: 'duplicate', MOVE_EXPENSE: 'moveExpense', }, + SELECTED_TRANSACTIONS_BULK_ACTION_TYPES: { + HOLD: 'hold', + UNHOLD: 'unhold', + MOVE: 'move', + MERGE: 'merge', + SPLIT: 'split', + DUPLICATE: 'duplicate', + }, ADD_EXPENSE_OPTIONS: { CREATE_NEW_EXPENSE: 'createNewExpense', ADD_UNREPORTED_EXPENSE: 'addUnreportedExpense', @@ -7464,6 +7472,7 @@ const CONST = { REJECT: 'reject', CHANGE_REPORT: 'changeReport', SPLIT: 'split', + DUPLICATE: 'duplicate', }, TRANSACTION_TYPE: { CASH: 'cash', diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ee31608a9325..3f9347b886f4 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -147,6 +147,7 @@ import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; import MoneyReportHeaderPrimaryAction from './MoneyReportHeaderPrimaryAction'; import {usePersonalDetails} from './OnyxListItemProvider'; import type {PopoverMenuItem} from './PopoverMenu'; +import BulkDuplicateHandler from './Search/BulkDuplicateHandler'; import {useSearchActionsContext, useSearchStateContext} from './Search/SearchContext'; import type {PaymentActionParams} from './SettlementButton/types'; import Text from './Text'; @@ -513,6 +514,10 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt options: originalSelectedTransactionsOptions, handleDeleteTransactions, handleDeleteTransactionsWithNavigation, + isDuplicateOptionVisible, + setDuplicateHandler, + allTransactions: allTransactionsForDuplicate, + allReports: allReportsForDuplicate, } = useSelectedTransactionsActions({ report: moneyRequestReport, reportActions, @@ -2025,67 +2030,79 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt } return ( - - - {shouldDisplayNarrowMoreButton && ( - - {!shouldShowSelectedTransactionsButton && primaryActionComponent} - {!!applicableSecondaryActions.length && !shouldShowSelectedTransactionsButton && ( - confirmPayment({paymentType: type})} - primaryAction={primaryAction} - applicableSecondaryActions={applicableSecondaryActions} - dropdownMenuRef={dropdownMenuRef} - onOptionsMenuHide={handleOptionsMenuHide} - ref={kycWallRef} - /> - )} - {shouldShowSelectedTransactionsButton && {renderSelectionModeDropdown()}} - - )} - - {!shouldDisplayNarrowMoreButton && - (shouldShowSelectedTransactionsButton ? ( - {renderSelectionModeDropdown(styles.w100)} - ) : ( - - {!!primaryAction && {primaryActionComponent}} - {!!applicableSecondaryActions.length && ( - confirmPayment({paymentType: type})} - primaryAction={primaryAction} - applicableSecondaryActions={applicableSecondaryActions} - dropdownMenuRef={dropdownMenuRef} - onOptionsMenuHide={handleOptionsMenuHide} - ref={kycWallRef} - /> - )} - - ))} - - - - - + <> + {isDuplicateOptionVisible && ( + clearSelectedTransactions(true)} + /> + )} + + + {shouldDisplayNarrowMoreButton && ( + + {!shouldShowSelectedTransactionsButton && primaryActionComponent} + {!!applicableSecondaryActions.length && !shouldShowSelectedTransactionsButton && ( + confirmPayment({paymentType: type})} + primaryAction={primaryAction} + applicableSecondaryActions={applicableSecondaryActions} + dropdownMenuRef={dropdownMenuRef} + onOptionsMenuHide={handleOptionsMenuHide} + ref={kycWallRef} + /> + )} + {shouldShowSelectedTransactionsButton && {renderSelectionModeDropdown()}} + + )} + + {!shouldDisplayNarrowMoreButton && + (shouldShowSelectedTransactionsButton ? ( + {renderSelectionModeDropdown(styles.w100)} + ) : ( + + {!!primaryAction && {primaryActionComponent}} + {!!applicableSecondaryActions.length && ( + confirmPayment({paymentType: type})} + primaryAction={primaryAction} + applicableSecondaryActions={applicableSecondaryActions} + dropdownMenuRef={dropdownMenuRef} + onOptionsMenuHide={handleOptionsMenuHide} + ref={kycWallRef} + /> + )} + + ))} + + + + + + ); } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index bf67d022d50b..2b2d08b6bc53 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -18,6 +18,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import ScrollView from '@components/ScrollView'; +import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -281,7 +282,13 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money [showConfirmModal, translate, selectedTransactionIDs.length, transactions, route.params?.backTo, chatReport?.reportID], ); - const {options: originalSelectedTransactionsOptions} = useSelectedTransactionsActions({ + const { + options: originalSelectedTransactionsOptions, + isDuplicateOptionVisible, + setDuplicateHandler, + allTransactions: allTransactionsForDuplicate, + allReports: allReportsForDuplicate, + } = useSelectedTransactionsActions({ report, reportActions, allTransactionsLength: transactions.length, @@ -840,144 +847,156 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); return ( - - {shouldUseNarrowLayout && isMobileSelectionModeEnabled && ( - - null} - options={selectedTransactionsOptions} - customText={translate('workspace.common.selected', { - count: selectedTransactionIDs.length, - })} - isSplitButton={false} - shouldAlwaysShowDropdownMenu - shouldPopoverUseScrollView={popoverUseScrollView} - wrapperStyle={[styles.w100, styles.ph5]} - /> - - 0 && selectedTransactionIDs.length !== transactionsWithoutPendingDelete.length} - onPress={() => { - if (selectedTransactionIDs.length !== 0) { - clearSelectedTransactions(true); - } else { - setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID)); - } - }} - /> - { - if (isSelectAllChecked) { - clearSelectedTransactions(true); - } else { - setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID)); - } - }} - accessibilityLabel={translate('accessibilityHints.selectAllItems')} - role="button" - accessibilityState={{checked: isSelectAllChecked}} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} - sentryLabel={CONST.SENTRY_LABEL.REPORT.MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL} - > - {translate('workspace.people.selectAll')} - - - - )} - - + {isDuplicateOptionVisible && ( + clearSelectedTransactions(true)} /> - {isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState ? ( - - + {shouldUseNarrowLayout && isMobileSelectionModeEnabled && ( + + null} + options={selectedTransactionsOptions} + customText={translate('workspace.common.selected', { + count: selectedTransactionIDs.length, + })} + isSplitButton={false} + shouldAlwaysShowDropdownMenu + shouldPopoverUseScrollView={popoverUseScrollView} + wrapperStyle={[styles.w100, styles.ph5]} /> - + 0 && selectedTransactionIDs.length !== transactionsWithoutPendingDelete.length} + onPress={() => { + if (selectedTransactionIDs.length !== 0) { + clearSelectedTransactions(true); + } else { + setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID)); + } + }} + /> + { + if (isSelectAllChecked) { + clearSelectedTransactions(true); + } else { + setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID)); + } + }} + accessibilityLabel={translate('accessibilityHints.selectAllItems')} + role="button" + accessibilityState={{checked: isSelectAllChecked}} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + sentryLabel={CONST.SENTRY_LABEL.REPORT.MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL} + > + {translate('workspace.people.selectAll')} + + + + )} + + + {isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState ? ( + + + + + ) : ( + + + 0} + isLoadingInitialReportActions={showReportActionsLoadingState} + /> + + } + keyboardShouldPersistTaps="handled" + onScroll={trackVerticalScrolling} + contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]} + ref={reportScrollManager.ref} + ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState + removeClippedSubviews={false} + initialScrollKey={linkedReportActionID} /> - - ) : ( - - - 0} - isLoadingInitialReportActions={showReportActionsLoadingState} - /> - - } - keyboardShouldPersistTaps="handled" - onScroll={trackVerticalScrolling} - contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]} - ref={reportScrollManager.ref} - ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState - removeClippedSubviews={false} - initialScrollKey={linkedReportActionID} + )} + + setIsDownloadErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isDownloadErrorModalVisible} + onClose={() => setIsDownloadErrorModalVisible(false)} + /> + setOfflineModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={offlineModalVisible} + onClose={() => setOfflineModalVisible(false)} + /> + {!!rejectModalAction && ( + )} - setIsDownloadErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadErrorModalVisible} - onClose={() => setIsDownloadErrorModalVisible(false)} - /> - setOfflineModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={offlineModalVisible} - onClose={() => setOfflineModalVisible(false)} - /> - {!!rejectModalAction && ( - - )} - + ); } diff --git a/src/components/Search/BulkDuplicateHandler.tsx b/src/components/Search/BulkDuplicateHandler.tsx new file mode 100644 index 000000000000..978e8b4725bd --- /dev/null +++ b/src/components/Search/BulkDuplicateHandler.tsx @@ -0,0 +1,30 @@ +import {useEffect} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; +import useBulkDuplicateAction from '@hooks/useBulkDuplicateAction'; +import type {Report, Transaction} from '@src/types/onyx'; + +type BulkDuplicateHandlerProps = { + selectedTransactionsKeys: string[]; + allTransactions: OnyxCollection; + allReports: OnyxCollection | undefined; + searchData: Record | undefined; + onHandlerReady: (handler: () => void) => void; + onAfterDuplicate?: () => void; +}; + +/** + * Invisible component that subscribes to action-time Onyx data for bulk duplication. + * Only mounted when the duplicate option is visible, avoiding unnecessary global + * subscriptions on the search page for users who aren't duplicating. + */ +function BulkDuplicateHandler({selectedTransactionsKeys, allTransactions, allReports, searchData, onHandlerReady, onAfterDuplicate}: BulkDuplicateHandlerProps) { + const handleDuplicate = useBulkDuplicateAction({selectedTransactionsKeys, allTransactions, allReports, searchData, onAfterDuplicate}); + + useEffect(() => { + onHandlerReady(handleDuplicate); + }, [handleDuplicate, onHandlerReady]); + + return null; +} + +export default BulkDuplicateHandler; diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 5d6b11072253..d0184c76a33c 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -24,6 +24,7 @@ import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import BulkDuplicateHandler from './BulkDuplicateHandler'; import {useSearchActionsContext, useSearchStateContext} from './SearchContext'; import type {BulkPaySelectionData, SearchQueryJSON} from './types'; @@ -68,6 +69,11 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { handleNonReimbursablePaymentErrorModalClose, dismissModalAndUpdateUseHold, dismissRejectModalBasedOnAction, + isDuplicateOptionVisible, + setDuplicateHandler, + allTransactions, + allReports, + searchData, } = useSearchBulkActions({queryJSON}); const currentSelectedPolicyID = selectedPolicyIDs?.at(0); const currentSelectedReportID = selectedTransactionReportIDs?.at(0) ?? selectedReportIDs?.at(0); @@ -101,6 +107,15 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { return ( <> + {isDuplicateOptionVisible && ( + + )} ; + allReports: OnyxCollection | undefined; + searchData: Record | undefined; + onAfterDuplicate?: () => void; +}; + +/** + * Hook that subscribes to action-time-only Onyx data needed for bulk expense duplication. + * Designed to be called inside a component that only mounts when the duplicate option is visible, + * so these subscriptions don't exist for users who aren't actively duplicating. + */ +function useBulkDuplicateAction({selectedTransactionsKeys, allTransactions, allReports, searchData, onAfterDuplicate}: UseBulkDuplicateActionParams) { + const {accountID} = useCurrentUserPersonalDetails(); + const {clearSelectedTransactions} = useSearchActionsContext(); + const defaultExpensePolicy = useDefaultExpensePolicy(); + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); + const [targetPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`); + const [targetPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy?.id}`); + + const sourcePolicyIDMap: Record = {}; + for (const transactionID of selectedTransactionsKeys) { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const reportID = transaction?.reportID; + if (!reportID) { + continue; + } + const report = (searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] as Report | undefined) ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + sourcePolicyIDMap[transactionID] = report?.policyID; + } + + const handleDuplicate = () => { + const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id); + + bulkDuplicateExpenses({ + transactionIDs: selectedTransactionsKeys, + allTransactions: allTransactions ?? {}, + sourcePolicyIDMap, + targetPolicy: (defaultExpensePolicy ?? undefined) as OnyxEntry, + targetPolicyCategories: targetPolicyCategories ?? {}, + targetPolicyTags: targetPolicyTags ?? {}, + targetReport: activePolicyExpenseChat, + personalDetails, + isASAPSubmitBetaEnabled, + introSelected, + activePolicyID, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + isSelfTourViewed, + transactionDrafts, + draftTransactionIDs, + betas, + recentWaypoints, + }); + + if (onAfterDuplicate) { + onAfterDuplicate(); + } else { + clearSelectedTransactions(undefined, true); + } + }; + + return handleDuplicate; +} + +export default useBulkDuplicateAction; diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index dcbe85086717..7481b7d6a8a3 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -39,24 +39,28 @@ import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} f import { canEditMultipleTransactions, getIntegrationIcon, + getPolicyExpenseChat, getReportOrDraftReport, hasOnlyNonReimbursableTransactions, + isArchivedReport, isBusinessInvoiceRoom, isCurrentUserSubmitter, + isDM, isExpenseReport as isExpenseReportUtil, isInvoiceReport, isIOUReport as isIOUReportUtil, + isSelfDM, } from '@libs/ReportUtils'; import {serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; import {navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {hasTransactionBeenRejected} from '@libs/TransactionUtils'; +import {hasCustomUnitOutOfPolicyViolation, hasTransactionBeenRejected, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest, isScanning} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import {canIOUBePaid, dismissRejectUseExplanation, initBulkEditDraftTransaction} from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {BillingGraceEndPeriod, Policy, Report, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {BillingGraceEndPeriod, Policy, Report, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import useAllPolicyExpenseChatReportActions from './useAllPolicyExpenseChatReportActions'; import useAllTransactions from './useAllTransactions'; @@ -64,6 +68,8 @@ import useBulkPayOptions from './useBulkPayOptions'; import useConfirmModal from './useConfirmModal'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDefaultExpensePolicy from './useDefaultExpensePolicy'; +import useEnvironment from './useEnvironment'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; @@ -95,11 +101,96 @@ function getRestrictedPolicyID( ); } +type ShouldShowBulkDuplicateParams = { + selectedTransactionsKeys: string[]; + selectedTransactions: Record; + allTransactions: OnyxCollection | undefined; + allReports: OnyxCollection | undefined; + allTransactionViolations: OnyxCollection | undefined; + allReportNameValuePairs: OnyxCollection | undefined; + defaultExpensePolicyID: string | undefined; + activePolicyExpenseChat: Report | undefined; + typeExpenseReport: boolean; + searchData: Record | undefined; +}; + +/** + * Determines whether the bulk duplicate option should be shown for the selected transactions. + * Mirrors the single-duplicate guards from MoneyReportHeader/MoneyRequestHeader. + */ +function shouldShowBulkDuplicateOption({ + selectedTransactionsKeys, + selectedTransactions, + allTransactions, + allReports, + allTransactionViolations, + allReportNameValuePairs, + defaultExpensePolicyID, + activePolicyExpenseChat, + typeExpenseReport, + searchData, +}: ShouldShowBulkDuplicateParams): boolean { + if (typeExpenseReport || selectedTransactionsKeys.length === 0) { + return false; + } + + const searchReports = searchData + ? Object.keys(searchData) + .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT)) + .map((key) => searchData[key] as Report) + .filter((report): report is Report => report != null && 'reportID' in report) + : []; + + return selectedTransactionsKeys.every((id) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; + if (!transaction || isManagedCardTransaction(transaction) || isScanning(transaction)) { + return false; + } + + const dates = transaction?.comment?.customUnit?.attributes?.dates; + if (isPerDiemRequest(transaction) && (!dates?.start || !dates?.end)) { + return false; + } + + const reportID = selectedTransactions[id]?.reportID; + const submitterReport = reportID ? getReportOrDraftReport(reportID, searchReports) : undefined; + if (submitterReport && !isCurrentUserSubmitter(submitterReport)) { + return false; + } + + const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`]; + if (hasCustomUnitOutOfPolicyViolation(transactionViolations)) { + return false; + } + + const report = reportID ? ((searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] as Report | undefined) ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) : undefined; + + if (isPerDiemRequest(transaction) && report?.policyID && defaultExpensePolicyID !== report.policyID) { + return false; + } + + if (isDistanceRequest(transaction) && reportID) { + const chatReportID = report?.chatReportID ?? report?.parentReportID; + const chatReport = chatReportID + ? ((searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] as Report | undefined) ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]) + : undefined; + const reportNVP = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; + const chatReportNVP = chatReportID ? allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${chatReportID}`] : undefined; + if (isArchivedReport(reportNVP) || isArchivedReport(chatReportNVP) || (activePolicyExpenseChat && chatReport && (isDM(chatReport) || isSelfDM(chatReport)))) { + return false; + } + } + + return true; + }); +} + function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const {isOffline} = useNetwork(); + const {isProduction} = useEnvironment(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {selectedTransactions, selectedReports, areAllMatchingItemsSelected, currentSearchResults, currentSearchKey} = useSearchStateContext(); @@ -125,6 +216,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {isBetaEnabled} = usePermissions(); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const defaultExpensePolicy = useDefaultExpensePolicy(); + // Cache the last search results that had data, so the merge option remains available // while results are temporarily unset (e.g. during sorting/loading). const lastNonEmptySearchResultsRef = useRef(undefined); @@ -165,6 +258,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { 'Exclamation', 'MoneyBag', 'ArrowSplit', + 'ExpenseCopy', 'QBOSquare', 'XeroSquare', 'NetSuiteSquare', @@ -721,6 +815,49 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); }, [selectedTransactionReportIDs, currentUserPersonalDetails?.accountID, currentSearchResults?.data]); + const duplicateHandlerRef = useRef<() => void>(() => {}); + const setDuplicateHandler = useCallback((handler: () => void) => { + duplicateHandlerRef.current = handler; + }, []); + const invokeDuplicateHandler = useCallback(() => { + duplicateHandlerRef.current(); + }, []); + + const activePolicyExpenseChat = useMemo( + () => getPolicyExpenseChat(currentUserPersonalDetails.accountID, defaultExpensePolicy?.id), + [currentUserPersonalDetails.accountID, defaultExpensePolicy?.id], + ); + + const isDuplicateOptionVisible = useMemo( + () => + !isProduction && + shouldShowBulkDuplicateOption({ + selectedTransactionsKeys, + selectedTransactions, + allTransactions, + allReports, + allTransactionViolations, + allReportNameValuePairs, + defaultExpensePolicyID: defaultExpensePolicy?.id, + activePolicyExpenseChat, + typeExpenseReport: isExpenseReportType, + searchData: currentSearchResults?.data, + }), + [ + isProduction, + selectedTransactionsKeys, + selectedTransactions, + allTransactions, + allReports, + allTransactionViolations, + allReportNameValuePairs, + defaultExpensePolicy?.id, + activePolicyExpenseChat, + isExpenseReportType, + currentSearchResults?.data, + ], + ); + const headerButtonsOptions = useMemo(() => { if (selectedTransactionsKeys.length === 0 || status == null || !hash) { return CONST.EMPTY_ARRAY as unknown as Array>; @@ -729,15 +866,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const options: Array> = []; const isAnyTransactionOnHold = Object.values(selectedTransactions).some((transaction) => transaction.isHeld); - const typeExpenseReport = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; - const getExportOptions = () => { const areFullReportsSelected = selectedTransactionReportIDs.length === selectedReportIDs.length && selectedTransactionReportIDs.every((id) => selectedReportIDs.includes(id)); const typeInvoice = queryJSON?.type === CONST.REPORT.TYPE.INVOICE; const typeExpense = queryJSON?.type === CONST.REPORT.TYPE.EXPENSE; const isAllOneTransactionReport = Object.values(selectedTransactions).every((transaction) => transaction.isFromOneTransactionReport); - const includeReportLevelExport = ((typeExpenseReport || typeInvoice) && areFullReportsSelected) || (typeExpense && !typeExpenseReport && isAllOneTransactionReport); + const includeReportLevelExport = ((isExpenseReportType || typeInvoice) && areFullReportsSelected) || (typeExpense && !isExpenseReportType && isAllOneTransactionReport); const policy = selectedPolicyIDs.length === 1 ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedPolicyIDs.at(0)}`] : undefined; const exportTemplates = getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy, includeReportLevelExport); @@ -745,7 +880,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportOptions: PopoverMenuItem[] = []; const connectedIntegration = getConnectedIntegration(policy); - const isReportsTab = typeExpenseReport; + const isReportsTab = isExpenseReportType; const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { @@ -893,7 +1028,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return [exportButtonOption]; } - const isExpenseReportSearch = typeExpenseReport || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const isExpenseReportSearch = isExpenseReportType || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const selectedTransactionsList = Object.values(selectedTransactions) .map((transaction) => transaction.transaction) .filter((transaction): transaction is Transaction => !!transaction); @@ -1161,7 +1296,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const canAllTransactionsBeMoved = selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport); - if (canAllTransactionsBeMoved && !hasMultipleOwners && !typeExpenseReport) { + if (canAllTransactionsBeMoved && !hasMultipleOwners && !isExpenseReportType) { options.push({ text: translate('iou.moveExpenses'), icon: expensifyIcons.DocumentMerge, @@ -1190,6 +1325,16 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } + if (isDuplicateOptionVisible) { + options.push({ + text: translate('search.bulkActions.duplicateExpense', {count: selectedTransactionsKeys.length}), + icon: expensifyIcons.ExpenseCopy, + value: CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE, + shouldCloseModalOnSelect: true, + onSelected: invokeDuplicateHandler, + }); + } + if (shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, selectedReports, queryJSON?.type)) { options.push({ icon: expensifyIcons.Trashcan, @@ -1263,8 +1408,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { currentUserPersonalDetails?.login, bankAccountList, styles.integrationIcon, - styles.colorMuted, - styles.fontWeightNormal, styles.textWrap, showConfirmModal, clearSelectedTransactions, @@ -1284,6 +1427,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { localeCompare, firstTransaction, firstTransactionPolicy, + isDuplicateOptionVisible, + invokeDuplicateHandler, + isExpenseReportType, handleDeleteSelectedTransactions, theme.icon, styles.colorMuted, @@ -1350,7 +1496,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { handleNonReimbursablePaymentErrorModalClose, dismissModalAndUpdateUseHold, dismissRejectModalBasedOnAction, + isDuplicateOptionVisible, + setDuplicateHandler, + allTransactions, + allReports, + searchData: currentSearchResults?.data, }; } export default useSearchBulkActions; +export {shouldShowBulkDuplicateOption}; +export type {ShouldShowBulkDuplicateParams}; diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 197dcc276899..0b4fa4b3a837 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useCallback, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; @@ -21,6 +21,7 @@ import { canHoldUnholdReportAction, canRejectReportAction, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + getPolicyExpenseChat, getReportOrDraftReport, isInvoiceReport, isMoneyRequestReport as isMoneyRequestReportUtils, @@ -37,21 +38,19 @@ import type {Policy, Report, ReportAction, Session, Transaction} from '@src/type import useAllTransactions from './useAllTransactions'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDefaultExpensePolicy from './useDefaultExpensePolicy'; import useDeleteTransactions from './useDeleteTransactions'; import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; +import useEnvironment from './useEnvironment'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; import useReportIsArchived from './useReportIsArchived'; +import {shouldShowBulkDuplicateOption} from './useSearchBulkActions'; -// We do not use PRIMARY_REPORT_ACTIONS or SECONDARY_REPORT_ACTIONS because they weren't meant to be used in this situation. `value` property of returned options is later ignored. -const HOLD = 'HOLD'; -const UNHOLD = 'UNHOLD'; -const MOVE = 'MOVE'; -const MERGE = 'MERGE'; -const SPLIT = 'SPLIT'; +const {HOLD, UNHOLD, MOVE, MERGE, SPLIT, DUPLICATE} = CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES; function useSelectedTransactionsActions({ report, @@ -90,6 +89,7 @@ function useSelectedTransactionsActions({ const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const {getCurrencyDecimals} = useCurrencyListActions(); const expensifyIcons = useMemoizedLazyExpensifyIcons([ @@ -102,6 +102,7 @@ function useSelectedTransactionsActions({ 'ArrowCollapse', 'ArrowSplit', 'ThumbsDown', + 'ExpenseCopy', 'Pencil', ] as const); @@ -110,6 +111,9 @@ function useSelectedTransactionsActions({ const {isBetaEnabled} = usePermissions(); const {deleteTransactions} = useDeleteTransactions({report, reportActions, policy}); const {login, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const defaultExpensePolicy = useDefaultExpensePolicy(); + const {isProduction} = useEnvironment(); + const selectedTransactionsList = selectedTransactionIDs.reduce((acc, transactionID) => { const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; if (transaction) { @@ -151,6 +155,53 @@ function useSelectedTransactionsActions({ const {translate, localeCompare} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const selectedTransactionsForDuplicate = useMemo(() => { + const map: Record = {}; + for (const id of selectedTransactionIDs) { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; + map[id] = {reportID: transaction?.reportID}; + } + return map; + }, [selectedTransactionIDs, allTransactions]); + + const activePolicyExpenseChat = useMemo(() => getPolicyExpenseChat(currentUserAccountID, defaultExpensePolicy?.id), [currentUserAccountID, defaultExpensePolicy?.id]); + + const isDuplicateOptionVisible = useMemo( + () => + !isProduction && + shouldShowBulkDuplicateOption({ + selectedTransactionsKeys: selectedTransactionIDs, + selectedTransactions: selectedTransactionsForDuplicate, + allTransactions, + allReports, + allTransactionViolations, + allReportNameValuePairs, + defaultExpensePolicyID: defaultExpensePolicy?.id, + activePolicyExpenseChat, + typeExpenseReport: false, + searchData: undefined, + }), + [ + isProduction, + selectedTransactionIDs, + selectedTransactionsForDuplicate, + allTransactions, + allReports, + allTransactionViolations, + allReportNameValuePairs, + defaultExpensePolicy?.id, + activePolicyExpenseChat, + ], + ); + + const duplicateHandlerRef = useRef<() => void>(() => {}); + const setDuplicateHandler = useCallback((handler: () => void) => { + duplicateHandlerRef.current = handler; + }, []); + const invokeDuplicateHandler = useCallback(() => { + duplicateHandlerRef.current(); + }, []); + // eslint-disable-next-line @typescript-eslint/no-deprecated const isTrackExpenseThread = isTrackExpenseReport(report); const isInvoice = isInvoiceReport(report); @@ -423,6 +474,17 @@ function useSelectedTransactionsActions({ }); } + if (isDuplicateOptionVisible) { + // eslint-disable-next-line react-hooks/refs -- invokeDuplicateHandler reads a ref, but only at event-handler time (onSelected), never during render + options.push({ + text: translate('search.bulkActions.duplicateExpense', {count: selectedTransactionIDs.length}), + icon: expensifyIcons.ExpenseCopy, + value: DUPLICATE, + shouldCloseModalOnSelect: true, + onSelected: invokeDuplicateHandler, + }); + } + const canAllSelectedTransactionsBeRemoved = selectedTransactionsList.every((transaction) => { const canRemoveTransaction = canDeleteCardTransactionByLiabilityType(transaction); const action = getIOUActionForTransactionID(reportActions, transaction.transactionID); @@ -459,6 +521,10 @@ function useSelectedTransactionsActions({ isDeleteModalVisible, showDeleteModal, hideDeleteModal, + isDuplicateOptionVisible, + setDuplicateHandler, + allTransactions, + allReports, }; } diff --git a/src/languages/de.ts b/src/languages/de.ts index b7be0e9631ae..98ed57600f10 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7574,6 +7574,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc hold: 'Warteschleife', unhold: 'Zurückhalten aufheben', reject: 'Ablehnen', + duplicateExpense: ({count}: {count: number}) => `${count === 1 ? 'Ausgabe' : 'Ausgaben'} duplizieren`, noOptionsAvailable: 'Für die ausgewählte Ausgabengruppe sind keine Optionen verfügbar.', }, filtersHeader: 'Filter', diff --git a/src/languages/en.ts b/src/languages/en.ts index 0022c81752a1..a944e1827ccd 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7545,6 +7545,7 @@ const translations = { hold: 'Hold', unhold: 'Remove hold', reject: 'Reject', + duplicateExpense: ({count}: {count: number}) => `Duplicate ${count === 1 ? 'expense' : 'expenses'}`, noOptionsAvailable: 'No options available for the selected group of expenses.', }, filtersHeader: 'Filters', diff --git a/src/languages/es.ts b/src/languages/es.ts index 92346b3d6b8f..4f631b51b756 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7411,6 +7411,7 @@ ${amount} para ${merchant} - ${date}`, hold: 'Retener', unhold: 'Desbloquear', reject: 'Rechazar', + duplicateExpense: ({count}: {count: number}) => `Duplicar ${count === 1 ? 'gasto' : 'gastos'}`, noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.', }, filtersHeader: 'Filtros', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c2aa900f79c0..022f92dd0aeb 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7597,6 +7597,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e hold: 'En attente', unhold: 'Supprimer la mise en attente', reject: 'Rejeter', + duplicateExpense: ({count}: {count: number}) => `Dupliquer ${count === 1 ? 'la dépense' : 'les dépenses'}`, noOptionsAvailable: 'Aucune option n’est disponible pour le groupe de dépenses sélectionné.', }, filtersHeader: 'Filtres', diff --git a/src/languages/it.ts b/src/languages/it.ts index b910fed6f908..1e8afaf4cb02 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7563,6 +7563,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, hold: 'Metti in attesa', unhold: 'Rimuovi blocco', reject: 'Rifiuta', + duplicateExpense: ({count}: {count: number}) => `Duplica ${count === 1 ? 'spesa' : 'spese'}`, noOptionsAvailable: 'Nessuna opzione disponibile per il gruppo di spese selezionato.', }, filtersHeader: 'Filtri', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0ebc55937bb9..39282bb575f9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7473,6 +7473,7 @@ ${reportName} hold: '保留', unhold: '保留を解除', reject: '却下', + duplicateExpense: ({count}: {count: number}) => `${count === 1 ? '経費を複製' : '経費を一括複製'}`, noOptionsAvailable: '選択した経費グループには利用できるオプションがありません。', }, filtersHeader: 'フィルター', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2f7a35e0a223..c5e17fcf067f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7540,6 +7540,7 @@ Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, hold: 'Vasthouden', unhold: 'Blokkering opheffen', reject: 'Afwijzen', + duplicateExpense: ({count}: {count: number}) => `${count === 1 ? 'Declaratie' : 'Declaraties'} dupliceren`, noOptionsAvailable: 'Geen opties beschikbaar voor de geselecteerde groep onkosten.', }, filtersHeader: 'Filters', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 2d28dbb7c4be..b34b9e740a8a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7530,6 +7530,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, hold: 'Wstrzymaj', unhold: 'Usuń blokadę', reject: 'Odrzuć', + duplicateExpense: ({count}: {count: number}) => `Duplikuj ${count === 1 ? 'wydatek' : 'wydatki'}`, noOptionsAvailable: 'Brak opcji dostępnych dla wybranej grupy wydatków.', }, filtersHeader: 'Filtry', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b522847f5a5d..10e1410c3a63 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7533,6 +7533,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, hold: 'Reter', unhold: 'Remover bloqueio', reject: 'Rejeitar', + duplicateExpense: ({count}: {count: number}) => `Duplicar ${count === 1 ? 'despesa' : 'despesas'}`, noOptionsAvailable: 'Nenhuma opção disponível para o grupo de despesas selecionado.', }, filtersHeader: 'Filtros', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 50070945fd46..4b78353bf446 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7337,6 +7337,7 @@ ${reportName} hold: '暂挂', unhold: '解除保留', reject: '拒绝', + duplicateExpense: ({count}: {count: number}) => `复制${count === 1 ? '报销' : '报销费用'}`, noOptionsAvailable: '所选报销的费用组没有可用选项。', }, filtersHeader: '筛选器', diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 07f42760db9f..c7cb36b7b74f 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -8,6 +8,7 @@ import type {MergeDuplicatesParams, ResolveDuplicatesParams} from '@libs/API/par import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {getExistingTransactionID} from '@libs/IOUUtils'; import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {getIOUActionForReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -17,6 +18,7 @@ import { buildOptimisticHoldReportAction, buildOptimisticResolvedDuplicatesReportAction, buildTransactionThread, + generateReportID, getTransactionDetails, } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; @@ -656,6 +658,8 @@ type DuplicateExpenseTransactionParams = { personalDetails: OnyxEntry; recentWaypoints: OnyxEntry; targetPolicyTags: OnyxEntry; + shouldPlaySound?: boolean; + shouldDeferAutoSubmit?: boolean; }; function duplicateExpenseTransaction({ @@ -678,6 +682,8 @@ function duplicateExpenseTransaction({ personalDetails, recentWaypoints, targetPolicyTags, + shouldPlaySound = true, + shouldDeferAutoSubmit = false, }: DuplicateExpenseTransactionParams) { if (!transaction) { return; @@ -705,6 +711,7 @@ function duplicateExpenseTransaction({ action: CONST.IOU.ACTION.CREATE, transactionParams, shouldHandleNavigation: false, + shouldPlaySound, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled, currentUserAccountIDParam: userAccountID, @@ -717,6 +724,7 @@ function duplicateExpenseTransaction({ isSelfTourViewed, betas, personalDetails, + shouldDeferAutoSubmit, }; // If no workspace is provided the expense should be unreported @@ -928,5 +936,102 @@ function duplicateReport({ playSound(SOUNDS.DONE); } -export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction, duplicateReport}; -export type {DuplicateExpenseTransactionParams, DuplicateReportParams}; +type BulkDuplicateExpensesParams = { + transactionIDs: string[]; + allTransactions: NonNullable>; + sourcePolicyIDMap: Record; + targetPolicy: OnyxEntry; + targetPolicyCategories: OnyxEntry; + targetPolicyTags: OnyxEntry; + targetReport: OnyxEntry; + personalDetails: OnyxEntry; + isASAPSubmitBetaEnabled: boolean; + introSelected: OnyxEntry; + activePolicyID: string | undefined; + quickAction: OnyxEntry; + policyRecentlyUsedCurrencies: string[]; + isSelfTourViewed: boolean; + transactionDrafts: Record | undefined; + draftTransactionIDs: string[]; + betas: OnyxEntry; + recentWaypoints: OnyxEntry; +}; + +function bulkDuplicateExpenses({ + transactionIDs, + allTransactions, + sourcePolicyIDMap, + targetPolicy, + targetPolicyCategories, + targetPolicyTags, + targetReport, + personalDetails, + isASAPSubmitBetaEnabled, + introSelected, + activePolicyID, + quickAction, + policyRecentlyUsedCurrencies, + isSelfTourViewed, + transactionDrafts, + draftTransactionIDs, + betas, + recentWaypoints, +}: BulkDuplicateExpensesParams) { + const transactionsToDuplicate = transactionIDs.map((id) => allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]).filter((t): t is OnyxTypes.Transaction => !!t); + + if (transactionsToDuplicate.length === 0) { + return; + } + + const optimisticChatReportID = generateReportID(); + const optimisticIOUReportID = generateReportID(); + + // After the first iteration creates a new optimistic IOU report, subsequent + // iterations must know its ID so getMoneyRequestInformation can find and + // MERGE into it instead of SET-overwriting it. We carry a local copy of + // targetReport whose iouReportID is patched after the first pass. + let currentTargetReport = targetReport; + + for (let i = 0; i < transactionsToDuplicate.length; i++) { + const item = transactionsToDuplicate.at(i); + if (!item) { + continue; + } + const isLastExpense = i === transactionsToDuplicate.length - 1; + const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; + + duplicateExpenseTransaction({ + transaction: item, + optimisticChatReportID, + optimisticIOUReportID, + isASAPSubmitBetaEnabled, + introSelected, + activePolicyID, + quickAction, + policyRecentlyUsedCurrencies, + isSelfTourViewed, + customUnitPolicyID: (isDistanceRequest(item) ? sourcePolicyIDMap[item.transactionID] : undefined) ?? targetPolicy?.id, + targetPolicy: targetPolicy ?? undefined, + targetPolicyCategories: targetPolicyCategories ?? {}, + targetReport: currentTargetReport, + existingTransactionDraft, + draftTransactionIDs, + betas, + personalDetails, + recentWaypoints, + targetPolicyTags, + shouldPlaySound: false, + shouldDeferAutoSubmit: !isLastExpense, + }); + + if (currentTargetReport && !currentTargetReport.iouReportID) { + currentTargetReport = {...currentTargetReport, iouReportID: optimisticIOUReportID}; + } + } + + playSound(SOUNDS.DONE); +} + +export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction, bulkDuplicateExpenses, duplicateReport}; +export type {DuplicateExpenseTransactionParams, BulkDuplicateExpensesParams, DuplicateReportParams}; diff --git a/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts b/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts new file mode 100644 index 000000000000..7c39e3bd8032 --- /dev/null +++ b/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts @@ -0,0 +1,866 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {renderHook, waitFor} from '@testing-library/react-native'; +import {useEffect} from 'react'; +import Onyx from 'react-native-onyx'; +import type {SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; +import useBulkDuplicateAction from '@hooks/useBulkDuplicateAction'; +import useSearchBulkActions from '@hooks/useSearchBulkActions'; +import {bulkDuplicateExpenses} from '@libs/actions/IOU/Duplicate'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyCategories, PolicyTagLists, Report} from '@src/types/onyx'; +import createRandomTransaction, {createRandomDistanceRequestTransaction} from '../../utils/collections/transaction'; + +jest.mock('@libs/actions/IOU/Duplicate', () => ({ + bulkDuplicateExpenses: jest.fn(), +})); + +jest.mock('@libs/actions/Search', () => ({ + getExportTemplates: jest.fn(() => []), + exportSearchItemsToCSV: jest.fn(), + queueExportSearchItemsToCSV: jest.fn(), + queueExportSearchWithTemplate: jest.fn(), + approveMoneyRequestOnSearch: jest.fn(), + bulkDeleteReports: jest.fn(), + getLastPolicyBankAccountID: jest.fn(), + getLastPolicyPaymentMethod: jest.fn(), + getPayMoneyOnSearchInvoiceParams: jest.fn(), + getPayOption: jest.fn(() => ({shouldEnableBulkPayOption: false, isFirstTimePayment: false})), + getReportType: jest.fn(), + getTotalFormattedAmount: jest.fn(() => ''), + isCurrencySupportWalletBulkPay: jest.fn(() => false), + payMoneyRequestOnSearch: jest.fn(), + submitMoneyRequestOnSearch: jest.fn(), + unholdMoneyRequestOnSearch: jest.fn(), +})); + +jest.mock('@libs/actions/MergeTransaction', () => ({ + setupMergeTransactionDataAndNavigate: jest.fn(), +})); + +jest.mock('@libs/actions/SplitExpenses.ts', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@libs/actions/Report', () => ({ + deleteAppReport: jest.fn(), + moveIOUReportToPolicy: jest.fn(), + moveIOUReportToPolicyAndInviteSubmitter: jest.fn(), +})); + +jest.mock('@libs/actions/User', () => ({ + setNameValuePair: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + getActiveRoute: jest.fn(() => '/test'), +})); + +const mockTranslate = jest.fn((key: string) => key); +const mockLocaleCompare = jest.fn((a: string, b: string) => a && b); +const mockFormatPhoneNumber = jest.fn((phone: string) => phone); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + translate: mockTranslate, + localeCompare: mockLocaleCompare, + formatPhoneNumber: mockFormatPhoneNumber, + }), +})); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => ({colorMuted: {}, fontWeightNormal: {}, textWrap: {}}), +})); + +jest.mock('@hooks/useTheme', () => ({ + __esModule: true, + default: () => ({icon: ''}), +})); + +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: false}), +})); + +jest.mock('@hooks/useEnvironment', () => ({ + __esModule: true, + default: () => ({isProduction: false, isDevelopment: true, environment: 'development'}), +})); + +jest.mock('@components/DelegateNoAccessModalProvider', () => ({ + useDelegateNoAccessState: () => ({isDelegateAccessRestricted: false}), + useDelegateNoAccessActions: () => ({showDelegateNoAccessModal: jest.fn()}), +})); + +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: jest.fn()}), +})); + +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: () => false}), +})); + +jest.mock('@hooks/useSelfDMReport', () => ({ + __esModule: true, + default: () => undefined, +})); + +jest.mock('@hooks/useBulkPayOptions', () => ({ + __esModule: true, + default: () => ({bulkPayButtonOptions: [], latestBankItems: []}), +})); + +let mockDefaultExpensePolicy: Policy | undefined; +jest.mock('@hooks/useDefaultExpensePolicy', () => ({ + __esModule: true, + default: () => mockDefaultExpensePolicy, +})); + +const mockClearSelectedTransactions = jest.fn(); +const mockSelectAllMatchingItems = jest.fn(); +let mockSelectedTransactions: SelectedTransactions = {}; +let mockSelectedReports: never[] = []; +let mockAreAllMatchingItemsSelected = false; + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchStateContext: () => ({ + selectedTransactions: mockSelectedTransactions, + selectedReports: mockSelectedReports, + areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected, + currentSearchResults: undefined, + }), + useSearchActionsContext: () => ({ + clearSelectedTransactions: mockClearSelectedTransactions, + selectAllMatchingItems: mockSelectAllMatchingItems, + }), +})); + +const CURRENT_USER_ACCOUNT_ID = 1; + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: jest.fn(() => ({ + login: 'test@example.com', + accountID: CURRENT_USER_ACCOUNT_ID, + email: 'test@example.com', + })), +})); + +const baseQueryJSON: SearchQueryJSON = { + inputQuery: 'type:expense status:all', + hash: 12345, + recentSearchHash: 12345, + similarSearchHash: 12345, + flatFilters: [], + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense'}, +}; + +function makeSelectedTransaction(overrides: Partial = {}): SelectedTransactions[string] { + return { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: 'report1', + policyID: 'policy1', + amount: 100, + currency: 'USD', + isFromOneTransactionReport: false, + ...overrides, + }; +} + +/** + * Renders useSearchBulkActions + useBulkDuplicateAction together (mirrors the real app tree + * where BulkDuplicateHandler mounts when the option is visible and populates the handler ref). + */ +function useSearchBulkActionsWithDuplicate({queryJSON}: {queryJSON: SearchQueryJSON}) { + const actions = useSearchBulkActions({queryJSON}); + const {setDuplicateHandler, allTransactions, allReports, searchData} = actions; + const handleDuplicate = useBulkDuplicateAction({ + selectedTransactionsKeys: Object.keys(mockSelectedTransactions), + allTransactions, + allReports, + searchData, + }); + useEffect(() => { + setDuplicateHandler(handleDuplicate); + }, [handleDuplicate, setDuplicateHandler]); + return actions; +} + +describe('useSearchBulkActions - duplicate option', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + mockSelectedTransactions = {}; + mockSelectedReports = []; + mockAreAllMatchingItemsSelected = false; + mockDefaultExpensePolicy = undefined; + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'}); + + const defaultReportIDs = ['report1', 'r0', 'r1', 'r2', 'r3']; + for (const reportID of defaultReportIDs) { + // eslint-disable-next-line no-await-in-loop + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + reportID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + reportName: `Report ${reportID}`, + }); + } + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + // ============================================================ + // Visibility tests + // ============================================================ + + it('should show duplicate option for a single cash expense', async () => { + const transactionID = '100'; + const transaction = { + ...createRandomTransaction(1), + transactionID, + managedCard: false, + }; + + mockSelectedTransactions = {[transactionID]: makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + const opt = result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE); + expect(opt).toBeDefined(); + expect(opt?.text).toBe('search.bulkActions.duplicateExpense'); + }); + }); + + it('should not show duplicate option for a card expense', async () => { + const transactionID = '200'; + const transaction = {...createRandomTransaction(1), transactionID, managedCard: true}; + + mockSelectedTransactions = {[transactionID]: makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should not show duplicate option when selection includes a mix of cash and card expenses', async () => { + const cashTxn = {...createRandomTransaction(1), transactionID: '300', managedCard: false}; + const cardTxn = {...createRandomTransaction(2), transactionID: '301', managedCard: true}; + + mockSelectedTransactions = { + '300': makeSelectedTransaction({reportID: 'r1'}), + '301': makeSelectedTransaction({reportID: 'r2'}), + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}300`, cashTxn); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}301`, cardTxn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should show duplicate option for multiple cash expenses', async () => { + const txn1 = {...createRandomTransaction(1), transactionID: '400', managedCard: false}; + const txn2 = {...createRandomTransaction(2), transactionID: '401', managedCard: false}; + + mockSelectedTransactions = { + '400': makeSelectedTransaction({reportID: 'r1'}), + '401': makeSelectedTransaction({reportID: 'r2'}), + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}400`, txn1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}401`, txn2); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + }); + + it('should not show duplicate option for expense_report type', async () => { + const txn = {...createRandomTransaction(1), transactionID: '500', managedCard: false}; + mockSelectedTransactions = {'500': makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}500`, txn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: {...baseQueryJSON, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}})); + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should not show duplicate option when no transactions are selected', () => { + mockSelectedTransactions = {}; + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should show duplicate option for a Per Diem expense with dates', async () => { + const txnID = '1500'; + const txn = { + ...createRandomTransaction(1), + transactionID: txnID, + managedCard: false, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + attributes: {dates: {start: '2026-03-01', end: '2026-03-05'}}, + }, + }, + }; + + mockSelectedTransactions = {[txnID]: makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + }); + + it('should show duplicate option for a Distance expense', async () => { + const txnID = '1600'; + const txn = { + ...createRandomDistanceRequestTransaction(1), + transactionID: txnID, + managedCard: false, + }; + + mockSelectedTransactions = {[txnID]: makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + }); + + it('should show duplicate option for a mix of cash, Per Diem, and Distance expenses', async () => { + const cashTxn = {...createRandomTransaction(1), transactionID: '1700', managedCard: false}; + const perDiemTxn = { + ...createRandomTransaction(2), + transactionID: '1701', + managedCard: false, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + attributes: {dates: {start: '2026-03-01', end: '2026-03-05'}}, + }, + }, + }; + const distanceTxn = {...createRandomDistanceRequestTransaction(3), transactionID: '1702', managedCard: false}; + + mockSelectedTransactions = { + '1700': makeSelectedTransaction({reportID: 'r1'}), + '1701': makeSelectedTransaction({reportID: 'r2'}), + '1702': makeSelectedTransaction({reportID: 'r3'}), + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1700`, cashTxn); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1701`, perDiemTxn); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1702`, distanceTxn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + }); + + it('should not show duplicate option when card expense is mixed with Per Diem or Distance', async () => { + const cardTxn = {...createRandomTransaction(1), transactionID: '1800', managedCard: true}; + const perDiemTxn = { + ...createRandomTransaction(2), + transactionID: '1801', + managedCard: false, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + attributes: {dates: {start: '2026-03-01', end: '2026-03-05'}}, + }, + }, + }; + const distanceTxn = {...createRandomDistanceRequestTransaction(3), transactionID: '1802', managedCard: false}; + + mockSelectedTransactions = { + '1800': makeSelectedTransaction({reportID: 'r1'}), + '1801': makeSelectedTransaction({reportID: 'r2'}), + '1802': makeSelectedTransaction({reportID: 'r3'}), + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1800`, cardTxn); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1801`, perDiemTxn); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1802`, distanceTxn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should show duplicate option for expenses in any state (submit, approve, pay, done)', async () => { + const states = [CONST.SEARCH.ACTION_TYPES.SUBMIT, CONST.SEARCH.ACTION_TYPES.APPROVE, CONST.SEARCH.ACTION_TYPES.PAY, CONST.SEARCH.ACTION_TYPES.DONE]; + + const transactionIDs = states.map((_, i) => `1900${i}`); + for (const [i, txnID] of transactionIDs.entries()) { + const txn = {...createRandomTransaction(i + 1), transactionID: txnID, managedCard: false}; + // eslint-disable-next-line no-await-in-loop + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + } + + mockSelectedTransactions = Object.fromEntries( + transactionIDs.map((id, i) => [id, makeSelectedTransaction({reportID: `r${i}`, action: states.at(i) ?? CONST.SEARCH.ACTION_TYPES.VIEW})]), + ); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + }); + + it('should show duplicate option for expenses with VIEW action (unreported/paid)', async () => { + const txnID = '2000'; + const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false}; + mockSelectedTransactions = {[txnID]: makeSelectedTransaction({action: CONST.SEARCH.ACTION_TYPES.VIEW})}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + }); + + // ============================================================ + // Data / invocation tests + // ============================================================ + + it('should call bulkDuplicateExpenses with all selected transaction IDs', async () => { + const txn1 = {...createRandomTransaction(1), transactionID: '600', managedCard: false}; + const txn2 = {...createRandomTransaction(2), transactionID: '601', managedCard: false}; + + mockSelectedTransactions = { + '600': makeSelectedTransaction({reportID: 'r1'}), + '601': makeSelectedTransaction({reportID: 'r2'}), + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}600`, txn1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}601`, txn2); + + const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.(); + + expect(bulkDuplicateExpenses).toHaveBeenCalledTimes(1); + expect(bulkDuplicateExpenses).toHaveBeenCalledWith( + expect.objectContaining({ + transactionIDs: expect.arrayContaining(['600', '601']), + }), + ); + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(undefined, true); + }); + + it('should pass defaultExpensePolicy as targetPolicy when available', async () => { + const policyID = 'POLICY_TEAM_1'; + const teamPolicy = {id: policyID, type: CONST.POLICY.TYPE.TEAM, name: 'Test Workspace'} as Policy; + mockDefaultExpensePolicy = teamPolicy; + + const txnID = '700'; + const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false}; + mockSelectedTransactions = {[txnID]: makeSelectedTransaction({policyID})}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.(); + + expect(bulkDuplicateExpenses).toHaveBeenCalledWith( + expect.objectContaining({ + targetPolicy: teamPolicy, + }), + ); + }); + + it('should pass undefined targetPolicy when no defaultExpensePolicy exists', async () => { + mockDefaultExpensePolicy = undefined; + + const txnID = '800'; + const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false}; + mockSelectedTransactions = {[txnID]: makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.(); + + expect(bulkDuplicateExpenses).toHaveBeenCalledWith( + expect.objectContaining({ + targetPolicy: undefined, + }), + ); + }); + + it('should resolve policy categories and tags from defaultExpensePolicy', async () => { + const policyID = 'POLICY_CAT_TEST'; + const teamPolicy = {id: policyID, type: CONST.POLICY.TYPE.TEAM, name: 'Category WS'} as Policy; + mockDefaultExpensePolicy = teamPolicy; + + const categories: PolicyCategories = { + Food: {name: 'Food', enabled: true, areCommentsRequired: false, externalID: '', origin: ''}, + Travel: {name: 'Travel', enabled: true, areCommentsRequired: false, externalID: '', origin: ''}, + }; + const tags: PolicyTagLists = { + Tag: {name: 'Tag', required: false, tags: {ProjectA: {name: 'ProjectA', enabled: true, 'GL Code': '', pendingAction: undefined}}, orderWeight: 1}, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, categories); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, tags); + + const txnID = '900'; + const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false, category: 'Food'}; + mockSelectedTransactions = {[txnID]: makeSelectedTransaction({policyID})}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.(); + + expect(bulkDuplicateExpenses).toHaveBeenCalledWith( + expect.objectContaining({ + targetPolicy: teamPolicy, + targetPolicyCategories: categories, + targetPolicyTags: tags, + }), + ); + }); + + it('should pass activePolicyExpenseChat as targetReport when it exists', async () => { + const policyID = 'POLICY_REPORT_TEST'; + const teamPolicy = {id: policyID, type: CONST.POLICY.TYPE.TEAM, name: 'Report WS'} as Policy; + mockDefaultExpensePolicy = teamPolicy; + + const policyExpenseChat: Report = { + reportID: 'pec_123', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + reportName: 'Test Workspace', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + + const txnID = '1000'; + const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false}; + mockSelectedTransactions = {[txnID]: makeSelectedTransaction({policyID})}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.(); + + expect(bulkDuplicateExpenses).toHaveBeenCalledWith( + expect.objectContaining({ + targetReport: expect.objectContaining({ + reportID: policyExpenseChat.reportID, + policyID, + }), + }), + ); + }); + + it('should duplicate expenses from multiple policies using the default expense policy data', async () => { + const defaultPolicyID = 'DEFAULT_POLICY'; + const otherPolicyID = 'OTHER_POLICY'; + const teamPolicy = {id: defaultPolicyID, type: CONST.POLICY.TYPE.TEAM, name: 'Default WS'} as Policy; + mockDefaultExpensePolicy = teamPolicy; + + const defaultCategories: PolicyCategories = { + Materials: {name: 'Materials', enabled: true, areCommentsRequired: false, externalID: '', origin: ''}, + }; + const otherCategories: PolicyCategories = { + Supplies: {name: 'Supplies', enabled: true, areCommentsRequired: false, externalID: '', origin: ''}, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultPolicyID}`, defaultCategories); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${otherPolicyID}`, otherCategories); + + const txn1 = {...createRandomTransaction(1), transactionID: '1100', managedCard: false, category: 'Materials'}; + const txn2 = {...createRandomTransaction(2), transactionID: '1101', managedCard: false, category: 'Supplies'}; + + mockSelectedTransactions = { + '1100': makeSelectedTransaction({reportID: 'r1', policyID: defaultPolicyID}), + '1101': makeSelectedTransaction({reportID: 'r2', policyID: otherPolicyID}), + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1100`, txn1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1101`, txn2); + + const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.(); + + expect(bulkDuplicateExpenses).toHaveBeenCalledTimes(1); + expect(bulkDuplicateExpenses).toHaveBeenCalledWith( + expect.objectContaining({ + transactionIDs: expect.arrayContaining(['1100', '1101']), + targetPolicy: teamPolicy, + targetPolicyCategories: defaultCategories, + }), + ); + }); + + it('should pass empty categories and tags when defaultExpensePolicy is undefined', async () => { + mockDefaultExpensePolicy = undefined; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}somePolicyID`, { + Ignored: {name: 'Ignored', enabled: true, areCommentsRequired: false, externalID: '', origin: ''}, + }); + + const txnID = '1200'; + const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false, category: 'Ignored'}; + mockSelectedTransactions = {[txnID]: makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.(); + + expect(bulkDuplicateExpenses).toHaveBeenCalledWith( + expect.objectContaining({ + targetPolicy: undefined, + targetPolicyCategories: {}, + targetPolicyTags: {}, + }), + ); + }); + + it('should include allTransactions data in bulkDuplicateExpenses call', async () => { + const txn1 = {...createRandomTransaction(1), transactionID: '1300', managedCard: false, category: 'A', amount: 5000}; + const txn2 = {...createRandomTransaction(2), transactionID: '1301', managedCard: false, category: 'B', amount: 7500}; + + mockSelectedTransactions = { + '1300': makeSelectedTransaction({reportID: 'r1'}), + '1301': makeSelectedTransaction({reportID: 'r2'}), + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1300`, txn1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1301`, txn2); + + const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined(); + }); + + result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.(); + + expect(bulkDuplicateExpenses).toHaveBeenCalledWith( + expect.objectContaining({ + allTransactions: expect.objectContaining({ + [`${ONYXKEYS.COLLECTION.TRANSACTION}1300`]: expect.objectContaining({transactionID: '1300', category: 'A', amount: 5000}), + [`${ONYXKEYS.COLLECTION.TRANSACTION}1301`]: expect.objectContaining({transactionID: '1301', category: 'B', amount: 7500}), + }), + }), + ); + }); + + it('should not show duplicate option for per-diem expense on non-default workspace', async () => { + const defaultPolicyID = 'DEFAULT_POLICY'; + const otherPolicyID = 'OTHER_POLICY'; + mockDefaultExpensePolicy = {id: defaultPolicyID, type: CONST.POLICY.TYPE.TEAM, name: 'Default WS'} as Policy; + + const txnID = '2100'; + const txn = { + ...createRandomTransaction(1), + transactionID: txnID, + managedCard: false, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL}}, + }; + + const report: Report = { + reportID: 'r_other', + policyID: otherPolicyID, + type: CONST.REPORT.TYPE.EXPENSE, + reportName: 'Other WS Report', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}r_other`, report); + + mockSelectedTransactions = {[txnID]: makeSelectedTransaction({reportID: 'r_other', policyID: otherPolicyID})}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should not show duplicate option for transaction with custom unit out of policy violation', async () => { + const txnID = '2200'; + const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false}; + + mockSelectedTransactions = {[txnID]: makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${txnID}`, [{name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION}]); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should not show duplicate option for distance expense from archived report', async () => { + const txnID = '2300'; + const reportID = 'r_archived'; + const txn = { + ...createRandomDistanceRequestTransaction(1), + transactionID: txnID, + managedCard: false, + }; + + const report: Report = { + reportID, + policyID: 'policy1', + type: CONST.REPORT.TYPE.EXPENSE, + reportName: 'Archived Report', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {private_isArchived: new Date().toISOString()}); + + mockSelectedTransactions = {[txnID]: makeSelectedTransaction({reportID})}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should not show duplicate option for distance expense from DM chat', async () => { + const defaultPolicyID = 'DEFAULT_POLICY'; + mockDefaultExpensePolicy = {id: defaultPolicyID, type: CONST.POLICY.TYPE.TEAM, name: 'Default WS'} as Policy; + + const txnID = '2400'; + const expenseReportID = 'r_expense_dm'; + const chatReportID = 'r_chat_dm'; + const txn = { + ...createRandomDistanceRequestTransaction(1), + transactionID: txnID, + managedCard: false, + }; + + const policyExpenseChat: Report = { + reportID: 'pec_dm', + policyID: defaultPolicyID, + type: CONST.REPORT.TYPE.EXPENSE, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + reportName: 'Default WS', + }; + const chatReport: Report = { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + reportName: 'DM Chat', + }; + const expenseReport: Report = { + reportID: expenseReportID, + policyID: defaultPolicyID, + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID, + reportName: 'DM Expense', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); + + mockSelectedTransactions = {[txnID]: makeSelectedTransaction({reportID: expenseReportID})}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); + + it('should not show duplicate option when all selected transactions are scanning', async () => { + const txnID = '1400'; + const txn = { + ...createRandomTransaction(1), + transactionID: txnID, + managedCard: false, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + modifiedMerchant: '', + receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_READY, source: 'test.jpg'}, + }; + + mockSelectedTransactions = {[txnID]: makeSelectedTransaction()}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0)); + expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined(); + }); +}); diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index 87c2870cd46c..8317feede74a 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -520,7 +520,7 @@ describe('useSelectedTransactionsActions', () => { expect(result.current.options.length).toBeGreaterThan(0); }); - const holdOption = result.current.options.find((option) => option.value === 'HOLD'); + const holdOption = result.current.options.find((option) => option.value === 'hold'); expect(holdOption).toBeDefined(); expect(holdOption?.text).toBe('iou.hold'); }); @@ -568,7 +568,7 @@ describe('useSelectedTransactionsActions', () => { expect(result.current.options.length).toBeGreaterThan(0); }); - const holdOption = result.current.options.find((option) => option.value === 'HOLD'); + const holdOption = result.current.options.find((option) => option.value === 'hold'); holdOption?.onSelected?.(); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS.getRoute({reportID: report.reportID})); @@ -618,7 +618,7 @@ describe('useSelectedTransactionsActions', () => { expect(result.current.options.length).toBeGreaterThan(0); }); - const unholdOption = result.current.options.find((option) => option.value === 'UNHOLD'); + const unholdOption = result.current.options.find((option) => option.value === 'unhold'); expect(unholdOption).toBeDefined(); expect(unholdOption?.text).toBe('iou.unhold'); @@ -673,7 +673,7 @@ describe('useSelectedTransactionsActions', () => { expect(result.current.options.length).toBeGreaterThan(0); }); - const unholdOption = result.current.options.find((option) => option.value === 'UNHOLD'); + const unholdOption = result.current.options.find((option) => option.value === 'unhold'); expect(unholdOption).toBeDefined(); unholdOption?.onSelected?.(); @@ -722,11 +722,11 @@ describe('useSelectedTransactionsActions', () => { // The EXPORT option appears immediately, but MOVE depends on selectedTransactionsList // which requires the Onyx transaction data to be fully loaded. await waitFor(() => { - const moveOption = result.current.options.find((option) => option.value === 'MOVE'); + const moveOption = result.current.options.find((option) => option.value === 'move'); expect(moveOption).toBeDefined(); }); - const moveOption = result.current.options.find((option) => option.value === 'MOVE'); + const moveOption = result.current.options.find((option) => option.value === 'move'); expect(moveOption?.text).toBe('iou.moveExpenses'); }); @@ -767,7 +767,7 @@ describe('useSelectedTransactionsActions', () => { ); await waitFor(() => { - const moveOption = result.current.options.find((option) => option.value === 'MOVE'); + const moveOption = result.current.options.find((option) => option.value === 'move'); expect(moveOption).toBeDefined(); }); @@ -829,11 +829,11 @@ describe('useSelectedTransactionsActions', () => { // The EXPORT option appears immediately, but SPLIT depends on selectedTransactionsList // which requires the Onyx transaction data to be fully loaded. await waitFor(() => { - const splitOption = result.current.options.find((option) => option.value === 'SPLIT'); + const splitOption = result.current.options.find((option) => option.value === 'split'); expect(splitOption).toBeDefined(); }); - const splitOption = result.current.options.find((option) => option.value === 'SPLIT'); + const splitOption = result.current.options.find((option) => option.value === 'split'); expect(splitOption?.text).toBe('iou.split'); splitOption?.onSelected?.(); @@ -876,11 +876,11 @@ describe('useSelectedTransactionsActions', () => { // The EXPORT option appears immediately, but MERGE depends on selectedTransactionsList // which requires the Onyx transaction data to be fully loaded. await waitFor(() => { - const mergeOption = result.current.options.find((option) => option.value === 'MERGE'); + const mergeOption = result.current.options.find((option) => option.value === 'merge'); expect(mergeOption).toBeDefined(); }); - const mergeOption = result.current.options.find((option) => option.value === 'MERGE'); + const mergeOption = result.current.options.find((option) => option.value === 'merge'); expect(mergeOption?.text).toBe('common.merge'); mergeOption?.onSelected?.();