diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 5abcced00bfaa..4ff8ac1432607 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -898,7 +898,7 @@ function MoneyReportHeader({ if (exportModalStatus === CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION) { exportToIntegration(moneyRequestReport?.reportID, connectedIntegration); } else if (exportModalStatus === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED) { - markAsManuallyExported(moneyRequestReport?.reportID, connectedIntegration); + markAsManuallyExported([moneyRequestReport?.reportID ?? CONST.DEFAULT_NUMBER_ID], connectedIntegration); } }, [connectedIntegration, exportModalStatus, moneyRequestReport?.reportID]); @@ -989,7 +989,7 @@ function MoneyReportHeader({ setExportModalStatus(CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED); return; } - markAsManuallyExported(moneyRequestReport?.reportID, connectedIntegration); + markAsManuallyExported([moneyRequestReport?.reportID ?? CONST.DEFAULT_NUMBER_ID], connectedIntegration); }, }, }; diff --git a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx index 7637bbcfb1616..9a0b6ba67afc1 100644 --- a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx +++ b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx @@ -95,7 +95,7 @@ function ExportWithDropdownMenu({ if (exportType === CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION) { exportToIntegration(reportID, connectionName); } else if (exportType === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED) { - markAsManuallyExported(reportID, connectionName); + markAsManuallyExported([reportID], connectionName); } }; diff --git a/src/libs/API/parameters/ReportExportParams.ts b/src/libs/API/parameters/ReportExportParams.ts index c6a4b7b58ee89..5b69aa1978a70 100644 --- a/src/libs/API/parameters/ReportExportParams.ts +++ b/src/libs/API/parameters/ReportExportParams.ts @@ -2,7 +2,7 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; type ReportExportParams = { - reportIDList: string; + reportIDList: string | string[]; connectionName: ValueOf; type: 'MANUAL'; /** diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index e8cbf9d3255c1..da1d43c94c54d 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4958,23 +4958,34 @@ function exportToIntegration(reportID: string, connectionName: ConnectionName) { API.write(WRITE_COMMANDS.REPORT_EXPORT, params, {optimisticData, failureData}); } -function markAsManuallyExported(reportID: string, connectionName: ConnectionName) { - const action = buildOptimisticExportIntegrationAction(connectionName, true); +function markAsManuallyExported(reportIDs: string[], connectionName: ConnectionName) { const label = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]; - const optimisticReportActionID = action.reportActionID; - const optimisticData: Array> = [ - { + const optimisticData: Array> = []; + const successData: Array> = []; + const failureData: Array> = []; + const reportData: Array<{reportID: string; label: string; optimisticReportActionID: string}> = []; + + // Process each report ID + for (const reportID of reportIDs) { + const action = buildOptimisticExportIntegrationAction(connectionName, true); + const optimisticReportActionID = action.reportActionID; + + reportData.push({ + reportID, + label, + optimisticReportActionID, + }); + + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { [optimisticReportActionID]: action, }, - }, - ]; + }); - const successData: Array> = [ - { + successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { @@ -4982,11 +4993,9 @@ function markAsManuallyExported(reportID: string, connectionName: ConnectionName pendingAction: null, }, }, - }, - ]; + }); - const failureData: Array> = [ - { + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { @@ -4994,18 +5003,12 @@ function markAsManuallyExported(reportID: string, connectionName: ConnectionName errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, - }, - ]; + }); + } const params = { markedManually: true, - data: JSON.stringify([ - { - reportID, - label, - optimisticReportActionID, - }, - ]), + data: JSON.stringify(reportData), } satisfies MarkAsExportedParams; API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, params, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index ce235e34ae9d5..084e096cab529 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -166,7 +166,11 @@ function handleActionButtonPress({ return; } - exportToIntegrationOnSearch(hash, item?.reportID, connectedIntegration, currentSearchKey); + if (!item?.reportID) { + return; + } + + exportToIntegrationOnSearch(hash, [item.reportID], connectedIntegration, currentSearchKey); return; } default: @@ -644,87 +648,89 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], curre API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH, {hash, reportIDList}, {optimisticData, failureData, successData}); } -function exportToIntegrationOnSearch(hash: number, reportID: string | undefined, connectionName: ConnectionName, currentSearchKey?: SearchKey) { - if (!reportID) { +function exportToIntegrationOnSearch(hash: number, reportIDs: string[], connectionName: ConnectionName, currentSearchKey?: SearchKey) { + if (!reportIDs.length) { return; } - const optimisticAction = buildOptimisticExportIntegrationAction(connectionName); - const successAction: OptimisticExportIntegrationAction = {...optimisticAction, pendingAction: null}; - const optimisticReportActionID = optimisticAction.reportActionID; + const optimisticReportActions: Record = {}; const optimisticData: Array> = [ { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, - value: {isActionLoading: true}, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: { - [optimisticReportActionID]: optimisticAction, - }, + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.REPORT_METADATA, + value: Object.fromEntries(reportIDs.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {isActionLoading: true}])), }, ]; - const successData: Array> = [ + const successData: Array> = []; + + const finallyData: Array> = [ { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, - value: {isActionLoading: false}, + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.REPORT_METADATA, + value: Object.fromEntries(reportIDs.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {isActionLoading: false}])), }, - { + ]; + + const failureData: Array> = []; + + for (const reportID of reportIDs) { + const optimisticAction = buildOptimisticExportIntegrationAction(connectionName); + const successAction: OptimisticExportIntegrationAction = {...optimisticAction, pendingAction: null}; + const optimisticReportActionID = optimisticAction.reportActionID; + + optimisticReportActions[reportID] = optimisticReportActionID; + + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { - [optimisticReportActionID]: successAction, + [optimisticReportActionID]: optimisticAction, }, - }, - ]; + }); - // If we are on the 'Export' suggested search, remove the report from the view once the action is taken, don't wait for the view to be re-fetched via Search - if (currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPORT) { successData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { - data: { - [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, - }, + [optimisticReportActionID]: successAction, }, }); - } - const failureData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, - value: {isActionLoading: false}, - }, - { + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { [optimisticReportActionID]: null, }, - }, - { + }); + + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: {errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, - }, - ]; + }); + } + + // If we are on the 'Export' suggested search, remove the report from the view once the action is taken, don't wait for the view to be re-fetched via Search + if (currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPORT) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + data: Object.fromEntries(reportIDs.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null])), + }, + }); + } const params = { - reportIDList: reportID, + reportIDList: reportIDs, connectionName, type: 'MANUAL', - optimisticReportActions: JSON.stringify({ - [reportID]: optimisticReportActionID, - }), + optimisticReportActions: JSON.stringify(optimisticReportActions), } satisfies ReportExportParams; - API.write(WRITE_COMMANDS.REPORT_EXPORT, params, {optimisticData, failureData, successData}); + API.write(WRITE_COMMANDS.REPORT_EXPORT, params, {optimisticData, successData, failureData, finallyData}); } function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], currentSearchKey?: SearchKey) { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 2c5dbe320d316..fc8bafeb24bdd 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -39,11 +39,12 @@ import useSelfDMReport from '@hooks/useSelfDMReport'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; -import {deleteAppReport, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; +import {deleteAppReport, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, bulkDeleteReports, exportSearchItemsToCSV, + exportToIntegrationOnSearch, getExportTemplates, getLastPolicyBankAccountID, getLastPolicyPaymentMethod, @@ -66,9 +67,10 @@ import {getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; -import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; -import {isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; +import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; +import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; import { + getIntegrationIcon, getReportOrDraftReport, isBusinessInvoiceRoom, isCurrentUserSubmitter, @@ -163,6 +165,12 @@ function SearchPage({route}: SearchPageProps) { 'SmartScan', 'MoneyBag', 'ArrowSplit', + 'QBOSquare', + 'XeroSquare', + 'NetSuiteSquare', + 'IntacctSquare', + 'QBDSquare', + 'Pencil', ] as const); const lastNonEmptySearchResults = useRef(undefined); @@ -387,6 +395,7 @@ function SearchPage({route}: SearchPageProps) { selectedTransactionsKeys, translate, clearSelectedTransactions, + setIsDownloadErrorModalVisible, showConfirmModal, hash, selectAllMatchingItems, @@ -737,21 +746,10 @@ function SearchPage({route}: SearchPageProps) { const typeExpenseReport = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; - // Gets the list of options for the export sub-menu // Gets the list of options for the export sub-menu const getExportOptions = () => { // We provide the basic and expense level export options by default - const exportOptions: PopoverMenuItem[] = [ - { - text: translate('export.basicExport'), - icon: expensifyIcons.Table, - onSelected: () => { - handleBasicExport(); - }, - shouldCloseModalOnSelect: true, - shouldCallAfterModalHide: true, - }, - ]; + const exportOptions: PopoverMenuItem[] = []; // Determine if only full reports are selected by comparing the reportIDs of the selected transactions and the reportIDs of the selected reports const areFullReportsSelected = selectedTransactionReportIDs.length === selectedReportIDs.length && selectedTransactionReportIDs.every((id) => selectedReportIDs.includes(id)); @@ -763,8 +761,119 @@ function SearchPage({route}: SearchPageProps) { // the selected expenses are the only expenses of their parent expense report include the report level export option. const includeReportLevelExport = ((typeExpenseReport || typeInvoice) && areFullReportsSelected) || (typeExpense && !typeExpenseReport && isAllOneTransactionReport); - // Collect a list of export templates available to the user from their account, policy, and custom integrations templates const policy = selectedPolicyIDs.length === 1 ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedPolicyIDs.at(0)}`] : undefined; + const connectedIntegration = getConnectedIntegration(policy); + const isReportsTab = typeExpenseReport; + + const canReportBeExported = (report: (typeof selectedReports)[0]) => { + if (!report.reportID) { + return false; + } + + const reportPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + const completeReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]; + + if (!completeReport) { + return false; + } + + const reportExportOptions = getSecondaryExportReportActions( + currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, + currentUserPersonalDetails?.login ?? '', + completeReport, + bankAccountList, + reportPolicy, + ); + + return reportExportOptions.includes(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION); + }; + + const canExportAllReports = isReportsTab && selectedReportIDs.length > 0 && includeReportLevelExport && selectedReports.every(canReportBeExported); + + if (canExportAllReports && connectedIntegration) { + const connectionNameFriendly = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectedIntegration]; + const integrationIcon = getIntegrationIcon(connectedIntegration, expensifyIcons); + + const handleExportAction = (exportAction: () => void) => { + if (isOffline) { + setIsOfflineModalVisible(true); + return; + } + + const exportedReportNames: string[] = []; + let areAnyReportsExported = false; + + for (const reportID of selectedReportIDs) { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + + if (!report?.isExportedToIntegration) { + continue; + } + + areAnyReportsExported = true; + + if (report.reportName) { + exportedReportNames.push(report.reportName); + } + } + + if (areAnyReportsExported) { + showConfirmModal({ + title: translate('workspace.exportAgainModal.title'), + prompt: translate('workspace.exportAgainModal.description', { + connectionName: connectedIntegration, + reportName: exportedReportNames.join('\n'), + }), + confirmText: translate('workspace.exportAgainModal.confirmText'), + cancelText: translate('workspace.exportAgainModal.cancelText'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + + if (hash) { + clearSelectedTransactions(); + exportAction(); + } + }); + } else if (hash) { + exportAction(); + clearSelectedTransactions(); + } + }; + + exportOptions.push( + { + text: connectionNameFriendly, + icon: integrationIcon, + onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, selectedReportIDs, connectedIntegration)), + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + displayInDefaultIconColor: true, + additionalIconStyles: styles.integrationIcon, + }, + { + text: translate('workspace.common.markAsExported'), + icon: integrationIcon, + onSelected: () => handleExportAction(() => markAsManuallyExported(selectedReportIDs, connectedIntegration)), + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + displayInDefaultIconColor: true, + additionalIconStyles: styles.integrationIcon, + }, + ); + } + + exportOptions.push({ + text: translate('export.basicExport'), + icon: expensifyIcons.Table, + onSelected: () => { + handleBasicExport(); + }, + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + }); + const exportTemplates = getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy, includeReportLevelExport); for (const template of exportTemplates) { exportOptions.push({ @@ -1072,19 +1181,7 @@ function SearchPage({route}: SearchPageProps) { hash, selectedTransactions, queryJSON?.type, - expensifyIcons.Export, - expensifyIcons.ArrowRight, - expensifyIcons.Table, - expensifyIcons.ThumbsUp, - expensifyIcons.ThumbsDown, - expensifyIcons.Send, - expensifyIcons.MoneyBag, - expensifyIcons.Stopwatch, - expensifyIcons.ArrowCollapse, - expensifyIcons.DocumentMerge, - expensifyIcons.ArrowSplit, - expensifyIcons.Trashcan, - expensifyIcons.Exclamation, + expensifyIcons, translate, areAllMatchingItemsSelected, isOffline, @@ -1099,6 +1196,9 @@ function SearchPage({route}: SearchPageProps) { policies, integrationsExportTemplates, csvExportLayouts, + currentUserPersonalDetails.accountID, + currentUserPersonalDetails?.login, + bankAccountList, handleBasicExport, beginExportWithTemplate, handleApproveWithDEWCheck, @@ -1111,8 +1211,8 @@ function SearchPage({route}: SearchPageProps) { onBulkPaySelected, areAllTransactionsFromSubmitter, dismissedHoldUseExplanation, - currentUserPersonalDetails.accountID, localeCompare, + allReports, firstTransaction, firstTransactionPolicy, handleDeleteSelectedTransactions, @@ -1120,6 +1220,8 @@ function SearchPage({route}: SearchPageProps) { styles.colorMuted, styles.fontWeightNormal, styles.textWrap, + styles.integrationIcon, + showConfirmModal, ]); const {initScanRequest, PDFValidationComponent, ErrorModal} = useReceiptScanDrop(); diff --git a/src/pages/inbox/report/ReportDetailsExportPage.tsx b/src/pages/inbox/report/ReportDetailsExportPage.tsx index 57aa99d015ef1..e0d8e9606facd 100644 --- a/src/pages/inbox/report/ReportDetailsExportPage.tsx +++ b/src/pages/inbox/report/ReportDetailsExportPage.tsx @@ -51,7 +51,7 @@ function ReportDetailsExportPage({route}: ReportDetailsExportPageProps) { if (type === CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION) { exportToIntegration(reportID, connectionName); } else if (type === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED) { - markAsManuallyExported(reportID, connectionName); + markAsManuallyExported([reportID], connectionName); } Navigation.dismissModal(); },