diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d3ca32e5a765..f2250c9f2097 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -21,8 +21,8 @@ import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import {openSearch, setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; import type {TransactionPreviewData} from '@libs/actions/Search'; +import {openSearch, setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; import Timing from '@libs/actions/Timing'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; @@ -115,6 +115,7 @@ function mapTransactionItemToSelectedEntry( isFromOneTransactionReport: item.isFromOneTransactionReport, convertedCurrency: item.convertedCurrency, currency: item.currency, + ownerAccountID: item.report?.ownerAccountID ?? item.accountID, }, ]; } @@ -197,6 +198,7 @@ function prepareTransactionsList( convertedCurrency: item.convertedCurrency, currency: item.currency, isFromOneTransactionReport: item.isFromOneTransactionReport, + ownerAccountID: item.report?.ownerAccountID ?? item.accountID, }, }; } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 7a204efefd6e..6a919db98547 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -49,6 +49,9 @@ type SelectedTransactionInfo = { /** Whether it is the only expense of the parent expense report */ isFromOneTransactionReport?: boolean; + + /** Account ID of the report owner */ + ownerAccountID?: number; }; /** Model of selected transactions */ diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 5ed62ab91540..95fd0c5123e6 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -15,6 +15,7 @@ import { canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + getReportOrDraftReport, isArchivedReport, isInvoiceReport, isMoneyRequestReport as isMoneyRequestReportUtils, @@ -58,7 +59,7 @@ function useSelectedTransactionsActions({ beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => void; }) { const {isOffline} = useNetworkWithOfflineStatus(); - const {selectedTransactionIDs, clearSelectedTransactions} = useSearchContext(); + const {selectedTransactionIDs, clearSelectedTransactions, selectedTransactions: selectedTransactionsMeta} = useSearchContext(); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {canBeMissing: true}); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); @@ -69,7 +70,7 @@ function useSelectedTransactionsActions({ const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(selectedTransactionIDs); const isReportArchived = useReportIsArchived(report?.reportID); - const selectedTransactions = useMemo( + const selectedTransactionsList = useMemo( () => selectedTransactionIDs.reduce((acc, transactionID) => { const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; @@ -80,6 +81,48 @@ function useSelectedTransactionsActions({ }, [] as Transaction[]), [allTransactions, selectedTransactionIDs], ); + const hasTransactionsFromMultipleOwners = useMemo(() => { + const knownOwnerIDs = new Set(); + let hasUnknownOwner = false; + + for (const selectedTransactionInfo of Object.values(selectedTransactionsMeta ?? {})) { + const ownerAccountID = selectedTransactionInfo?.ownerAccountID; + if (typeof ownerAccountID === 'number') { + knownOwnerIDs.add(ownerAccountID); + if (knownOwnerIDs.size > 1) { + return true; + } + } else { + hasUnknownOwner = true; + } + } + + for (const selectedTransaction of selectedTransactionsList) { + const reportID = selectedTransaction?.reportID; + if (!reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + hasUnknownOwner = true; + continue; + } + + const parentReport = getReportOrDraftReport(reportID); + const ownerAccountID = parentReport?.ownerAccountID; + + if (typeof ownerAccountID === 'number') { + knownOwnerIDs.add(ownerAccountID); + if (knownOwnerIDs.size > 1) { + return true; + } + } else { + hasUnknownOwner = true; + } + } + + if (hasUnknownOwner) { + return knownOwnerIDs.size > 0 || selectedTransactionIDs.length > 1; + } + + return false; + }, [selectedTransactionsList, selectedTransactionsMeta, selectedTransactionIDs]); const {translate} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); @@ -171,10 +214,10 @@ function useSelectedTransactionsActions({ const options = []; const isMoneyRequestReport = isMoneyRequestReportUtils(report); const isReportReimbursed = report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report?.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; - let canHoldTransactions = selectedTransactions.length > 0 && isMoneyRequestReport && !isReportReimbursed; - let canUnholdTransactions = selectedTransactions.length > 0 && isMoneyRequestReport; + let canHoldTransactions = selectedTransactionsList.length > 0 && isMoneyRequestReport && !isReportReimbursed; + let canUnholdTransactions = selectedTransactionsList.length > 0 && isMoneyRequestReport; - selectedTransactions.forEach((selectedTransaction) => { + selectedTransactionsList.forEach((selectedTransaction) => { if (!canHoldTransactions && !canUnholdTransactions) { return; } @@ -271,7 +314,7 @@ function useSelectedTransactionsActions({ subMenuItems: getExportOptions(), }); - const canSelectedExpensesBeMoved = selectedTransactions.every((transaction) => { + const canSelectedExpensesBeMoved = selectedTransactionsList.every((transaction) => { if (!transaction) { return false; } @@ -282,7 +325,7 @@ function useSelectedTransactionsActions({ }); const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); - if (canSelectedExpensesBeMoved && canUserPerformWriteAction) { + if (canSelectedExpensesBeMoved && canUserPerformWriteAction && !hasTransactionsFromMultipleOwners) { options.push({ text: translate('iou.moveExpenses', {count: selectedTransactionIDs.length}), icon: Expensicons.DocumentMerge, @@ -296,14 +339,14 @@ function useSelectedTransactionsActions({ } // In phase 1, we only show merge action if report is eligible for merge and only one transaction is selected - const canMergeTransaction = selectedTransactions.length === 1 && report && isMergeAction(report, selectedTransactions, policy); + const canMergeTransaction = selectedTransactionsList.length === 1 && report && isMergeAction(report, selectedTransactionsList, policy); if (canMergeTransaction) { options.push({ text: translate('common.merge'), icon: Expensicons.ArrowCollapse, value: MERGE, onSelected: () => { - const targetTransaction = selectedTransactions.at(0); + const targetTransaction = selectedTransactionsList.at(0); if (!report || !targetTransaction) { return; @@ -315,7 +358,7 @@ function useSelectedTransactionsActions({ }); } - const canAllSelectedTransactionsBeRemoved = Object.values(selectedTransactions).every((transaction) => { + const canAllSelectedTransactionsBeRemoved = selectedTransactionsList.every((transaction) => { const canRemoveTransaction = canDeleteCardTransactionByLiabilityType(transaction); const action = getIOUActionForTransactionID(reportActions, transaction.transactionID); const isActionDeleted = isDeletedAction(action); @@ -338,7 +381,7 @@ function useSelectedTransactionsActions({ }, [ selectedTransactionIDs, report, - selectedTransactions, + selectedTransactionsList, translate, isReportArchived, policy, @@ -356,6 +399,7 @@ function useSelectedTransactionsActions({ lastVisitedPath, session?.accountID, showDeleteModal, + hasTransactionsFromMultipleOwners, ]); return { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 1a62f5de57cf..52eb31f0516f 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -57,7 +57,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {getActiveAdminWorkspaces, hasVBBA, isPaidGroupPolicy} from '@libs/PolicyUtils'; -import {generateReportID, getPolicyExpenseChat, isExpenseReport as isExpenseReportUtil, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; +import {generateReportID, getPolicyExpenseChat, getReportOrDraftReport, isExpenseReport as isExpenseReportUtil, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; @@ -452,9 +452,28 @@ function SearchPage({route}: SearchPageProps) { }); } + 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)?.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) { + if (canAllTransactionsBeMoved && !hasMultipleOwners) { options.push({ text: translate('iou.moveExpenses', {count: selectedTransactionsKeys.length}), icon: Expensicons.DocumentMerge, diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 028db7a0dde5..9a6f9555dae5 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -10,7 +10,7 @@ import {createNewReport} from '@libs/actions/Report'; import {changeTransactionsReport} from '@libs/actions/Transaction'; import Navigation from '@libs/Navigation/Navigation'; import Permissions from '@libs/Permissions'; -import {hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; +import {getReportOrDraftReport, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import IOURequestEditReportCommon from '@pages/iou/request/step/IOURequestEditReportCommon'; import CONST from '@src/CONST'; @@ -42,6 +42,30 @@ function SearchTransactionsChangeReport() { Object.values(selectedTransactions).every((transaction) => transaction.reportID === firstTransactionReportID) && firstTransactionReportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? firstTransactionReportID : undefined; + const areAllTransactionsUnreported = + selectedTransactionsKeys.length > 0 && selectedTransactionsKeys.every((transactionKey) => selectedTransactions[transactionKey]?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID); + const targetOwnerAccountID = useMemo(() => { + if (selectedTransactionsKeys.length === 0) { + return undefined; + } + + // Prefer owner metadata attached to each selection (handles unreported expenses) + const ownerFromSelection = selectedTransactionsKeys.map((transactionKey) => selectedTransactions[transactionKey]?.ownerAccountID).find((ownerID) => typeof ownerID === 'number'); + if (ownerFromSelection !== undefined) { + return ownerFromSelection; + } + + const reportIDWithOwner = selectedTransactionsKeys + .map((transactionKey) => selectedTransactions[transactionKey]?.reportID) + .find((reportID) => reportID && reportID !== CONST.REPORT.UNREPORTED_REPORT_ID); + + if (!reportIDWithOwner) { + return undefined; + } + + const report = getReportOrDraftReport(reportIDWithOwner); + return report?.ownerAccountID; + }, [selectedTransactions, selectedTransactionsKeys]); const createReport = () => { if (shouldSelectPolicy) { @@ -109,6 +133,8 @@ function SearchTransactionsChangeReport() { removeFromReport={removeFromReport} createReport={createReport} isEditing + isUnreported={areAllTransactionsUnreported} + targetOwnerAccountID={targetOwnerAccountID} /> ); } diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index c9e38771a802..306823be13c5 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -34,6 +34,7 @@ type Props = { transactionIDs?: string[]; selectedReportID?: string; selectedPolicyID?: string; + targetOwnerAccountID?: number; selectReport: (item: TransactionGroupListItem) => void; removeFromReport?: () => void; isEditing?: boolean; @@ -53,6 +54,7 @@ function IOURequestEditReportCommon({ selectReport, selectedReportID, selectedPolicyID, + targetOwnerAccountID, removeFromReport, isEditing = false, isUnreported, @@ -66,7 +68,17 @@ function IOURequestEditReportCommon({ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [selectedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`, {canBeMissing: true}); - const reportOwnerAccountID = useMemo(() => selectedReport?.ownerAccountID ?? currentUserPersonalDetails.accountID, [selectedReport, currentUserPersonalDetails.accountID]); + const resolvedReportOwnerAccountID = useMemo(() => { + if (targetOwnerAccountID !== undefined) { + return targetOwnerAccountID; + } + + if (selectedReport?.ownerAccountID !== undefined) { + return selectedReport.ownerAccountID; + } + + return currentUserPersonalDetails.accountID; + }, [targetOwnerAccountID, selectedReport, currentUserPersonalDetails.accountID]); const reportPolicy = usePolicy(selectedReport?.policyID); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); @@ -75,7 +87,8 @@ function IOURequestEditReportCommon({ const [allPoliciesID] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesSelector, canBeMissing: false}); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); - const isOwner = selectedReport ? selectedReport.ownerAccountID === currentUserPersonalDetails.accountID : false; + const isSelectedReportUnreported = useMemo(() => !!(isUnreported ?? selectedReportID === CONST.REPORT.UNREPORTED_REPORT_ID), [isUnreported, selectedReportID]); + const isOwner = selectedReport ? selectedReport.ownerAccountID === currentUserPersonalDetails.accountID : isSelectedReportUnreported; const isReportIOU = selectedReport ? isIOUReport(selectedReport) : false; const reportTransactions = useReportTransactions(selectedReportID); @@ -89,7 +102,7 @@ function IOURequestEditReportCommon({ .some((transaction) => transaction?.comment?.liabilityType === CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT); }, [transactionIDs, selectedReport, reportTransactions]); - const shouldShowRemoveFromReport = isEditing && isOwner && !isReportIOU && !isUnreported && !isCardTransaction; + const shouldShowRemoveFromReport = isEditing && isOwner && !isReportIOU && !isSelectedReportUnreported && !isCardTransaction; const expenseReports = useMemo(() => { // Early return if no reports are available to prevent useless loop @@ -106,7 +119,7 @@ function IOURequestEditReportCommon({ } const reports = getOutstandingReportsForUser( policyID, - reportOwnerAccountID, + resolvedReportOwnerAccountID, outstandingReportsByPolicyID?.[policyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, reportNameValuePairs, isEditing, @@ -117,12 +130,12 @@ function IOURequestEditReportCommon({ } return getOutstandingReportsForUser( selectedPolicyID, - reportOwnerAccountID, + resolvedReportOwnerAccountID, outstandingReportsByPolicyID?.[selectedPolicyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, reportNameValuePairs, isEditing, ); - }, [outstandingReportsByPolicyID, reportOwnerAccountID, allPoliciesID, reportNameValuePairs, selectedReport, selectedPolicyID, isEditing]); + }, [outstandingReportsByPolicyID, resolvedReportOwnerAccountID, allPoliciesID, reportNameValuePairs, selectedReport, selectedPolicyID, isEditing]); const reportOptions: TransactionGroupListItem[] = useMemo(() => { if (!outstandingReportsByPolicyID || isEmptyObject(outstandingReportsByPolicyID)) { @@ -194,7 +207,7 @@ function IOURequestEditReportCommon({ icon={Expensicons.Document} /> ); - }, [createReport, isEditing, isOwner, translate, policyForMovingExpenses?.name]); + }, [createReport, isEditing, isOwner, translate, policyForMovingExpenses?.name, isSelectedReportUnreported]); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useMemo(() => {