From f1008c79f4b12a3bea008a21b0d39248d91ea15c Mon Sep 17 00:00:00 2001 From: daledah Date: Fri, 28 Aug 2026 01:22:19 +0700 Subject: [PATCH 1/6] feat: add Auto report option for expenses spanning multiple submitters --- src/CONST/index.ts | 2 + src/hooks/useSearchBulkActions.ts | 25 ++-------- src/languages/de.ts | 2 + src/languages/el.ts | 2 + src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + src/libs/actions/Transaction.ts | 21 +++++++++ .../Search/SearchTransactionsChangeReport.tsx | 33 ++++++++++++- .../step/IOURequestEditReportCommon.tsx | 46 +++++++++++++++---- 16 files changed, 116 insertions(+), 33 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index afa1889ec2fb..3ff4daae2792 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1499,6 +1499,8 @@ const CONST = { UNREPORTED_REPORT_ID: '0', TRASH_REPORT_ID: '-1', SPLIT_REPORT_ID: '-2', + // "Auto report" destination: the backend picks each expense's report (its owner's latest draft, or a new one). + AUTOMATIC_REPORT_ID: '-3', SECONDARY_ACTIONS: { SUBMIT: 'submit', APPROVE: 'approve', diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 165f54a202b2..371838560ae7 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -2582,30 +2582,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } } - const ownerAccountIDs = new Set(); - let hasUnknownOwner = false; - for (const id of selectedTransactionsKeys) { - const transactionEntry = selectedTransactions[id]; - if (!transactionEntry) { - continue; - } - const ownerAccountID = - transactionEntry.ownerAccountID ?? - getReportOrDraftReport(transactionEntry.reportID, undefined, undefined, undefined, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionEntry.reportID}`])?.ownerAccountID; - if (typeof ownerAccountID === 'number') { - ownerAccountIDs.add(ownerAccountID); - if (ownerAccountIDs.size > 1) { - break; - } - } else { - hasUnknownOwner = true; - } - } - const hasMultipleOwners = ownerAccountIDs.size > 1 || (hasUnknownOwner && (ownerAccountIDs.size > 0 || selectedTransactionsKeys.length > 1)); - const canAllTransactionsBeMoved = selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport); - if (canAllTransactionsBeMoved && !hasMultipleOwners && !isExpenseReportType) { + // Selections spanning submitters are allowed through: the destination screen swaps its per-report list for + // "Auto report", so gating on owners here would hide the only entry point into that flow. + if (canAllTransactionsBeMoved && !isExpenseReportType) { options.push({ text: translate('iou.moveExpenses'), icon: expensifyIcons.DocumentMerge, diff --git a/src/languages/de.ts b/src/languages/de.ts index 88237b4c0d5f..f2a86310e1f8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1770,6 +1770,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'Der Bericht konnte nicht abgelehnt werden. Bitte versuchen Sie es erneut.', }, moveExpenses: 'Zum Bericht verschieben', + autoReport: 'Automatischer Bericht', + autoReportDescription: 'Im Namen des Mitarbeiters zu Berichtsentwürfen hinzufügen', moveExpensesMaxTransactionsError: `Berichte sind auf ${CONST.REPORT.MAX_TRANSACTIONS} Ausgaben begrenzt. Bitte verschiebe einige in einen anderen Bericht.`, moveExpensesError: 'Sie können Pauschalspesen nicht in Berichte anderer Arbeitsbereiche verschieben, da die Pauschalsätze je nach Arbeitsbereich unterschiedlich sein können.', submitReportTo: { diff --git a/src/languages/el.ts b/src/languages/el.ts index fa914d069e6e..e53af4db95c8 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1823,6 +1823,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'Η αναφορά δεν μπόρεσε να απορριφθεί. Παρακαλούμε προσπαθήστε ξανά.', }, moveExpenses: 'Μετακίνηση στην αναφορά', + autoReport: 'Αυτόματη αναφορά', + autoReportDescription: 'Προσθήκη σε προσχέδια αναφορών εκ μέρους του εργαζομένου', moveExpensesMaxTransactionsError: `Οι αναφορές περιορίζονται σε ${CONST.REPORT.MAX_TRANSACTIONS} έξοδα. Μετακινήστε ορισμένα σε άλλη αναφορά.`, moveExpensesError: 'Δεν μπορείτε να μετακινήσετε έξοδα ημερήσιας αποζημίωσης σε αναφορές άλλων χώρων εργασίας, επειδή οι τιμές ημερήσιας αποζημίωσης μπορεί να διαφέρουν μεταξύ των χώρων εργασίας.', diff --git a/src/languages/en.ts b/src/languages/en.ts index c38b485abeb6..63a7acdb19f5 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1854,6 +1854,8 @@ const translations = { couldNotReject: 'The report could not be rejected. Please try again.', }, moveExpenses: 'Move to report', + autoReport: 'Auto report', + autoReportDescription: 'Add to draft reports on behalf of the employee', moveExpensesError: "You can't move per diem expenses to reports on other workspaces, because the per diem rates may differ between workspaces.", moveExpensesMaxTransactionsError: `Reports are limited to ${CONST.REPORT.MAX_TRANSACTIONS} expenses. Please move some to another report.`, submitReportTo: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 6a6652872c24..15bedbf5d7c7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1766,6 +1766,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'No se pudo rechazar el informe. Por favor, inténtalo de nuevo.', }, moveExpenses: 'Mover a informe', + autoReport: 'Informe automático', + autoReportDescription: 'Añadir a los informes borrador en nombre del empleado', moveExpensesMaxTransactionsError: `Los informes están limitados a ${CONST.REPORT.MAX_TRANSACTIONS} gastos. Por favor, mueve algunos a otro informe.`, moveExpensesError: 'No puedes mover gastos per diem a informes de otros espacios de trabajo, porque las tarifas de dietas pueden diferir entre espacios de trabajo.', submitReportTo: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 47a18f0ce344..235be981fa8a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1776,6 +1776,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'La note de frais n’a pas pu être rejetée. Veuillez réessayer.', }, moveExpenses: 'Déplacer vers le rapport', + autoReport: 'Rapport automatique', + autoReportDescription: 'Ajouter aux brouillons de rapports au nom de l’employé', moveExpensesMaxTransactionsError: `Les rapports sont limités à ${CONST.REPORT.MAX_TRANSACTIONS} dépenses. Veuillez en déplacer certaines vers un autre rapport.`, moveExpensesError: 'Vous ne pouvez pas déplacer des frais de per diem vers des notes de frais d’autres espaces de travail, car les taux de per diem peuvent varier d’un espace de travail à l’autre.', diff --git a/src/languages/it.ts b/src/languages/it.ts index efc722a148f8..6beb10800d17 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1769,6 +1769,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'Non è stato possibile rifiutare il report. Riprova.', }, moveExpenses: 'Sposta nel report', + autoReport: 'Report automatico', + autoReportDescription: 'Aggiungi alle bozze dei report a nome del dipendente', moveExpensesMaxTransactionsError: `I report sono limitati a ${CONST.REPORT.MAX_TRANSACTIONS} spese. Spostane alcune in un altro report.`, moveExpensesError: 'Non puoi spostare le spese di diaria nei report di altri spazi di lavoro, perché le tariffe di diaria possono variare tra gli spazi di lavoro.', submitReportTo: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 009346f794a2..f4f66ff07a42 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1748,6 +1748,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'レポートを拒否できませんでした。もう一度お試しください。', }, moveExpenses: 'レポートに移動', + autoReport: '自動レポート', + autoReportDescription: '従業員に代わって下書きレポートに追加', moveExpensesMaxTransactionsError: `レポートは${CONST.REPORT.MAX_TRANSACTIONS}件の経費までに制限されています。一部を別のレポートに移動してください。`, moveExpensesError: '日当経費は、ワークスペースごとに日当レートが異なる場合があるため、他のワークスペースのレポートに移動することはできません。', submitReportTo: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 28fd7d9ad85d..8375b44c3473 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1765,6 +1765,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'Het rapport kon niet worden afgewezen. Probeer het opnieuw.', }, moveExpenses: 'Verplaatsen naar rapport', + autoReport: 'Automatisch rapport', + autoReportDescription: 'Toevoegen aan conceptrapporten namens de werknemer', moveExpensesMaxTransactionsError: `Rapporten zijn beperkt tot ${CONST.REPORT.MAX_TRANSACTIONS} uitgaven. Verplaats er enkele naar een ander rapport.`, moveExpensesError: 'Je kunt dagvergoedingdeclaraties niet naar rapporten in andere werkruimten verplaatsen, omdat de dagvergoedingsbedragen per werkruimte kunnen verschillen.', submitReportTo: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 00287d1685b4..4bc4bbf4ab7d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1793,6 +1793,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'Nie udało się odrzucić raportu. Spróbuj ponownie.', }, moveExpenses: 'Przenieś do raportu', + autoReport: 'Raport automatyczny', + autoReportDescription: 'Dodaj do szkiców raportów w imieniu pracownika', moveExpensesMaxTransactionsError: `Raporty są ograniczone do ${CONST.REPORT.MAX_TRANSACTIONS} wydatków. Przenieś część z nich do innego raportu.`, moveExpensesError: 'Nie możesz przenosić diet do raportów w innych przestrzeniach roboczych, ponieważ stawki diet mogą się różnić między przestrzeniami roboczymi.', submitReportTo: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 361133ed32fc..f411ff1087ca 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1764,6 +1764,8 @@ const translations: TranslationDeepObject = { couldNotReject: 'Não foi possível rejeitar o relatório. Tente novamente.', }, moveExpenses: 'Mover para relatório', + autoReport: 'Relatório automático', + autoReportDescription: 'Adicionar aos relatórios de rascunho em nome do funcionário', moveExpensesMaxTransactionsError: `Os relatórios são limitados a ${CONST.REPORT.MAX_TRANSACTIONS} despesas. Mova algumas para outro relatório.`, moveExpensesError: 'Você não pode mover despesas de diária para relatórios em outros workspaces, porque as tarifas de diária podem ser diferentes entre os workspaces.', submitReportTo: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index cb89e0234f2b..2978e2daa3a2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1703,6 +1703,8 @@ const translations: TranslationDeepObject = { couldNotReject: '无法拒绝该报表。请重试。', }, moveExpenses: '移动到报告', + autoReport: '自动报告', + autoReportDescription: '代表员工添加到草稿报告', moveExpensesMaxTransactionsError: `每个报告最多可包含 ${CONST.REPORT.MAX_TRANSACTIONS} 笔支出。请将部分支出移动到其他报告。`, moveExpensesError: '您无法将每日津贴报销移动到其他工作区的报表中,因为不同工作区的每日津贴标准可能不同。', submitReportTo: { diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 085a12a4140d..77f4065a851f 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -2030,6 +2030,26 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { }); } +/** + * Reports expenses without naming a destination: the backend puts each one in its owner's latest draft report, or a new + * report when they have none. Needed when a selection spans submitters, since any report the App picked would belong to + * just one of them. Carries no optimistic data because every update the normal move builds hangs off a destination only + * the backend knows. + */ +function autoReportTransactions(transactionIDs: string[]) { + if (transactionIDs.length === 0) { + return; + } + + const parameters: ChangeTransactionsReportParams = { + transactionList: transactionIDs.join(','), + reportID: CONST.REPORT.AUTOMATIC_REPORT_ID, + transactionIDToReportActionAndThreadData: '{}', + }; + + API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, parameters); +} + function getDefaultP2PMileageRate() { API.read(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null); } @@ -2051,6 +2071,7 @@ function getDuplicateTransactionDetails(transactionID?: string) { } export { + autoReportTransactions, saveWaypoint, removeWaypoint, getRoute, diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 88ff61bff4ea..281defc5435b 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -13,7 +13,7 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import {createNewReport} from '@libs/actions/Report'; -import {changeTransactionsReport} from '@libs/actions/Transaction'; +import {autoReportTransactions, changeTransactionsReport} from '@libs/actions/Transaction'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; @@ -113,6 +113,27 @@ function SearchTransactionsChangeReport() { return report?.ownerAccountID; }, [selectedTransactions, selectedTransactionsKeys, allReports]); const targetOwnerPersonalDetails = useMemo(() => getPersonalDetailsForAccountID(targetOwnerAccountID, personalDetails) as PersonalDetails, [personalDetails, targetOwnerAccountID]); + // Kept separate from `targetOwnerAccountID`, which stops at the first owner it finds; counting needs them all. + const hasMultipleSubmitters = useMemo(() => { + const ownerAccountIDs = new Set(); + let hasUnknownOwner = false; + + for (const transactionKey of selectedTransactionsKeys) { + const selection = selectedTransactions[transactionKey]; + const reportID = selection?.reportID; + const ownerAccountID = + selection?.ownerAccountID ?? getReportOrDraftReport(reportID, undefined, undefined, undefined, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])?.ownerAccountID; + + if (typeof ownerAccountID === 'number') { + ownerAccountIDs.add(ownerAccountID); + } else { + hasUnknownOwner = true; + } + } + + // An unresolved owner only implies a mix when there is something to mix with, so a lone expense stays single. + return ownerAccountIDs.size > 1 || (hasUnknownOwner && (ownerAccountIDs.size > 0 || selectedTransactionsKeys.length > 1)); + }, [selectedTransactions, selectedTransactionsKeys, allReports]); useEffect(() => { const snapshotData = currentSearchResults?.data; @@ -284,6 +305,14 @@ function SearchTransactionsChangeReport() { Navigation.goBack(undefined, {afterTransition: clearSelectedTransactions}); }; + const autoReport = () => { + if (selectedTransactionsKeys.length === 0) { + return; + } + autoReportTransactions(selectedTransactionsKeys); + Navigation.goBack(undefined, {afterTransition: clearSelectedTransactions}); + }; + const removeFromReport = () => { if (selectedTransactionsKeys.length === 0) { return; @@ -325,6 +354,8 @@ function SearchTransactionsChangeReport() { transactionPolicyID={selectedReportPolicyID} isPerDiemRequest={hasPerDiemTransactions} isUnreportedManagedCardTransaction={hasUnreportedManagedCardTransactions} + hasMultipleSubmitters={hasMultipleSubmitters} + autoReport={autoReport} /> ); } diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index ffc1ef9a98c0..587f09f4793e 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -61,6 +61,10 @@ type Props = { isPerDiemRequest: boolean; isTimeRequest?: boolean; isUnreportedManagedCardTransaction?: boolean; + /** Whether the expenses being moved belong to more than one submitter */ + hasMultipleSubmitters?: boolean; + /** Lets the backend pick a destination report per expense. Required to offer the action to multiple submitters */ + autoReport?: () => void; }; function IOURequestEditReportCommon({ @@ -81,8 +85,10 @@ function IOURequestEditReportCommon({ isPerDiemRequest, isTimeRequest = false, isUnreportedManagedCardTransaction = false, + hasMultipleSubmitters = false, + autoReport, }: Props) { - const icons = useMemoizedLazyExpensifyIcons(['Close', 'Document']); + const icons = useMemoizedLazyExpensifyIcons(['Close', 'Document', 'DocumentMagicWand']); const {inputCallbackRef} = useAutoFocusInput(); const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const personalDetails = usePersonalDetails(); @@ -147,7 +153,8 @@ function IOURequestEditReportCommon({ const outstandingReports = useOutstandingReports(selectedReportID, selectedPolicyID, resolvedReportOwnerAccountID, isEditing); const reportOptions: TransactionGroupListItem[] = useMemo(() => { - if (outstandingReports.length === 0) { + // Outstanding reports belong to one owner, so listing them would offer their reports for everyone else's expenses. + if (outstandingReports.length === 0 || hasMultipleSubmitters) { return []; } @@ -196,6 +203,7 @@ function IOURequestEditReportCommon({ localeCompare, allPolicies, currentUserPersonalDetails.accountID, + hasMultipleSubmitters, isPerDiemRequest, isTimeRequest, translate, @@ -310,7 +318,8 @@ function IOURequestEditReportCommon({ const headerMessage = useMemo(() => (searchValue && !reportOptions.length ? translate('common.noResultsFound') : ''), [searchValue, reportOptions.length, translate]); const createReportOption = useMemo(() => { - if (!createReport || (isEditing && !isOwner && !isAdmin)) { + // A report per submitter would need one API call each, so "Auto report" serves them alone in a single call. + if (!createReport || hasMultipleSubmitters || (isEditing && !isOwner && !isAdmin)) { return undefined; } @@ -322,10 +331,27 @@ function IOURequestEditReportCommon({ icon={icons.Document} /> ); - }, [icons.Document, createReport, translate, policyForMovingExpenses?.name, handleCreateReport, isEditing, isOwner, isAdmin]); + }, [icons.Document, createReport, hasMultipleSubmitters, translate, policyForMovingExpenses?.name, handleCreateReport, isEditing, isOwner, isAdmin]); + + const autoReportOption = useMemo(() => { + if (!autoReport || !hasMultipleSubmitters) { + return undefined; + } + + return ( + + ); + }, [icons.DocumentMagicWand, autoReport, hasMultipleSubmitters, translate]); + + const listHeaderContent = createReportOption ?? autoReportOption; const shouldShowNotFoundPage = useMemo(() => { - if (createReportOption) { + if (listHeaderContent) { return false; } @@ -341,7 +367,7 @@ function IOURequestEditReportCommon({ const isSubmitter = isReportOwner(selectedReport); // If the report is Open, then only submitters, admins can move expenses return isOpen && !isAdmin && !isSubmitter; - }, [createReportOption, outstandingReports.length, shouldShowNotFoundPageFromProps, selectedReport, isAdmin]); + }, [listHeaderContent, outstandingReports.length, shouldShowNotFoundPageFromProps, selectedReport, isAdmin]); const hidePerDiemWarningModal = () => setPerDiemWarningModalVisible(false); @@ -358,7 +384,7 @@ function IOURequestEditReportCommon({ data={reportOptions} onSelectRow={handleSelectReport} isRowMultilineSupported - shouldShowTextInput={outstandingReports.length >= CONST.STANDARD_LIST_ITEM_LIMIT} + shouldShowTextInput={!hasMultipleSubmitters && outstandingReports.length >= CONST.STANDARD_LIST_ITEM_LIMIT} textInputOptions={{ value: searchValue, label: translate('common.search'), @@ -369,9 +395,9 @@ function IOURequestEditReportCommon({ }} shouldSingleExecuteRowSelect initiallyFocusedItemKey={selectedReportID} - shouldScrollToFocusedIndexOnMount={!createReportOption} + shouldScrollToFocusedIndexOnMount={!listHeaderContent} ListItem={InviteMemberListItem} - customListHeaderContent={createReportOption} + customListHeaderContent={listHeaderContent} listFooterContent={ shouldShowRemoveFromReport ? ( ) : undefined } - listEmptyContent={createReportOption} + listEmptyContent={listHeaderContent} /> Date: Mon, 7 Sep 2026 13:26:06 +0700 Subject: [PATCH 2/6] fix: split comment semicolon into two sentences --- src/pages/Search/SearchTransactionsChangeReport.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 0cc22fc1a526..648005883380 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -113,7 +113,7 @@ function SearchTransactionsChangeReport() { return report?.ownerAccountID; }, [selectedTransactions, selectedTransactionsKeys, allReports]); const targetOwnerPersonalDetails = useMemo(() => getPersonalDetailsForAccountID(targetOwnerAccountID, personalDetails) as PersonalDetails, [personalDetails, targetOwnerAccountID]); - // Kept separate from `targetOwnerAccountID`, which stops at the first owner it finds; counting needs them all. + // Kept separate from `targetOwnerAccountID`, which stops at the first owner it finds. Counting needs them all. const hasMultipleSubmitters = useMemo(() => { const ownerAccountIDs = new Set(); let hasUnknownOwner = false; From 202cbd207158de2aa1a1bf6839467f6af43bb8ae Mon Sep 17 00:00:00 2001 From: daledah Date: Mon, 7 Sep 2026 13:32:47 +0700 Subject: [PATCH 3/6] fix: block Auto report while offline instead of silently queuing it --- .../step/IOURequestEditReportCommon.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index 00fd7455cca5..c51c5048c520 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -13,6 +13,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDebouncedState from '@hooks/useDebouncedState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOutstandingReports from '@hooks/useOutstandingReports'; import usePolicy from '@hooks/usePolicy'; @@ -118,6 +119,7 @@ function IOURequestEditReportCommon({ const [perDiemWarningModalVisible, setPerDiemWarningModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); + const {isOffline} = useNetwork(); const blockDistanceRequestIfNeeded = useBlockDistanceRequest({ isManualDistanceRequest, isOdometerDistanceRequest, @@ -339,6 +341,21 @@ function IOURequestEditReportCommon({ ); }, [createReport, hasMultipleSubmitters, isEditing, isOwner, isAdmin, handleCreateReport, icons.Document, translate, policyForMovingExpensesName]); + // The destinations are chosen server-side, so there is nothing to apply optimistically and nothing to show for a + // queued request. Blocking offline keeps the screen open and says why, rather than silently discarding the action. + const handleAutoReport = useCallback(() => { + if (isOffline) { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + autoReport?.(); + }, [isOffline, showConfirmModal, translate, autoReport]); + const autoReportOption = useMemo(() => { if (!autoReport || !hasMultipleSubmitters) { return undefined; @@ -346,13 +363,13 @@ function IOURequestEditReportCommon({ return ( ); - }, [icons.DocumentMagicWand, autoReport, hasMultipleSubmitters, translate]); + }, [icons.DocumentMagicWand, autoReport, handleAutoReport, hasMultipleSubmitters, translate]); const listHeaderContent = createReportOption ?? autoReportOption; From ccefcc2230931a846179d284719a17459a9cbe19 Mon Sep 17 00:00:00 2001 From: daledah Date: Mon, 14 Sep 2026 15:56:17 +0700 Subject: [PATCH 4/6] fix: scope Auto report to unreported expenses and surface move failures --- src/hooks/useSearchBulkActions.ts | 42 +++++++++++++++++-- src/libs/actions/Transaction.ts | 16 ++++++- .../Search/SearchTransactionsChangeReport.tsx | 15 ++++--- .../step/IOURequestEditReportCommon.tsx | 9 +++- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 263a74912115..1f7bd893b3dd 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -102,6 +102,8 @@ import { isDeletedTransaction, isDistanceRequest, isManagedCardTransaction, + isManualDistanceRequest, + isOdometerDistanceRequest, isPending, isPerDiemRequest, isScanning, @@ -2650,11 +2652,45 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } } + const ownerAccountIDs = new Set(); + let hasUnknownOwner = false; + for (const id of selectedTransactionsKeys) { + const transactionEntry = selectedTransactions[id]; + if (!transactionEntry) { + continue; + } + const ownerAccountID = + transactionEntry.ownerAccountID ?? + getReportOrDraftReport(transactionEntry.reportID, undefined, undefined, undefined, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionEntry.reportID}`])?.ownerAccountID; + if (typeof ownerAccountID === 'number') { + ownerAccountIDs.add(ownerAccountID); + } else { + hasUnknownOwner = true; + } + } + const hasMultipleOwners = ownerAccountIDs.size > 1 || (hasUnknownOwner && (ownerAccountIDs.size > 0 || selectedTransactionsKeys.length > 1)); + const canAllTransactionsBeMoved = selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport); - // Selections spanning submitters are allowed through: the destination screen swaps its per-report list for - // "Auto report", so gating on owners here would hide the only entry point into that flow. - if (canAllTransactionsBeMoved && !isExpenseReportType) { + // Across submitters the only destination the App can offer is "Auto report", and it fits just the reconciliation + // case it was built for. Every other mixed-owner selection stays hidden as before, so there is no entry into a + // screen that could only offer one submitter's reports to everybody else's expenses. Requirements: + // - every expense unreported, since Auto report files them into their owners' drafts + // - every owner resolved, or the count below cannot tell one cardholder's bulk selection from a mixed one + // - nothing whose validity depends on the destination workspace, which the backend picks: per diem rates and + // the map/GPS rules on manual and odometer distance can only be checked against a known workspace + const canAutoReportAcrossSubmitters = + ownerAccountIDs.size > 1 && + !hasUnknownOwner && + selectedTransactionsKeys.every((id) => { + if (selectedTransactions[id]?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + return false; + } + const transaction = selectedTransactions[id]?.transaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; + return !transaction || !(isPerDiemRequest(transaction) || isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction)); + }); + + if (canAllTransactionsBeMoved && !isExpenseReportType && (!hasMultipleOwners || canAutoReportAcrossSubmitters)) { options.push({ text: translate('iou.moveExpenses'), icon: expensifyIcons.DocumentMerge, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 3a0f47349de1..3c749f88f90e 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -12,6 +12,7 @@ import type { import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {translateLocal} from '@libs/Localize'; import {buildOptimisticNextStep} from '@libs/NextStepUtils'; @@ -2042,13 +2043,26 @@ function autoReportTransactions(transactionIDs: string[]) { return; } + // There is no optimistic move to roll back, but a failure still has to reach the admin: the screen has closed and + // the selection is gone by then, so the error is surfaced on the expenses themselves via their red brick road. + const successData: Array> = transactionIDs.map((transactionID) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {errors: null}, + })); + const failureData: Array> = transactionIDs.map((transactionID) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')}, + })); + const parameters: ChangeTransactionsReportParams = { transactionList: transactionIDs.join(','), reportID: CONST.REPORT.AUTOMATIC_REPORT_ID, transactionIDToReportActionAndThreadData: '{}', }; - API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, parameters); + API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, parameters, {successData, failureData}); } function getDefaultP2PMileageRate() { diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 648005883380..26048c0974e0 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -114,9 +114,15 @@ function SearchTransactionsChangeReport() { }, [selectedTransactions, selectedTransactionsKeys, allReports]); const targetOwnerPersonalDetails = useMemo(() => getPersonalDetailsForAccountID(targetOwnerAccountID, personalDetails) as PersonalDetails, [personalDetails, targetOwnerAccountID]); // Kept separate from `targetOwnerAccountID`, which stops at the first owner it finds. Counting needs them all. + // Only distinct resolved owners count. An owner we cannot resolve must not stand in for a second submitter: for an + // unreported expense the report lookup can never resolve one (its reportID is `0`), so a search snapshot missing + // the money-request action would otherwise file one cardholder's bulk selection as mixed and strip its report list. const hasMultipleSubmitters = useMemo(() => { + if (!areAllTransactionsUnreported) { + return false; + } + const ownerAccountIDs = new Set(); - let hasUnknownOwner = false; for (const transactionKey of selectedTransactionsKeys) { const selection = selectedTransactions[transactionKey]; @@ -126,14 +132,11 @@ function SearchTransactionsChangeReport() { if (typeof ownerAccountID === 'number') { ownerAccountIDs.add(ownerAccountID); - } else { - hasUnknownOwner = true; } } - // An unresolved owner only implies a mix when there is something to mix with, so a lone expense stays single. - return ownerAccountIDs.size > 1 || (hasUnknownOwner && (ownerAccountIDs.size > 0 || selectedTransactionsKeys.length > 1)); - }, [selectedTransactions, selectedTransactionsKeys, allReports]); + return ownerAccountIDs.size > 1; + }, [areAllTransactionsUnreported, selectedTransactions, selectedTransactionsKeys, allReports]); useHydrateReportsFromSnapshot(currentSearchResults, allReports); diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index c51c5048c520..1530242f3da2 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -361,6 +361,13 @@ function IOURequestEditReportCommon({ return undefined; } + // These expenses are only valid on some workspaces, and the guards that decide which — per diem rates, the + // map/GPS rules on manual and odometer distance — need a destination the backend has not picked yet. Callers + // are expected to withhold the whole flow for such a selection; this keeps the row honest if one does not. + if (isPerDiemRequest || isManualDistanceRequest || isOdometerDistanceRequest) { + return undefined; + } + return ( ); - }, [icons.DocumentMagicWand, autoReport, handleAutoReport, hasMultipleSubmitters, translate]); + }, [icons.DocumentMagicWand, autoReport, handleAutoReport, hasMultipleSubmitters, isManualDistanceRequest, isOdometerDistanceRequest, isPerDiemRequest, translate]); const listHeaderContent = createReportOption ?? autoReportOption; From 4ace428e8fe0cfe5236751dcc7442a1efd9bbdf8 Mon Sep 17 00:00:00 2001 From: daledah Date: Mon, 14 Sep 2026 16:46:13 +0700 Subject: [PATCH 5/6] fix: allow Auto report for selections mixing draft and unreported expenses --- src/hooks/useSearchBulkActions.ts | 10 +++------- src/pages/Search/SearchTransactionsChangeReport.tsx | 6 +----- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 0ebcee9d18ff..0a49b8bfb816 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -2718,10 +2718,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const canAllTransactionsBeMoved = selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport); - // Across submitters the only destination the App can offer is "Auto report", and it fits just the reconciliation - // case it was built for. Every other mixed-owner selection stays hidden as before, so there is no entry into a - // screen that could only offer one submitter's reports to everybody else's expenses. Requirements: - // - every expense unreported, since Auto report files them into their owners' drafts + // Across submitters the only destination the App can offer is "Auto report". Every other mixed-owner selection + // stays hidden as before, so there is no entry into a screen that could only offer one submitter's reports to + // everybody else's expenses. Requirements: // - every owner resolved, or the count below cannot tell one cardholder's bulk selection from a mixed one // - nothing whose validity depends on the destination workspace, which the backend picks: per diem rates and // the map/GPS rules on manual and odometer distance can only be checked against a known workspace @@ -2729,9 +2728,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ownerAccountIDs.size > 1 && !hasUnknownOwner && selectedTransactionsKeys.every((id) => { - if (selectedTransactions[id]?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - return false; - } const transaction = selectedTransactions[id]?.transaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; return !transaction || !(isPerDiemRequest(transaction) || isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction)); }); diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 57c94c2e9221..0b382f82a8e6 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -124,10 +124,6 @@ function SearchTransactionsChangeReport() { // unreported expense the report lookup can never resolve one (its reportID is `0`), so a search snapshot missing // the money-request action would otherwise file one cardholder's bulk selection as mixed and strip its report list. const hasMultipleSubmitters = useMemo(() => { - if (!areAllTransactionsUnreported) { - return false; - } - const ownerAccountIDs = new Set(); for (const transactionKey of selectedTransactionsKeys) { @@ -142,7 +138,7 @@ function SearchTransactionsChangeReport() { } return ownerAccountIDs.size > 1; - }, [areAllTransactionsUnreported, selectedTransactions, selectedTransactionsKeys, allReports]); + }, [selectedTransactions, selectedTransactionsKeys, allReports]); useHydrateReportsFromSnapshot(currentSearchResults, allReports); From 0742500367671c54b580402860d536833e451146 Mon Sep 17 00:00:00 2001 From: daledah Date: Tue, 15 Sep 2026 10:57:45 +0700 Subject: [PATCH 6/6] fix: require managed card expenses for Auto report to avoid 404 Card not found --- src/hooks/useSearchBulkActions.ts | 8 ++++++- .../Search/SearchTransactionsChangeReport.tsx | 5 +++++ .../step/IOURequestEditReportCommon.tsx | 21 ++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 0a49b8bfb816..36245cbf2649 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -2722,14 +2722,20 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { // stays hidden as before, so there is no entry into a screen that could only offer one submitter's reports to // everybody else's expenses. Requirements: // - every owner resolved, or the count below cannot tell one cardholder's bulk selection from a mixed one + // - every expense on a managed card, because the backend resolves each destination through the card; one + // expense without a card fails the whole request with "404 Card not found" // - nothing whose validity depends on the destination workspace, which the backend picks: per diem rates and // the map/GPS rules on manual and odometer distance can only be checked against a known workspace + // An expense we cannot read fails all three, so it withholds the flow rather than risking a rejected move. const canAutoReportAcrossSubmitters = ownerAccountIDs.size > 1 && !hasUnknownOwner && selectedTransactionsKeys.every((id) => { const transaction = selectedTransactions[id]?.transaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; - return !transaction || !(isPerDiemRequest(transaction) || isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction)); + if (!transaction || !isManagedCardTransaction(transaction)) { + return false; + } + return !(isPerDiemRequest(transaction) || isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction)); }); if (canAllTransactionsBeMoved && !isExpenseReportType && (!hasMultipleOwners || canAutoReportAcrossSubmitters)) { diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 0b382f82a8e6..8ed1a07bf074 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -23,6 +23,7 @@ import {generateReportID, getPersonalDetailsForAccountID, getReportOrDraftReport import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import { isDistanceRequest as isDistanceRequestUtil, + isManagedCardTransaction, isManualDistanceRequest as isManualDistanceRequestUtil, isOdometerDistanceRequest as isOdometerDistanceRequestUtil, isUnreportedManagedCardTransaction, @@ -123,6 +124,9 @@ function SearchTransactionsChangeReport() { // Only distinct resolved owners count. An owner we cannot resolve must not stand in for a second submitter: for an // unreported expense the report lookup can never resolve one (its reportID is `0`), so a search snapshot missing // the money-request action would otherwise file one cardholder's bulk selection as mixed and strip its report list. + // "Auto report" has the backend resolve each destination through the expense's card, so one expense without a card + // fails the whole request with "404 Card not found". + const areAllManagedCardTransactions = selectedTransactionsKeys.length > 0 && transactions.length === selectedTransactionsKeys.length && transactions.every(isManagedCardTransaction); const hasMultipleSubmitters = useMemo(() => { const ownerAccountIDs = new Set(); @@ -331,6 +335,7 @@ function SearchTransactionsChangeReport() { isPerDiemRequest={hasPerDiemTransactions} isUnreportedManagedCardTransaction={hasUnreportedManagedCardTransactions} hasMultipleSubmitters={hasMultipleSubmitters} + areAllManagedCardTransactions={areAllManagedCardTransactions} autoReport={autoReport} /> ); diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index 6fc2e9b1be78..ee6b962db838 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -64,6 +64,8 @@ type Props = { isUnreportedManagedCardTransaction?: boolean; /** Whether the expenses being moved belong to more than one submitter */ hasMultipleSubmitters?: boolean; + /** Whether every expense being moved sits on a managed card, which "Auto report" needs to resolve a destination */ + areAllManagedCardTransactions?: boolean; /** Lets the backend pick a destination report per expense. Required to offer the action to multiple submitters */ autoReport?: () => void; }; @@ -88,6 +90,7 @@ function IOURequestEditReportCommon({ isTimeRequest = false, isUnreportedManagedCardTransaction = false, hasMultipleSubmitters = false, + areAllManagedCardTransactions = false, autoReport, }: Props) { const icons = useMemoizedLazyExpensifyIcons(['Close', 'Document', 'DocumentMagicWand']); @@ -370,6 +373,12 @@ function IOURequestEditReportCommon({ return undefined; } + // The backend resolves each destination through the expense's card, so an expense without one fails the whole + // request with "404 Card not found". + if (!areAllManagedCardTransactions) { + return undefined; + } + // These expenses are only valid on some workspaces, and the guards that decide which — per diem rates, the // map/GPS rules on manual and odometer distance — need a destination the backend has not picked yet. Callers // are expected to withhold the whole flow for such a selection; this keeps the row honest if one does not. @@ -385,7 +394,17 @@ function IOURequestEditReportCommon({ icon={icons.DocumentMagicWand} /> ); - }, [icons.DocumentMagicWand, autoReport, handleAutoReport, hasMultipleSubmitters, isManualDistanceRequest, isOdometerDistanceRequest, isPerDiemRequest, translate]); + }, [ + icons.DocumentMagicWand, + areAllManagedCardTransactions, + autoReport, + handleAutoReport, + hasMultipleSubmitters, + isManualDistanceRequest, + isOdometerDistanceRequest, + isPerDiemRequest, + translate, + ]); const listHeaderContent = createReportOption ?? autoReportOption;