diff --git a/src/CONST/index.ts b/src/CONST/index.ts index da2b20e50bd55..379ac88de971f 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6634,6 +6634,7 @@ const CONST = { HOLD: 'hold', UNHOLD: 'unhold', DELETE: 'delete', + REJECT: 'reject', CHANGE_REPORT: 'changeReport', }, TRANSACTION_TYPE: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5c9b2faacd532..3893661d76459 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -110,6 +110,7 @@ const ROUTES = { }, }, TRANSACTION_HOLD_REASON_RHP: 'search/hold', + SEARCH_REJECT_REASON_RHP: 'search/reject', MOVE_TRANSACTIONS_SEARCH_RHP: 'search/move-transactions', // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated diff --git a/src/SCREENS.ts b/src/SCREENS.ts index a713e3e51cef3..3b0945b501208 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -46,6 +46,7 @@ const SCREENS = { MONEY_REQUEST_REPORT: 'Search_Money_Request_Report', MONEY_REQUEST_REPORT_VERIFY_ACCOUNT: 'Search_Money_Request_Report_Verify_Account', MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS: 'Search_Money_Request_Report_Hold_Transactions', + MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS: 'Search_Money_Request_Report_Reject_Transactions', REPORT_RHP: 'Search_Report_RHP', REPORT_VERIFY_ACCOUNT: 'Search_Report_Verify_Account', ADVANCED_FILTERS_RHP: 'Search_Advanced_Filters_RHP', @@ -91,6 +92,7 @@ const SCREENS = { SAVED_SEARCH_RENAME_RHP: 'Search_Saved_Search_Rename_RHP', ADVANCED_FILTERS_IN_RHP: 'Search_Advanced_Filters_In_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', + SEARCH_REJECT_REASON_RHP: 'Search_Reject_Reason_RHP', TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP: 'Search_Transactions_Change_Report_RHP', }, SETTINGS: { diff --git a/src/languages/de.ts b/src/languages/de.ts index f3b0cb43e3bf7..6a1ad8f665865 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6412,6 +6412,7 @@ ${ delete: 'Löschen', hold: 'Halten', unhold: 'Halten entfernen', + reject: 'Ablehnen', noOptionsAvailable: 'Keine Optionen verfügbar für die ausgewählte Gruppe von Ausgaben.', }, filtersHeader: 'Filter', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2a35a7fa20742..74a57a3bd8d48 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6487,6 +6487,7 @@ const translations = { delete: 'Delete', hold: 'Hold', unhold: 'Remove hold', + reject: 'Reject', 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 e28676dc10dbe..49c033b7b0be8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6137,6 +6137,7 @@ ${amount} para ${merchant} - ${date}`, delete: 'Eliminar', hold: 'Retener', unhold: 'Desbloquear', + reject: 'Rechazar', 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 490e628737e37..d5557c5cf79e2 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6420,6 +6420,7 @@ ${ delete: 'Supprimer', hold: 'Attente', unhold: 'Supprimer la suspension', + reject: 'Refuser', noOptionsAvailable: 'Aucune option disponible pour le groupe de dépenses sélectionné.', }, filtersHeader: 'Filtres', diff --git a/src/languages/it.ts b/src/languages/it.ts index 22f55093723e7..f2125f229f03b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6400,6 +6400,7 @@ ${ delete: 'Elimina', hold: 'Attendere', unhold: 'Rimuovi blocco', + reject: 'Rifiuta', 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 5489423e508d9..f0f506e6ee4f4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6335,6 +6335,7 @@ ${ delete: '削除', hold: '保留', unhold: '保留を解除', + reject: '却下', noOptionsAvailable: '選択した経費グループには利用可能なオプションがありません。', }, filtersHeader: 'フィルター', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 35a1c6aa15b28..4aea2ded3481c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6382,6 +6382,7 @@ ${ delete: 'Verwijderen', hold: 'Vasthouden', unhold: 'Verwijder blokkering', + reject: 'Afwijzen', noOptionsAvailable: 'Geen opties beschikbaar voor de geselecteerde groep uitgaven.', }, filtersHeader: 'Filters', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 6ee7de5d3db64..51f993d342f95 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6369,6 +6369,7 @@ ${ delete: 'Usuń', hold: 'Trzymaj', unhold: 'Usuń blokadę', + reject: 'Odrzuć', noOptionsAvailable: 'Brak dostępnych opcji dla wybranej grupy wydatków.', }, filtersHeader: 'Filtry', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 5ebbabf2e8d1d..e75baf79b8ecb 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6381,6 +6381,7 @@ ${ delete: 'Excluir', hold: 'Manter', unhold: 'Remover retenção', + reject: 'Rejeitar', 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 451b6b444bb99..813c4381cf242 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6247,6 +6247,7 @@ ${ delete: '删除', hold: '保持', unhold: '移除保留', + reject: '拒绝', noOptionsAvailable: '所选费用组没有可用选项。', }, filtersHeader: '筛选器', diff --git a/src/libs/API/parameters/RejectMoneyRequestInBulkParams.ts b/src/libs/API/parameters/RejectMoneyRequestInBulkParams.ts new file mode 100644 index 0000000000000..ca25c749e23b7 --- /dev/null +++ b/src/libs/API/parameters/RejectMoneyRequestInBulkParams.ts @@ -0,0 +1,7 @@ +type RejectMoneyRequestInBulkParams = { + reportID: string; + comment: string; + transactionIDToRejectReportAction: string; +}; + +export default RejectMoneyRequestInBulkParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index cf1203c683705..09553e22d5e0f 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -288,6 +288,7 @@ export type {default as MarkAsExportedParams} from './MarkAsExportedParams'; export type {default as UpgradeToCorporateParams} from './UpgradeToCorporateParams'; export type {default as DowngradeToTeamParams} from './DowngradeToTeamParams'; export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyRequestOnSearchParams'; +export type {default as RejectMoneyRequestInBulkParams} from './RejectMoneyRequestInBulkParams'; export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams'; export type {default as ApproveMoneyRequestOnSearchParams} from './ApproveMoneyRequestOnSearchParams'; export type {default as PayMoneyRequestOnSearchParams} from './PayMoneyRequestOnSearchParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 9deecea130b84..4658058b2c195 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -352,6 +352,7 @@ const WRITE_COMMANDS = { UPGRADE_TO_CORPORATE: 'UpgradeToCorporate', DOWNGRADE_TO_TEAM: 'Policy_DowngradeToTeam', DELETE_MONEY_REQUEST_ON_SEARCH: 'DeleteMoneyRequestOnSearch', + REJECT_MONEY_REQUEST_IN_BULK: 'RejectMoneyRequestInBulk', HOLD_MONEY_REQUEST_ON_SEARCH: 'HoldMoneyRequestOnSearch', APPROVE_MONEY_REQUEST_ON_SEARCH: 'ApproveMoneyRequestOnSearch', UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', @@ -899,6 +900,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SEND_SCHEDULE_CALL_NUDGE]: Parameters.SendScheduleCallNudgeParams; [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; + [WRITE_COMMANDS.REJECT_MONEY_REQUEST_IN_BULK]: Parameters.RejectMoneyRequestInBulkParams; [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; [WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH]: Parameters.ApproveMoneyRequestOnSearchParams; [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.UnholdMoneyRequestOnSearchParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 1b55a8080a8e5..687527baa737b 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -70,9 +70,15 @@ const OPTIONS_PER_SCREEN: Partial [SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: { animation: Animations.NONE, }, + [SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS]: { + animation: Animations.NONE, + }, [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: { animation: Animations.NONE, }, + [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: { + animation: Animations.NONE, + }, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: { animation: Animations.NONE, }, @@ -848,7 +854,9 @@ const SearchReportModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchRootVerifyAccountPage').default, [SCREENS.SEARCH.MONEY_REQUEST_REPORT_VERIFY_ACCOUNT]: () => require('../../../../pages/Search/SearchMoneyRequestReportVerifyAccountPage').default, [SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: () => require('../../../../pages/Search/SearchHoldReasonPage').default, + [SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS]: () => require('../../../../pages/Search/SearchRejectReasonPage').default, [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: () => require('../../../../pages/Search/SearchHoldReasonPage').default, + [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: () => require('../../../../pages/Search/SearchRejectReasonPage').default, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: () => require('../../../../pages/Search/SearchTransactionsChangeReport').default, }); diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts index a733606877875..05caeb60b3de7 100644 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts @@ -11,6 +11,7 @@ const SEARCH_TO_RHP: Partial['config'] = { [SCREENS.SEARCH.MONEY_REQUEST_REPORT_VERIFY_ACCOUNT]: ROUTES.SEARCH_MONEY_REQUEST_REPORT_VERIFY_ACCOUNT.route, [SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: ROUTES.SEARCH_MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS.route, [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: ROUTES.TRANSACTION_HOLD_REASON_RHP, + [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: ROUTES.SEARCH_REJECT_REASON_RHP, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 962dcff253ce5..2b895fac57149 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2629,6 +2629,11 @@ type SearchReportParamList = { /** Selected transactions' report ID */ reportID: string; }; + [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: Record; + [SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS]: { + /** Selected transactions' report ID */ + reportID: string; + }; }; type SearchFullscreenNavigatorParamList = { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3dbe19ad4f086..161b860ff2065 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12649,10 +12649,58 @@ function getReportURLForCurrentContext(reportID: string | undefined): string { return `${environmentURL}/${relativePath}`; } +/** + * Checks if all selected items have the necessary Onyx data hydrated for bulk rejection. + */ +function checkBulkRejectHydration( + selectedTransactions: Record, + policies: Record | null | undefined, +): {areHydrated: boolean; missingReportIDs: string[]; missingPolicyIDs: string[]} { + const transactionIDs = Object.keys(selectedTransactions); + const missingReportIDs: string[] = []; + const missingPolicyIDs: string[] = []; + + for (const transactionID of transactionIDs) { + const transaction = selectedTransactions[transactionID]; + const reportID = transaction?.reportID; + + // Check if we have the report data + const report = getReportOrDraftReport(reportID); + if (!report) { + missingReportIDs.push(reportID); + continue; + } + + const effectivePolicyID = transaction?.policyID ?? report.policyID; + + // Check if we have the policy data (required for canRejectReportAction check) + if (effectivePolicyID) { + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${effectivePolicyID}`]; + if (!policy) { + missingPolicyIDs.push(effectivePolicyID); + continue; + } + } + + // Check if essential report fields are present + if (report.managerID == null || report.stateNum == null || report.statusNum == null) { + missingReportIDs.push(reportID); + continue; + } + } + + return { + areHydrated: missingReportIDs.length === 0 && missingPolicyIDs.length === 0, + missingReportIDs: [...new Set(missingReportIDs)], + missingPolicyIDs: [...new Set(missingPolicyIDs)], + }; +} + export { areAllRequestsBeingSmartScanned, buildOptimisticAddCommentReportAction, buildOptimisticApprovedReportAction, + checkBulkRejectHydration, buildOptimisticUnapprovedReportAction, buildOptimisticCancelPaymentReportAction, buildOptimisticChangedTaskAssigneeReportAction, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index b9defa497c545..e88cdcd7da14f 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -319,6 +319,14 @@ type MoneyRequestInformation = { reimbursable?: boolean; }; +type RejectMoneyRequestData = { + optimisticData: OnyxUpdate[]; + successData: OnyxUpdate[]; + failureData: OnyxUpdate[]; + parameters: RejectMoneyRequestParams; + urlToNavigateBack: Route | undefined; +}; + type TrackExpenseInformation = { createdWorkspaceParams?: CreateWorkspaceParams; iouReport?: OnyxTypes.Report; @@ -13035,13 +13043,21 @@ function dismissRejectUseExplanation() { } /** - * Reject a money request + * Retrieve the reject money request data * @param transactionID - The ID of the transaction to reject * @param reportID - The ID of the expense report to reject * @param comment - The comment to add to the reject action - * @returns The route to navigate back to + * @param options + * - sharedRejectedToReportID: When rejecting multiple expenses sequentially, pass a single shared destination reportID so all rejections land in the same new report. + * @returns optimisticData, successData, failureData, parameters, urlToNavigateBack */ -function rejectMoneyRequest(transactionID: string, reportID: string, comment: string): Route | undefined { +function prepareRejectMoneyRequestData( + transactionID: string, + reportID: string, + comment: string, + options?: {sharedRejectedToReportID?: string}, + shouldUseBulkAction?: boolean, +): RejectMoneyRequestData | undefined { const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const transactionAmount = getAmount(transaction); const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; @@ -13063,7 +13079,7 @@ function rejectMoneyRequest(transactionID: string, reportID: string, comment: st const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`]; let movedToReport; - let rejectedToReportID; + let rejectedToReportID = options?.sharedRejectedToReportID; let urlToNavigateBack; let reportPreviewAction: OnyxTypes.ReportAction | undefined; let createdIOUReportActionID; @@ -13086,7 +13102,7 @@ function rejectMoneyRequest(transactionID: string, reportID: string, comment: st const successData: OnyxUpdate[] = []; const failureData: OnyxUpdate[] = []; - if (!isPolicyDelayedSubmissionEnabled || isIOU) { + if ((!isPolicyDelayedSubmissionEnabled || isIOU) && !shouldUseBulkAction) { if (hasMultipleExpenses) { // For reports with multiple expenses: Update report total optimisticData.push( @@ -13225,7 +13241,7 @@ function rejectMoneyRequest(transactionID: string, reportID: string, comment: st urlToNavigateBack = ROUTES.REPORT_WITH_ID.getRoute(report.chatReportID); } } - } else if (hasMultipleExpenses) { + } else if (hasMultipleExpenses && !shouldUseBulkAction) { if (isUserOnSearchPage || isUserOnSearchMoneyRequestReport) { // Navigate to the existing Reports > Expense view. urlToNavigateBack = undefined; @@ -13321,8 +13337,10 @@ function rejectMoneyRequest(transactionID: string, reportID: string, comment: st }, ); } else { - // Create optimistic report for the rejected transaction - rejectedToReportID = generateReportID(); + // When no existing open report is found, use the sharedRejectedToReportID + // so multiple sequential rejections land in the same destination report + // Fallback to generating a fresh ID if not provided + rejectedToReportID = rejectedToReportID ?? generateReportID(); const newExpenseReport = buildOptimisticExpenseReport( report.chatReportID, report?.policyID, @@ -13709,10 +13727,19 @@ function rejectMoneyRequest(transactionID: string, reportID: string, comment: st expenseCreatedReportActionID, }; + return {optimisticData, successData, failureData, parameters, urlToNavigateBack: urlToNavigateBack as Route}; +} + +function rejectMoneyRequest(transactionID: string, reportID: string, comment: string, options?: {sharedRejectedToReportID?: string}): Route | undefined { + const data = prepareRejectMoneyRequestData(transactionID, reportID, comment, options); + if (!data) { + return; + } + const {urlToNavigateBack, optimisticData, successData, failureData, parameters} = data; // Make API call API.write(WRITE_COMMANDS.REJECT_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); - return urlToNavigateBack as Route; + return urlToNavigateBack; } function markRejectViolationAsResolved(transactionID: string, reportID?: string) { @@ -14807,6 +14834,7 @@ export { calculateDiffAmount, dismissRejectUseExplanation, rejectMoneyRequest, + prepareRejectMoneyRequestData, markRejectViolationAsResolved, setMoneyRequestReimbursable, computePerDiemExpenseAmount, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index bba1a60496b43..7990a6ca06266 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -20,12 +20,13 @@ import Navigation from '@libs/Navigation/Navigation'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {rand64} from '@libs/NumberUtils'; import {getActivePaymentType} from '@libs/PaymentUtils'; -import {getPersonalPolicy, getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; +import {getPersonalPolicy, getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, isDelayedSubmissionEnabled} from '@libs/PolicyUtils'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import type {OptimisticExportIntegrationAction} from '@libs/ReportUtils'; import { buildOptimisticExportIntegrationAction, buildOptimisticIOUReportAction, + generateReportID, getReportTransactions, hasHeldExpenses, isExpenseReport, @@ -48,6 +49,7 @@ import type {SearchReport, SearchTransaction} from '@src/types/onyx/SearchResult import type Nullable from '@src/types/utils/Nullable'; import SafeString from '@src/utils/SafeString'; import {setPersonalBankAccountContinueKYCOnSuccess} from './BankAccounts'; +import {prepareRejectMoneyRequestData, rejectMoneyRequest} from './IOU'; import {setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; @@ -734,6 +736,71 @@ function deleteMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { } } +function rejectMoneyRequestInBulk(hash: number, reportID: string, comment: string, transactionIDs: string[]) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + const transactionIDToRejectReportAction: Record< + string, + { + rejectedActionReportActionID: string; + rejectedCommentReportActionID: string; + } + > = {}; + for (const transactionID of transactionIDs) { + const data = prepareRejectMoneyRequestData(transactionID, reportID, comment, undefined, true); + if (data) { + optimisticData.push(...data.optimisticData); + successData.push(...data.successData); + failureData.push(...data.failureData); + transactionIDToRejectReportAction[transactionID] = { + rejectedActionReportActionID: data.parameters.rejectedActionReportActionID, + rejectedCommentReportActionID: data.parameters.rejectedCommentReportActionID, + }; + } + } + + API.write( + WRITE_COMMANDS.REJECT_MONEY_REQUEST_IN_BULK, + {reportID, comment, transactionIDToRejectReportAction: JSON.stringify(transactionIDToRejectReportAction)}, + {optimisticData, successData, failureData, finallyData}, + ); +} + +function rejectMoneyRequestsOnSearch(hash: number, selectedTransactions: SelectedTransactions, comment: string, allPolicies: OnyxCollection, allReports: OnyxCollection) { + const transactionIDs = Object.keys(selectedTransactions); + + const transactionsByReport = transactionIDs.reduce>((acc, transactionID) => { + const reportID = selectedTransactions[transactionID].reportID; + + if (!acc[reportID]) { + acc[reportID] = []; + } + + acc[reportID].push(transactionID); + + return acc; + }, {}); + + for (const [reportID, selectedTransactionIDs] of Object.entries(transactionsByReport)) { + const allReportTransactions = getReportTransactions(reportID).filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const allTransactionIDs = allReportTransactions.map((transaction) => transaction.transactionID); + const areAllExpensesSelected = allTransactionIDs.every((transactionID) => selectedTransactionIDs.includes(transactionID)); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + const isPolicyDelayedSubmissionEnabled = policy ? isDelayedSubmissionEnabled(policy) : false; + if (isPolicyDelayedSubmissionEnabled && areAllExpensesSelected) { + rejectMoneyRequestInBulk(hash, reportID, comment, allTransactionIDs); + } else { + // Share a single destination ID across all rejections from the same source report + const sharedRejectedToReportID = generateReportID(); + for (const transactionID of selectedTransactionIDs) { + rejectMoneyRequest(transactionID, reportID, comment, {sharedRejectedToReportID}); + } + } + } +} + type Params = Record; function exportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDList}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void) { @@ -1107,6 +1174,7 @@ export { deleteMoneyRequestOnSearch, holdMoneyRequestOnSearch, unholdMoneyRequestOnSearch, + rejectMoneyRequestsOnSearch, exportSearchItemsToCSV, queueExportSearchItemsToCSV, queueExportSearchWithTemplate, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 9a0d10e283de0..d9430e855749a 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -9,6 +9,8 @@ import DecisionModal from '@components/DecisionModal'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; +import HoldOrRejectEducationalModal from '@components/HoldOrRejectEducationalModal'; +import * as Expensicons from '@components/Icon/Expensicons'; import type {PaymentMethodType} from '@components/KYCWall/types'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; @@ -30,7 +32,8 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {confirmReadyToOpenApp} from '@libs/actions/App'; -import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; +import {openWorkspace} from '@libs/actions/Policy/Policy'; +import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, openReport, searchInServer} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, deleteMoneyRequestOnSearch, @@ -57,6 +60,8 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {getActiveAdminWorkspaces, hasDynamicExternalWorkflow, hasVBBA, isPaidGroupPolicy} from '@libs/PolicyUtils'; import { + canRejectReportAction, + checkBulkRejectHydration, generateReportID, getPolicyExpenseChat, getReportOrDraftReport, @@ -67,9 +72,10 @@ import { } from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import {getTransactionViolationsOfTransaction} from '@libs/TransactionUtils'; import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; import variables from '@styles/variables'; -import {initMoneyRequest, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; +import {dismissRejectUseExplanation, initMoneyRequest, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; import {openOldDotLink} from '@userActions/Link'; import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; @@ -113,6 +119,13 @@ function SearchPage({route}: SearchPageProps) { const [isExportWithTemplateModalVisible, setIsExportWithTemplateModalVisible] = useState(false); const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); + const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, {canBeMissing: true}); + const [isRejectEducationalModalVisible, setIsRejectEducationalModalVisible] = useState(false); + const dismissModalAndUpdateUseReject = () => { + setIsRejectEducationalModalVisible(false); + dismissRejectUseExplanation(); + Navigation.navigate(ROUTES.SEARCH_REJECT_REASON_RHP); + }; const queryJSON = useMemo(() => buildSearchQueryJSON(route.params.q), [route.params.q]); const {saveScrollOffset} = useContext(ScrollOffsetContext); const activeAdminPolicies = getActiveAdminWorkspaces(policies, currentUserPersonalDetails?.accountID.toString()).sort((a, b) => localeCompare(a.name || '', b.name || '')); @@ -175,6 +188,58 @@ function SearchPage({route}: SearchPageProps) { } }, [lastSearchType, queryJSON, setLastSearchType, currentSearchResults]); + // Check hydration status and prefetch missing data for bulk reject + const bulkRejectHydrationStatus = useMemo(() => { + if (Object.keys(selectedTransactions).length === 0) { + return {areHydrated: true, missingReportIDs: [], missingPolicyIDs: []}; + } + return checkBulkRejectHydration(selectedTransactions, policies); + }, [selectedTransactions, policies]); + + // Prefetch missing report and policy data when items are selected + const lastPrefetchKeyRef = useRef(''); + useEffect(() => { + const {areHydrated, missingReportIDs, missingPolicyIDs} = bulkRejectHydrationStatus; + + // If hydrated or nothing selected, clear and exit + if (areHydrated) { + lastPrefetchKeyRef.current = ''; + return; + } + if (isOffline) { + return; + } + + const key = `${[...missingReportIDs].sort().join(',')}|${[...missingPolicyIDs].sort().join(',')}`; + if (key === lastPrefetchKeyRef.current) { + return; + } + + // Prefetch once per unique set of missing IDs + for (const id of missingReportIDs) { + if (id) { + openReport(id); + } + } + + for (const id of missingPolicyIDs) { + if (id) { + openWorkspace(id, []); + } + } + + lastPrefetchKeyRef.current = key; + }, [bulkRejectHydrationStatus, isOffline]); + + // Allow retry on reconnect + const prevIsOffline = usePrevious(isOffline); + useEffect(() => { + if (isOffline || !prevIsOffline) { + return; + } + lastPrefetchKeyRef.current = ''; + }, [isOffline, prevIsOffline]); + const {status, hash} = queryJSON ?? {}; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); @@ -448,6 +513,63 @@ function SearchPage({route}: SearchPageProps) { }); } + // Check if any items are explicitly not rejectable (only when data is hydrated) + // If data is not hydrated, we don't treat it as "not rejectable" - instead we'll disable the button + const login = currentUserPersonalDetails?.login ?? ''; + const areAllExplicitlyRejectable = + selectedTransactionReportIDs.length > 0 && + selectedTransactionReportIDs.every((id) => { + const report = getReportOrDraftReport(id); + if (!report) { + return false; + } + const policyForReport = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + if (!policyForReport) { + return false; + } + return canRejectReportAction(login, report, policyForReport); + }); + + const hasNoRejectedTransaction = selectedTransactionsKeys.every((id) => { + const transactionViolations = getTransactionViolationsOfTransaction(id) ?? []; + return !transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE); + }); + + // Check if all selected items have hydrated Onyx data for rejection + const {areHydrated: areItemsHydratedForReject} = bulkRejectHydrationStatus; + + // Show the Reject option unless we know for sure it's not allowed + const shouldShowRejectOption = !isOffline && areAllExplicitlyRejectable && hasNoRejectedTransaction; + + // Disabled if not hydrated + const isRejectDisabled = !areItemsHydratedForReject; + + if (shouldShowRejectOption) { + options.push({ + icon: Expensicons.ThumbsDown, + text: translate('search.bulkActions.reject'), + value: CONST.SEARCH.BULK_ACTION_TYPES.REJECT, + shouldCloseModalOnSelect: true, + disabled: isRejectDisabled, + onSelected: () => { + if (isOffline) { + setIsOfflineModalVisible(true); + return; + } + + if (!areItemsHydratedForReject) { + return; + } + + if (dismissedRejectUseExplanation) { + Navigation.navigate(ROUTES.SEARCH_REJECT_REASON_RHP); + } else { + setIsRejectEducationalModalVisible(true); + } + }, + }); + } + const shouldShowSubmitOption = !isOffline && areSelectedTransactionsIncludedInReports && @@ -639,6 +761,7 @@ function SearchPage({route}: SearchPageProps) { styles.fontWeightNormal, styles.textWrap, expensifyIcons, + bulkRejectHydrationStatus, ]); const handleDeleteExpenses = () => { @@ -971,6 +1094,12 @@ function SearchPage({route}: SearchPageProps) { cancelText={translate('common.cancel')} /> )} + {!!isRejectEducationalModalVisible && ( + + )} )} diff --git a/src/pages/Search/SearchRejectReasonPage.tsx b/src/pages/Search/SearchRejectReasonPage.tsx new file mode 100644 index 0000000000000..c755144fcf1a9 --- /dev/null +++ b/src/pages/Search/SearchRejectReasonPage.tsx @@ -0,0 +1,51 @@ +import React, {useCallback, useEffect} from 'react'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import {useSearchContext} from '@components/Search/SearchContext'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {clearErrorFields, clearErrors} from '@libs/actions/FormActions'; +import {rejectMoneyRequestsOnSearch} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; +import {getFieldRequiredErrors} from '@libs/ValidationUtils'; +import RejectReasonFormView from '@pages/iou/RejectReasonFormView'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/MoneyRequestRejectReasonForm'; + +function SearchRejectReasonPage() { + const {translate} = useLocalize(); + const context = useSearchContext(); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + const onSubmit = useCallback( + ({comment}: FormOnyxValues) => { + rejectMoneyRequestsOnSearch(context.currentSearchHash, context.selectedTransactions, comment, allPolicies, allReports); + context.clearSelectedTransactions(); + Navigation.goBack(); + }, + [context, allPolicies, allReports], + ); + + const validate = useCallback( + (values: FormOnyxValues) => { + const errors: FormInputErrors = getFieldRequiredErrors(values, [INPUT_IDS.COMMENT]); + return errors; + }, + [translate], + ); + + useEffect(() => { + clearErrors(ONYXKEYS.FORMS.MONEY_REQUEST_REJECT_FORM); + clearErrorFields(ONYXKEYS.FORMS.MONEY_REQUEST_REJECT_FORM); + }, []); + + return ( + + ); +} + +SearchRejectReasonPage.displayName = 'SearchRejectReasonPage'; + +export default SearchRejectReasonPage; diff --git a/src/pages/iou/RejectReasonFormView.tsx b/src/pages/iou/RejectReasonFormView.tsx index 7b1a0da7ba7e5..3e28b7edd628a 100644 --- a/src/pages/iou/RejectReasonFormView.tsx +++ b/src/pages/iou/RejectReasonFormView.tsx @@ -23,7 +23,7 @@ type RejectReasonFormViewProps = { validate: (values: FormOnyxValues) => Partial>; /** Link to previous page */ - backTo: Route; + backTo?: Route; }; function RejectReasonFormView({backTo, validate, onSubmit}: RejectReasonFormViewProps) {