Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
5c80f02
[Duplicate Expense] Allow bulk duplication.
Krishna2323 Mar 10, 2026
0a910f3
add tests.
Krishna2323 Mar 10, 2026
17b8a60
refactor code.
Krishna2323 Mar 10, 2026
cff4f62
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 10, 2026
e2b5c55
minor updates.
Krishna2323 Mar 10, 2026
2dae542
fix optimistic action issue.
Krishna2323 Mar 10, 2026
5760d55
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 10, 2026
9591889
fix tests and ESLint.
Krishna2323 Mar 10, 2026
ab91ed8
fix tests.
Krishna2323 Mar 10, 2026
c0b25f5
add more tests.
Krishna2323 Mar 10, 2026
2de7466
add missing translations.
Krishna2323 Mar 10, 2026
55447e2
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 13, 2026
0bb2736
fix prettier.
Krishna2323 Mar 13, 2026
3b6a21e
Add submitter and Per Diem date filters to bulk duplicate option
Krishna2323 Mar 14, 2026
dca96c3
Merge branch 'Expensify:main' into krishna2323/issue/84281
Krishna2323 Mar 15, 2026
fe9b4e0
address codex review comments.
Krishna2323 Mar 15, 2026
d2c9894
update tests.
Krishna2323 Mar 15, 2026
c2150c8
perf: defer bulk-duplicate Onyx subscriptions until the option is vis…
Krishna2323 Mar 17, 2026
d2802aa
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 17, 2026
d6e1f5d
perf: hoist searchReports computation outside the .every() loop.
Krishna2323 Mar 17, 2026
9036e5c
Merge branch 'Expensify:main' into krishna2323/issue/84281
Krishna2323 Mar 18, 2026
370c6e3
fix: resolve source reports from search data instead of only Onyx.
Krishna2323 Mar 18, 2026
556354f
fix: carry forward iouReportID across bulk-duplicate loop iterations.
Krishna2323 Mar 18, 2026
f59ca89
Merge branch 'Expensify:main' into krishna2323/issue/84281
Krishna2323 Mar 20, 2026
7f485d8
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 22, 2026
01cc84f
fix ESLint.
Krishna2323 Mar 22, 2026
e3ec590
fix: address review comments — plural text, displayName, production gate
Krishna2323 Mar 23, 2026
1f1e7b3
update translations.
Krishna2323 Mar 23, 2026
b96226c
fix translations.
Krishna2323 Mar 23, 2026
ecd002e
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 25, 2026
7546137
add bulk duplicate expense option on report details page.
Krishna2323 Mar 25, 2026
e4a450b
clear selection on duplicate.
Krishna2323 Mar 25, 2026
6e10ed9
Merge branch 'Expensify:main' into krishna2323/issue/84281
Krishna2323 Mar 27, 2026
7cd5378
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 29, 2026
db0da62
fix prettier and lint.
Krishna2323 Mar 29, 2026
efa3ce3
Remove manual useMemo/useCallback from React Compiler-compatible hooks
Krishna2323 Mar 29, 2026
d003789
Reuse BulkDuplicateHandler on report details page to avoid unconditio…
Krishna2323 Mar 29, 2026
255d93d
Fix duplicate handler not firing from MoneyReportHeader and remove de…
Krishna2323 Mar 30, 2026
aaed1b8
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 30, 2026
a124b9c
fix TS.
Krishna2323 Mar 30, 2026
5bbe3b6
Memoize duplicate-related computations in useSelectedTransactionsActions
Krishna2323 Mar 30, 2026
b84f459
fix TS.
Krishna2323 Mar 30, 2026
6551b4d
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Mar 31, 2026
77cb659
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Apr 2, 2026
09c81ea
Suppress per-item sound and defer auto-submit in bulkDuplicateExpenses
Krishna2323 Apr 2, 2026
d3be3f3
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Apr 3, 2026
0ce86c5
Move bulk action type constants to CONST.REPORT and annotate eslint s…
Krishna2323 Apr 6, 2026
f40173f
Merge branch 'Expensify:main' into krishna2323/issue/84281
Krishna2323 Apr 6, 2026
3542312
remove duplicate dependency.
Krishna2323 Apr 6, 2026
0d7a532
fix tests.
Krishna2323 Apr 7, 2026
2d3c64e
Merge branch 'Expensify:main' into krishna2323/issue/84281
Krishna2323 Apr 7, 2026
3ad6703
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Apr 8, 2026
fe9eb48
Merge branch 'main' into krishna2323/issue/84281
Krishna2323 Apr 9, 2026
0a2f1b0
fix: remove stale shouldShowMoreContent block from merge conflict res…
Krishna2323 Apr 9, 2026
af30acd
fix: show bulk duplicate option for unreported expenses
Krishna2323 Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7240,6 +7240,7 @@ const CONST = {
REJECT: 'reject',
CHANGE_REPORT: 'changeReport',
SPLIT: 'split',
DUPLICATE: 'duplicate',
},
TRANSACTION_TYPE: {
CASH: 'cash',
Expand Down
132 changes: 131 additions & 1 deletion src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {hasSeenTourSelector} from '@selectors/Onboarding';
import {validTransactionDraftsSelector} from '@selectors/TransactionDraft';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager} from 'react-native';
import type {OnyxCollection} from 'react-native-onyx';
Expand All @@ -10,6 +12,7 @@ import type {PopoverMenuItem} from '@components/PopoverMenu';
import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext';
import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader/SearchPageHeader';
import type {BulkPaySelectionData, PaymentData, SearchQueryJSON} from '@components/Search/types';
import {bulkDuplicateExpenses} from '@libs/actions/IOU/Duplicate';
import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction';
import {deleteAppReport, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report';
import {
Expand Down Expand Up @@ -39,16 +42,20 @@ import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyU
import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils';
import {
getIntegrationIcon,
getPolicyExpenseChat,
getReportOrDraftReport,
isArchivedReport,
isBusinessInvoiceRoom,
isCurrentUserSubmitter,
isDM,
isExpenseReport as isExpenseReportUtil,
isInvoiceReport,
isIOUReport as isIOUReportUtil,
isSelfDM,
} from '@libs/ReportUtils';
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} from '@userActions/IOU';
import {openOldDotLink} from '@userActions/Link';
Expand All @@ -60,6 +67,7 @@ import useAllTransactions from './useAllTransactions';
import useBulkPayOptions from './useBulkPayOptions';
import useConfirmModal from './useConfirmModal';
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
import useDefaultExpensePolicy from './useDefaultExpensePolicy';
import {useMemoizedLazyExpensifyIcons} from './useLazyAsset';
import useLocalize from './useLocalize';
import useNetwork from './useNetwork';
Expand Down Expand Up @@ -100,9 +108,24 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS);
const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);
const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS);
const personalPolicy = usePersonalPolicy();
const [userBillingGraceEndPeriodCollection] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END);

const defaultExpensePolicy = useDefaultExpensePolicy();
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 [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);

// 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<SearchResults | undefined>(undefined);
Expand All @@ -119,6 +142,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const {showConfirmModal} = useConfirmModal();
const {isBetaEnabled} = usePermissions();
const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW);
const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);
const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false);
const [rejectModalAction, setRejectModalAction] = useState<ValueOf<
typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT
Expand All @@ -144,6 +168,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
'Exclamation',
'MoneyBag',
'ArrowSplit',
'ExpenseCopy',
'QBOSquare',
'XeroSquare',
'NetSuiteSquare',
Expand Down Expand Up @@ -709,6 +734,53 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
);
}, [selectedTransactionReportIDs, currentUserPersonalDetails?.accountID, currentSearchResults?.data]);

const handleDuplicateSelectedTransactions = useCallback(() => {
const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id);
const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {};
const targetPolicyTags = defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {};

bulkDuplicateExpenses({
transactionIDs: selectedTransactionsKeys,
allTransactions: allTransactions ?? {},
targetPolicy: defaultExpensePolicy ?? undefined,
targetPolicyCategories: activePolicyCategories,
targetPolicyTags,
targetReport: activePolicyExpenseChat,
personalDetails,
isASAPSubmitBetaEnabled,
introSelected,
activePolicyID,
quickAction,
policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
isSelfTourViewed,
transactionDrafts,
draftTransactionIDs,
betas,
recentWaypoints,
});

clearSelectedTransactions(undefined, true);
}, [
selectedTransactionsKeys,
accountID,
defaultExpensePolicy,
allTransactions,
allPolicyCategories,
allPolicyTags,
personalDetails,
isASAPSubmitBetaEnabled,
introSelected,
activePolicyID,
quickAction,
policyRecentlyUsedCurrencies,
isSelfTourViewed,
transactionDrafts,
draftTransactionIDs,
betas,
recentWaypoints,
clearSelectedTransactions,
]);

const headerButtonsOptions = useMemo(() => {
if (selectedTransactionsKeys.length === 0 || status == null || !hash) {
return CONST.EMPTY_ARRAY as unknown as Array<DropdownOption<SearchHeaderOptionValue>>;
Expand Down Expand Up @@ -1127,6 +1199,59 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
});
}

const activePolicyExpenseChat = getPolicyExpenseChat(currentUserPersonalDetails.accountID, defaultExpensePolicy?.id);
const shouldShowDuplicateOption =
!typeExpenseReport &&
selectedTransactionsKeys.length > 0 &&
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;
if (reportID && !isCurrentUserSubmitter(getReportOrDraftReport(reportID))) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve submitter checks against search snapshot reports

The duplicate-option predicate uses isCurrentUserSubmitter(getReportOrDraftReport(reportID)), which only consults the global report collection. In search flows, selected rows can come from currentSearchResults.data (the hook already handles this for transactions via useAllTransactions()), so a report that is present in the snapshot but not yet hydrated in the collection is treated as missing and therefore “not submitter,” hiding bulk duplicate for otherwise eligible expenses.

Useful? React with 👍 / 👎.

return false;
}
const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`];
if (hasCustomUnitOutOfPolicyViolation(transactionViolations)) {
return false;
}
if (isPerDiemRequest(transaction)) {
const report = reportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] : undefined;
if (report?.policyID && defaultExpensePolicy?.id !== report.policyID) {
return false;
}
}
if (isDistanceRequest(transaction) && reportID) {
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
const chatReportID = report?.chatReportID ?? report?.parentReportID;
const chatReport = chatReportID ? 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;
const isReportArchived = isArchivedReport(reportNVP) || isArchivedReport(chatReportNVP);
if (isReportArchived || (activePolicyExpenseChat && chatReport && (isDM(chatReport) || isSelfDM(chatReport)))) {
return false;
}
}
return true;
});

if (shouldShowDuplicateOption) {
options.push({
text: translate('search.bulkActions.duplicateExpense'),
icon: expensifyIcons.ExpenseCopy,
value: CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE,
shouldCloseModalOnSelect: true,
onSelected: () => {
handleDuplicateSelectedTransactions();
},
});
}

if (shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, selectedReports, queryJSON?.type)) {
options.push({
icon: expensifyIcons.Trashcan,
Expand Down Expand Up @@ -1176,6 +1301,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
expensifyIcons.ArrowCollapse,
expensifyIcons.DocumentMerge,
expensifyIcons.ArrowSplit,
expensifyIcons.ExpenseCopy,
expensifyIcons.Trashcan,
expensifyIcons.Exclamation,
translate,
Expand Down Expand Up @@ -1217,6 +1343,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
localeCompare,
firstTransaction,
firstTransactionPolicy,
allTransactions,
allReportNameValuePairs,
defaultExpensePolicy?.id,
handleDuplicateSelectedTransactions,
handleDeleteSelectedTransactions,
theme.icon,
styles.colorMuted,
Expand Down
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7313,6 +7313,7 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und
hold: 'Warteschleife',
unhold: 'Zurückhalten aufheben',
reject: 'Ablehnen',
duplicateExpense: 'Doppelte Ausgabe',
noOptionsAvailable: 'Für die ausgewählte Ausgabengruppe sind keine Optionen verfügbar.',
},
filtersHeader: 'Filter',
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7286,6 +7286,7 @@ const translations = {
hold: 'Hold',
unhold: 'Remove hold',
reject: 'Reject',
duplicateExpense: 'Duplicate expense',
noOptionsAvailable: 'No options available for the selected group of expenses.',
},
filtersHeader: 'Filters',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7148,6 +7148,7 @@ ${amount} para ${merchant} - ${date}`,
hold: 'Retener',
unhold: 'Desbloquear',
reject: 'Rechazar',
duplicateExpense: 'Duplicar gasto',
noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.',
},
filtersHeader: 'Filtros',
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7335,6 +7335,7 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip
hold: 'En attente',
unhold: 'Supprimer la mise en attente',
reject: 'Rejeter',
duplicateExpense: 'Dépense en double',
noOptionsAvailable: 'Aucune option n’est disponible pour le groupe de dépenses sélectionné.',
},
filtersHeader: 'Filtres',
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7299,6 +7299,7 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo
hold: 'Metti in attesa',
unhold: 'Rimuovi blocco',
reject: 'Rifiuta',
duplicateExpense: 'Spesa duplicata',
noOptionsAvailable: 'Nessuna opzione disponibile per il gruppo di spese selezionato.',
},
filtersHeader: 'Filtri',
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7220,6 +7220,7 @@ ${reportName}
hold: '保留',
unhold: '保留を解除',
reject: '却下',
duplicateExpense: '重複経費',
noOptionsAvailable: '選択した経費グループには利用できるオプションがありません。',
},
filtersHeader: 'フィルター',
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7278,6 +7278,7 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar
hold: 'Vasthouden',
unhold: 'Blokkering opheffen',
reject: 'Afwijzen',
duplicateExpense: 'Dubbele uitgave',
noOptionsAvailable: 'Geen opties beschikbaar voor de geselecteerde groep onkosten.',
},
filtersHeader: 'Filters',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7267,6 +7267,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i
hold: 'Wstrzymaj',
unhold: 'Usuń blokadę',
reject: 'Odrzuć',
duplicateExpense: 'Zduplikowany wydatek',
noOptionsAvailable: 'Brak opcji dostępnych dla wybranej grupy wydatków.',
},
filtersHeader: 'Filtry',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7270,6 +7270,7 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e
hold: 'Reter',
unhold: 'Remover bloqueio',
reject: 'Rejeitar',
duplicateExpense: 'Duplicar despesa',
noOptionsAvailable: 'Nenhuma opção disponível para o grupo de despesas selecionado.',
},
filtersHeader: 'Filtros',
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7103,6 +7103,7 @@ ${reportName}
hold: '暂挂',
unhold: '解除保留',
reject: '拒绝',
duplicateExpense: '重复报销',
noOptionsAvailable: '所选报销的费用组没有可用选项。',
},
filtersHeader: '筛选器',
Expand Down
Loading
Loading