From f84ffac689c096779d4fdd7e22d9fa5054f0d57f Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:39:56 +0700 Subject: [PATCH] Add bulk export to accounting integration --- src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/API/parameters/ReportExportParams.ts | 2 +- src/libs/actions/Report/index.ts | 56 ++++++++ src/libs/actions/Search.ts | 100 ++++++++++++++ src/pages/Search/SearchPage.tsx | 126 ++++++++++++++---- 14 files changed, 264 insertions(+), 30 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 0d7c52143b5cb..20faa6a45e2e5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8189,6 +8189,7 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, expenseLevelExport: 'Alle Daten – Ausgabenebene', exportInProgress: 'Export wird ausgeführt', conciergeWillSend: 'Concierge wird dir die Datei in Kürze senden.', + conciergeWillNotifyOnExportFailure: 'Concierge wird dir eine Nachricht senden, wenn Berichte nicht exportiert werden.', }, domain: { notVerified: 'Nicht verifiziert', diff --git a/src/languages/en.ts b/src/languages/en.ts index f797a1e62716d..389df38652bae 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8106,6 +8106,7 @@ const translations = { expenseLevelExport: 'All Data - expense level', exportInProgress: 'Export in progress', conciergeWillSend: 'Concierge will send you the file shortly.', + conciergeWillNotifyOnExportFailure: "Concierge will send you a message if any reports don't export.", }, domain: { notVerified: 'Not verified', diff --git a/src/languages/es.ts b/src/languages/es.ts index 0bcd0578cdcb0..076e9df967292 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8254,6 +8254,7 @@ ${amount} para ${merchant} - ${date}`, expenseLevelExport: 'Todos los datos - a nivel de gasto', exportInProgress: 'Exportación en curso', conciergeWillSend: 'Concierge te enviará el archivo en breve.', + conciergeWillNotifyOnExportFailure: 'Concierge te enviará un mensaje si algún informe no se exporta.', }, openAppFailureModal: { title: 'Algo salió mal...', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8227beb6b5bfd..78991cb95cd87 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8203,6 +8203,7 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, expenseLevelExport: 'Toutes les données - niveau dépense', exportInProgress: 'Export en cours', conciergeWillSend: 'Concierge vous enverra le fichier sous peu.', + conciergeWillNotifyOnExportFailure: 'Concierge vous enverra un message si certains rapports ne sont pas exportés.', }, domain: { notVerified: 'Non vérifié', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6169ff2dfc3f0..f4ff1fe1b8e9c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8171,6 +8171,7 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, expenseLevelExport: 'Tutti i dati - livello spesa', exportInProgress: 'Esportazione in corso', conciergeWillSend: 'Concierge ti invierà il file a breve.', + conciergeWillNotifyOnExportFailure: 'Concierge ti invierà un messaggio se alcuni report non vengono esportati.', }, domain: { notVerified: 'Non verificato', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7497d8df7f674..12ce90c6d4c70 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8103,6 +8103,7 @@ ${reportName} expenseLevelExport: 'すべてのデータ - 経費レベル', exportInProgress: 'エクスポート処理中', conciergeWillSend: 'Conciergeがまもなくファイルを送信します。', + conciergeWillNotifyOnExportFailure: 'レポートのエクスポートに失敗した場合、Concierge からメッセージが届きます。', }, domain: { notVerified: '未確認', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f9423d3a515bb..b7066ae81e6ba 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8158,6 +8158,7 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, expenseLevelExport: 'Alle gegevens - uitgaveniveau', exportInProgress: 'Export bezig', conciergeWillSend: 'Concierge stuurt je het bestand zo meteen.', + conciergeWillNotifyOnExportFailure: 'Concierge stuurt je een bericht als rapporten niet worden geëxporteerd.', }, domain: { notVerified: 'Niet geverifieerd', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ce9ca4863cd66..634d85dd35472 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8138,6 +8138,7 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, expenseLevelExport: 'Wszystkie dane – poziom wydatku', exportInProgress: 'Trwa eksport', conciergeWillSend: 'Concierge wkrótce wyśle Ci plik.', + conciergeWillNotifyOnExportFailure: 'Concierge wyśle Ci wiadomość, jeśli niektóre raporty nie zostaną wyeksportowane.', }, domain: { notVerified: 'Niezweryfikowane', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0781ad77b079f..6c2baae731e4b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8139,6 +8139,7 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, expenseLevelExport: 'Todos os dados - nível de despesa', exportInProgress: 'Exportação em andamento', conciergeWillSend: 'O Concierge enviará o arquivo para você em breve.', + conciergeWillNotifyOnExportFailure: 'O Concierge enviará uma mensagem se algum relatório não for exportado.', }, domain: { notVerified: 'Não verificado', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e1a3b2f98c0f5..0db725bfced18 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7982,6 +7982,7 @@ ${reportName} expenseLevelExport: '所有数据 - 报销级别', exportInProgress: '导出进行中', conciergeWillSend: 'Concierge 将很快把文件发送给你。', + conciergeWillNotifyOnExportFailure: '如果有报告导出失败,Concierge 将向你发送消息。', }, domain: { notVerified: '未验证', 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 dcdd77356ed95..75bf6d7dc0950 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5017,6 +5017,61 @@ function markAsManuallyExported(reportID: string, connectionName: ConnectionName API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, params, {optimisticData, successData, failureData}); } +function markAsManuallyExportedMultipleReports(reportIDs: string[], connectionName: ConnectionName) { + const label = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]; + 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, + }, + }); + + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticReportActionID]: { + pendingAction: null, + }, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticReportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }); + } + + const params = { + markedManually: true, + data: JSON.stringify(reportData), + } satisfies MarkAsExportedParams; + + API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, params, {optimisticData, successData, failureData}); +} + function exportReportToCSV({reportID, transactionIDList}: ExportReportCSVParams, onDownloadFailed: () => void, translate: LocalizedTranslate) { let reportIDParam = reportID; const allReportTransactions = getReportTransactions(reportID).filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); @@ -6683,6 +6738,7 @@ export { leaveGroupChat, leaveRoom, markAsManuallyExported, + markAsManuallyExportedMultipleReports, markCommentAsUnread, navigateToAndOpenChildReport, createChildReport, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 6ddaf363d8714..84716edb6892d 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -713,6 +713,105 @@ function exportToIntegrationOnSearch(hash: number, reportID: string | undefined, API.write(WRITE_COMMANDS.REPORT_EXPORT, params, {optimisticData, failureData, successData}); } +function exportMultipleReportsToIntegration(hash: number, reportIDs: string[], connectionName: ConnectionName, currentSearchKey?: SearchKey) { + if (!reportIDs.length) { + return; + } + + const optimisticActions: Record = {}; + const successActions: Record = {}; + const optimisticReportActions: Record = {}; + + for (const reportID of reportIDs) { + const optimisticAction = buildOptimisticExportIntegrationAction(connectionName); + const successAction: OptimisticExportIntegrationAction = {...optimisticAction, pendingAction: null}; + const optimisticReportActionID = optimisticAction.reportActionID; + + optimisticActions[reportID] = optimisticAction; + successActions[reportID] = successAction; + optimisticReportActions[reportID] = optimisticReportActionID; + } + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.REPORT_METADATA, + value: Object.fromEntries(reportIDs.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {isActionLoading: true}])), + }, + ]; + + for (const reportID of reportIDs) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticReportActions[reportID]]: optimisticActions[reportID], + }, + }); + } + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.REPORT_METADATA, + value: Object.fromEntries(reportIDs.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {isActionLoading: false}])), + }, + ]; + + for (const reportID of reportIDs) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticReportActions[reportID]]: successActions[reportID], + }, + }); + } + + 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 failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.REPORT_METADATA, + value: Object.fromEntries(reportIDs.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {isActionLoading: false}])), + }, + ]; + + for (const reportID of reportIDs) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticReportActions[reportID]]: null, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }); + } + + const params = { + reportIDList: reportIDs, + connectionName, + type: 'MANUAL', + optimisticReportActions: JSON.stringify(optimisticReportActions), + } satisfies ReportExportParams; + + API.write(WRITE_COMMANDS.REPORT_EXPORT, params, {optimisticData, failureData, successData}); +} + function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], currentSearchKey?: SearchKey) { const optimisticData: Array> = [ { @@ -1366,6 +1465,7 @@ export { getLastPolicyPaymentMethod, getLastPolicyBankAccountID, exportToIntegrationOnSearch, + exportMultipleReportsToIntegration, getPayOption, isValidBulkPayOption, handleBulkPayItemSelected, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index c362994c9ec84..194b23f86e176 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -4,6 +4,7 @@ import {InteractionManager, View} from 'react-native'; import Animated from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import {DelegateNoAccessContext} from '@components/DelegateNoAccessModalProvider'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; @@ -37,10 +38,11 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {confirmReadyToOpenApp} from '@libs/actions/App'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; -import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; +import {markAsManuallyExportedMultipleReports, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, deleteMoneyRequestOnSearch, + exportMultipleReportsToIntegration, exportSearchItemsToCSV, getExportTemplates, getLastPolicyBankAccountID, @@ -65,10 +67,11 @@ 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 {getActiveAdminWorkspaces, hasDynamicExternalWorkflow, hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil, isPaidGroupPolicy} from '@libs/PolicyUtils'; -import {isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; +import {getActiveAdminWorkspaces, getConnectedIntegration, hasDynamicExternalWorkflow, hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil, isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; import { generateReportID, + getIntegrationIcon, getPolicyExpenseChat, getReportOrDraftReport, isBusinessInvoiceRoom, @@ -132,6 +135,7 @@ function SearchPage({route}: SearchPageProps) { const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); + const [accountingExportModalVisible, setAccountingExportModalVisible] = useState(false); const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); const {showConfirmModal} = useConfirmModal(); const {isBetaEnabled} = usePermissions(); @@ -163,6 +167,11 @@ function SearchPage({route}: SearchPageProps) { 'SmartScan', 'MoneyBag', 'ArrowSplit', + 'QBOSquare', + 'XeroSquare', + 'NetSuiteSquare', + 'IntacctSquare', + 'QBDSquare', ] as const); const lastNonEmptySearchResults = useRef(undefined); @@ -629,21 +638,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)); @@ -655,8 +653,76 @@ 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; + + // Helper function to check if a single report can be exported + const canReportBeExported = (report: (typeof selectedReports)[0]) => { + if (!report.reportID) { + return false; + } + + const reportPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + const completeReport = getReportOrDraftReport(report.reportID); + + if (!completeReport) { + return false; + } + + const reportExportOptions = getSecondaryExportReportActions( + currentUserPersonalDetails?.accountID ?? 0, + currentUserPersonalDetails?.login ?? '', + completeReport, + bankAccountList, + reportPolicy, + ); + + return reportExportOptions.includes(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION); + }; + + // Check if all selected reports can be exported using existing logic + const canExportAllReports = isReportsTab && selectedReportIDs.length > 0 && includeReportLevelExport && selectedReports.every(canReportBeExported); + + // Add accounting integration export options if conditions are met + if (canExportAllReports && connectedIntegration) { + const connectionNameFriendly = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectedIntegration]; + const integrationIcon = getIntegrationIcon(connectedIntegration, expensifyIcons); + + exportOptions.push( + { + text: connectionNameFriendly, + icon: integrationIcon, + onSelected: () => { + // Show accounting integration confirmation modal + setAccountingExportModalVisible(true); + exportMultipleReportsToIntegration(hash, selectedReportIDs, connectedIntegration); + }, + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + }, + { + text: translate('workspace.common.markAsExported'), + icon: integrationIcon, + onSelected: () => { + markAsManuallyExportedMultipleReports(selectedReportIDs, connectedIntegration); + }, + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + }, + ); + } + + 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({ @@ -991,19 +1057,6 @@ function SearchPage({route}: SearchPageProps) { styles.colorMuted, styles.fontWeightNormal, styles.textWrap, - expensifyIcons.ArrowCollapse, - expensifyIcons.ArrowRight, - expensifyIcons.ArrowSplit, - expensifyIcons.DocumentMerge, - expensifyIcons.Exclamation, - expensifyIcons.Export, - expensifyIcons.MoneyBag, - expensifyIcons.Send, - expensifyIcons.Stopwatch, - expensifyIcons.Table, - expensifyIcons.ThumbsDown, - expensifyIcons.ThumbsUp, - expensifyIcons.Trashcan, dismissedHoldUseExplanation, dismissedRejectUseExplanation, areAllTransactionsFromSubmitter, @@ -1012,7 +1065,10 @@ function SearchPage({route}: SearchPageProps) { isDelegateAccessRestricted, showDelegateNoAccessModal, currentUserPersonalDetails.accountID, + currentUserPersonalDetails?.login, personalPolicyID, + bankAccountList, + expensifyIcons, ]); const saveFileAndInitMoneyRequest = (files: FileObject[]) => { @@ -1272,6 +1328,18 @@ function SearchPage({route}: SearchPageProps) { onConfirm={dismissModalAndUpdateUseHold} /> )} + + { + setAccountingExportModalVisible(false); + }} + onCancel={() => setAccountingExportModalVisible(false)} + title={translate('export.exportInProgress')} + prompt={translate('export.conciergeWillNotifyOnExportFailure')} + confirmText={translate('common.buttonConfirm')} + shouldShowCancelButton={false} + /> )}