diff --git a/assets/images/document-merge.svg b/assets/images/document-merge.svg new file mode 100644 index 000000000000..80f38b5d3eb6 --- /dev/null +++ b/assets/images/document-merge.svg @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index eabdc9d5c498..37bad3579923 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1207,6 +1207,7 @@ const CONST = { DELETE: 'delete', ADD_EXPENSE: 'addExpense', REOPEN: 'reopen', + MOVE_EXPENSE: 'moveExpense', }, PRIMARY_ACTIONS: { SUBMIT: 'submit', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 399af77a7415..f7e6cb7944b5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -696,6 +696,15 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => getUrlWithBackToParam(`${action as string}/${iouType as string}/report/${transactionID}/${reportID}`, backTo), }, + MONEY_REQUEST_EDIT_REPORT: { + route: ':action/:iouType/report/:reportID/edit', + getRoute: (action: IOUAction, iouType: IOUType, reportID?: string, backTo = '') => { + if (!reportID) { + Log.warn('Invalid reportID while building route MONEY_REQUEST_EDIT_REPORT'); + } + return getUrlWithBackToParam(`${action as string}/${iouType as string}/report/${reportID}/edit`, backTo); + }, + }, SETTINGS_TAGS_ROOT: { route: 'settings/:policyID/tags', getRoute: (policyID: string | undefined, backTo = '') => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 4483e187aa7e..8f0365e2a0a7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -266,6 +266,7 @@ const SCREENS = { STEP_TIME_EDIT: 'Money_Request_Time_Edit', STEP_SUBRATE_EDIT: 'Money_Request_SubRate_Edit', STEP_REPORT: 'Money_Request_Report', + EDIT_REPORT: 'Money_Request_Edit_Report', }, TRANSACTION_DUPLICATE: { diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index dda9a9f7ff7f..12974e787aef 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -69,6 +69,7 @@ import CreditCardExclamation from '@assets/images/credit-card-exclamation.svg'; import CreditCardHourglass from '@assets/images/credit-card-hourglass.svg'; import CreditCard from '@assets/images/creditcard.svg'; import Crosshair from '@assets/images/crosshair.svg'; +import DocumentMerge from '@assets/images/document-merge.svg'; import DocumentPlus from '@assets/images/document-plus.svg'; import DocumentSlash from '@assets/images/document-slash.svg'; import Document from '@assets/images/document.svg'; @@ -280,6 +281,7 @@ export { DeletedRoomAvatar, Document, DocumentSlash, + DocumentMerge, DomainRoomAvatar, DotIndicator, DotIndicatorUnfilled, diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f232dfc542dc..e904e787267a 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -4,6 +4,7 @@ import {ActivityIndicator, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import useActiveRoute from '@hooks/useActiveRoute'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; @@ -38,9 +39,10 @@ import { hasUpdatedTotal, isAllowedToApproveExpenseReport, isExported as isExportedUtils, - isInvoiceReport, + isInvoiceReport as isInvoiceReportUtil, isProcessingReport, isReportOwner, + isTrackExpenseReport as isTrackExpenseReportUtil, navigateToDetailsPage, reportTransactionsSelector, } from '@libs/ReportUtils'; @@ -133,6 +135,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const {shouldUseNarrowLayout, isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout(); const shouldDisplayNarrowVersion = shouldUseNarrowLayout || isMediumScreenWidth; const route = useRoute(); + const {getReportRHPActiveRoute} = useActiveRoute(); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`, {canBeMissing: true}); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -222,6 +225,20 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea [moneyRequestReport, chatReport, policy, transaction], ); + const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); + const isTrackExpenseReport = isTrackExpenseReportUtil(moneyRequestReport); + + const iouType = useMemo(() => { + if (isTrackExpenseReport) { + return CONST.IOU.TYPE.TRACK; + } + if (isInvoiceReport) { + return CONST.IOU.TYPE.INVOICE; + } + + return CONST.IOU.TYPE.SUBMIT; + }, [isTrackExpenseReport, isInvoiceReport]); + const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const {selectedTransactionsID, setSelectedTransactionsID} = useMoneyRequestReportContext(); @@ -290,7 +307,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea setIsNoDelegateAccessMenuVisible(true); } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); - } else if (isInvoiceReport(moneyRequestReport)) { + } else if (isInvoiceReport) { startAnimation(); payInvoice(type, chatReport, moneyRequestReport, payAsBusiness, methodID, paymentMethod); } else { @@ -298,7 +315,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea payMoneyRequest(type, chatReport, moneyRequestReport, true); } }, - [chatReport, isAnyTransactionOnHold, isDelegateAccessRestricted, moneyRequestReport, startAnimation], + [chatReport, isAnyTransactionOnHold, isDelegateAccessRestricted, isInvoiceReport, moneyRequestReport, startAnimation], ); const confirmApproval = () => { @@ -701,6 +718,20 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea Navigation.navigate(ROUTES.REPORT_WITH_ID_CHANGE_WORKSPACE.getRoute(moneyRequestReport.reportID)); }, }, + [CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE]: { + text: translate('iou.moveExpenses', {count: 1}), + icon: Expensicons.DocumentMerge, + value: CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE, + onSelected: () => { + if (!moneyRequestReport || !transaction) { + return; + } + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, moneyRequestReport.reportID, getReportRHPActiveRoute()), + ); + }, + }, [CONST.REPORT.SECONDARY_ACTIONS.DELETE]: { text: translate('common.delete'), icon: Expensicons.Trashcan, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index d0b8bc6c8b2d..d95f5b15f746 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -124,18 +124,17 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions, const {sortBy, sortOrder} = sortConfig; - const newTransactionID = useMemo(() => { + const newTransactionsID = useMemo(() => { if (!prevTransactions || transactions.length === prevTransactions.length) { return CONST.EMPTY_ARRAY as unknown as string[]; } - return transactions - .filter((transaction) => !prevTransactions.some((prevTransaction) => prevTransaction.transactionID === transaction.transactionID)) - .reduce((latest, t) => { - const inserted = t?.inserted ?? 0; - const latestInserted = latest?.inserted ?? 0; - return inserted > latestInserted ? t : latest; - }, transactions.at(0))?.transactionID; + return transactions.reduce((acc, t) => { + if (!prevTransactions.some((prevTransaction) => prevTransaction.transactionID === t.transactionID)) { + acc.push(t.transactionID); + } + return acc; + }, [] as string[]); }, [prevTransactions, transactions]); const sortedTransactions: TransactionWithOptionalHighlight[] = useMemo(() => { @@ -143,9 +142,9 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions, .sort((a, b) => compareValues(a[getTransactionKey(a, sortBy)], b[getTransactionKey(b, sortBy)], sortOrder, sortBy)) .map((transaction) => ({ ...transaction, - shouldBeHighlighted: newTransactionID === transaction.transactionID, + shouldBeHighlighted: newTransactionsID?.includes(transaction.transactionID), })); - }, [newTransactionID, sortBy, sortOrder, transactions]); + }, [newTransactionsID, sortBy, sortOrder, transactions]); const navigateToTransaction = useCallback( (activeTransaction: OnyxTypes.Transaction) => { diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 0c409f8642a2..02bdf3529da6 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -1,4 +1,5 @@ import {useCallback, useMemo, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import {useMoneyRequestReportContext} from '@components/MoneyRequestReportView/MoneyRequestReportContext'; import {deleteMoneyRequest, unholdRequest} from '@libs/actions/IOU'; @@ -6,16 +7,27 @@ import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {exportReportToCSV} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID, getOriginalMessage, isDeletedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {canDeleteCardTransactionByLiabilityType, canDeleteTransaction, canHoldUnholdReportAction, isMoneyRequestReport as isMoneyRequestReportUtils} from '@libs/ReportUtils'; -import {getTransaction} from '@libs/TransactionUtils'; +import { + canDeleteCardTransactionByLiabilityType, + canDeleteTransaction, + canEditFieldOfMoneyRequest, + canHoldUnholdReportAction, + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + isInvoiceReport, + isMoneyRequestReport as isMoneyRequestReportUtils, + isTrackExpenseReport, +} from '@libs/ReportUtils'; +import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {OriginalMessageIOU, Report, ReportAction, Session} from '@src/types/onyx'; +import type {OriginalMessageIOU, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; import useLocalize from './useLocalize'; // We do not use PRIMARY_REPORT_ACTIONS or SECONDARY_REPORT_ACTIONS because they weren't meant to be used in this situation. `value` property of returned options is later ignored. const HOLD = 'HOLD'; const UNHOLD = 'UNHOLD'; +const MOVE = 'MOVE'; function useSelectedTransactionsActions({ report, @@ -31,8 +43,31 @@ function useSelectedTransactionsActions({ onExportFailed?: () => void; }) { const {selectedTransactionsID, setSelectedTransactionsID} = useMoneyRequestReportContext(); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); + const selectedTransactions = useMemo( + () => + selectedTransactionsID.reduce((acc, transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (transaction) { + acc.push(transaction); + } + return acc; + }, [] as Transaction[]), + [allTransactions, selectedTransactionsID], + ); + const {translate} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const isTrackExpenseThread = isTrackExpenseReport(report); + const isInvoice = isInvoiceReport(report); + let iouType: IOUType = CONST.IOU.TYPE.SUBMIT; + + if (isTrackExpenseThread) { + iouType = CONST.IOU.TYPE.TRACK; + } + if (isInvoice) { + iouType = CONST.IOU.TYPE.INVOICE; + } const handleDeleteTransactions = useCallback(() => { const iouActions = reportActions.filter((action) => isMoneyRequestAction(action)); @@ -67,7 +102,6 @@ function useSelectedTransactionsActions({ } const options = []; const isMoneyRequestReport = isMoneyRequestReportUtils(report); - const selectedTransactions = selectedTransactionsID.map((transactionID) => getTransaction(transactionID)).filter((t) => !!t); 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; @@ -136,6 +170,29 @@ function useSelectedTransactionsActions({ }, }); + const canSelectedExpensesBeMoved = selectedTransactions.every((transaction) => { + if (!transaction) { + return false; + } + const iouReportAction = getIOUActionForTransactionID(reportActions, transaction.transactionID); + + const canMoveExpense = canEditFieldOfMoneyRequest(iouReportAction, CONST.EDIT_REQUEST_FIELD.REPORT); + return canMoveExpense; + }); + + const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report); + if (canSelectedExpensesBeMoved && canUserPerformWriteAction) { + options.push({ + text: translate('iou.moveExpenses', {count: selectedTransactionsID.length}), + icon: Expensicons.DocumentMerge, + value: MOVE, + onSelected: () => { + const route = ROUTES.MONEY_REQUEST_EDIT_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, report?.reportID); + Navigation.navigate(route); + }, + }); + } + const canAllSelectedTransactionsBeRemoved = selectedTransactionsID.every((transactionID) => { const canRemoveTransaction = canDeleteCardTransactionByLiabilityType(transactionID); const action = getIOUActionForTransactionID(reportActions, transactionID); @@ -156,7 +213,7 @@ function useSelectedTransactionsActions({ }); } return options; - }, [onExportFailed, report, reportActions, selectedTransactionsID, session?.accountID, setSelectedTransactionsID, translate, showDeleteModal]); + }, [selectedTransactionsID, report, selectedTransactions, translate, reportActions, setSelectedTransactionsID, onExportFailed, iouType, session?.accountID, showDeleteModal]); return { options: computedOptions, diff --git a/src/languages/en.ts b/src/languages/en.ts index 7ba6858b83e5..b1cb982a2401 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1216,6 +1216,7 @@ const translations = { dates: 'Dates', rates: 'Rates', submitsTo: ({name}: SubmitsToParams) => `Submits to ${name}`, + moveExpenses: () => ({one: 'Move expense', other: 'Move expenses'}), }, share: { shareToExpensify: 'Share to Expensify', diff --git a/src/languages/es.ts b/src/languages/es.ts index 9d90df257774..4bbca7282837 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1214,6 +1214,7 @@ const translations = { dates: 'Fechas', rates: 'Tasas', submitsTo: ({name}: SubmitsToParams) => `Se envĂ­a a ${name}`, + moveExpenses: () => ({one: 'Mover gasto', other: 'Mover gastos'}), }, share: { shareToExpensify: 'Compartir para Expensify', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index ad4873fb0b52..4aa6d41ef1c0 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -102,6 +102,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepParticipants').default, [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesPage').default, [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT]: () => require('../../../../pages/workspace/tags/WorkspaceTagsPage').default, + [SCREENS.MONEY_REQUEST.EDIT_REPORT]: () => require('../../../../pages/iou/request/step/IOURequestEditReport').default, [SCREENS.MONEY_REQUEST.STEP_SCAN]: () => require('../../../../pages/iou/request/step/IOURequestStepScan').default, [SCREENS.MONEY_REQUEST.STEP_TAG]: () => require('../../../../pages/iou/request/step/IOURequestStepTag').default, [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: () => require('../../../../pages/iou/request/step/IOURequestStepWaypoint').default, diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 8a204ab436b3..3f59de155557 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -520,49 +520,51 @@ function navigateToReportWithPolicyCheck( forceReplace = false, ref = navigationRef, ) { - const targetReport = reportID ? {reportID, ...allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]} : report; - const policyID = policyIDToCheck ?? getPolicyIDFromState(navigationRef.getRootState() as State); - const policyMemberAccountIDs = getPolicyEmployeeAccountIDs(policyID); - const shouldOpenAllWorkspace = isEmptyObject(targetReport) ? true : !doesReportBelongToWorkspace(targetReport, policyMemberAccountIDs, policyID); + isNavigationReady().then(() => { + const targetReport = reportID ? {reportID, ...allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]} : report; + const policyID = policyIDToCheck ?? getPolicyIDFromState(navigationRef.getRootState() as State); + const policyMemberAccountIDs = getPolicyEmployeeAccountIDs(policyID); + const shouldOpenAllWorkspace = isEmptyObject(targetReport) ? true : !doesReportBelongToWorkspace(targetReport, policyMemberAccountIDs, policyID); - if ((shouldOpenAllWorkspace && !policyID) || !shouldOpenAllWorkspace) { - linkTo(ref.current, ROUTES.REPORT_WITH_ID.getRoute(targetReport?.reportID, reportActionID, referrer, undefined, undefined, backTo), {forceReplace: !!forceReplace}); - return; - } + if ((shouldOpenAllWorkspace && !policyID) || !shouldOpenAllWorkspace) { + linkTo(ref.current, ROUTES.REPORT_WITH_ID.getRoute(targetReport?.reportID, reportActionID, referrer, undefined, undefined, backTo), {forceReplace: !!forceReplace}); + return; + } - const params: Record = { - reportID: targetReport?.reportID, - }; + const params: Record = { + reportID: targetReport?.reportID, + }; - if (reportActionID) { - params.reportActionID = reportActionID; - } + if (reportActionID) { + params.reportActionID = reportActionID; + } - if (referrer) { - params.referrer = referrer; - } + if (referrer) { + params.referrer = referrer; + } - if (forceReplace) { + if (forceReplace) { + ref.dispatch( + StackActions.replace(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, { + policyID: undefined, + screen: SCREENS.REPORT, + params, + }), + ); + return; + } + + if (backTo) { + params.backTo = backTo; + } ref.dispatch( - StackActions.replace(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, { + StackActions.push(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, { policyID: undefined, screen: SCREENS.REPORT, params, }), ); - return; - } - - if (backTo) { - params.backTo = backTo; - } - ref.dispatch( - StackActions.push(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, { - policyID: undefined, - screen: SCREENS.REPORT, - params, - }), - ); + }); } function getReportRouteByID(reportID?: string, routes: NavigationRoute[] = navigationRef.getRootState().routes): NavigationRoute | null { diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index a82ea4fc81c4..bd9ed0388e49 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1305,6 +1305,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT]: ROUTES.SETTINGS_CATEGORIES_ROOT.route, [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT]: ROUTES.SETTINGS_TAGS_ROOT.route, + [SCREENS.MONEY_REQUEST.EDIT_REPORT]: ROUTES.MONEY_REQUEST_EDIT_REPORT.route, [SCREENS.MONEY_REQUEST.STEP_SEND_FROM]: ROUTES.MONEY_REQUEST_STEP_SEND_FROM.route, [SCREENS.MONEY_REQUEST.STEP_REPORT]: ROUTES.MONEY_REQUEST_STEP_REPORT.route, [SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO]: ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index a9b6de2afda8..18d17d4529b4 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1157,6 +1157,12 @@ type MoneyRequestNavigatorParamList = { reportID: string; backTo: Routes; }; + [SCREENS.MONEY_REQUEST.EDIT_REPORT]: { + action: IOUAction; + iouType: IOUType; + reportID: string; + backTo: Routes; + }; [SCREENS.MONEY_REQUEST.STEP_REPORT]: { action: IOUAction; iouType: IOUType; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index b0d9ce609737..c7eec85be275 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -14,9 +14,10 @@ import { hasIntegrationAutoSync, isPrefferedExporter, } from './PolicyUtils'; -import {getIOUActionForReportID, getOneTransactionThreadReportID, isPayAction} from './ReportActionsUtils'; +import {getIOUActionForReportID, getIOUActionForTransactionID, getOneTransactionThreadReportID, isPayAction} from './ReportActionsUtils'; import { canAddTransaction, + canEditFieldOfMoneyRequest, isArchivedReport, isClosedReport as isClosedReportUtils, isCurrentUserSubmitter, @@ -320,6 +321,20 @@ function isChangeWorkspaceAction(report: Report, policy?: Policy): boolean { return policies.filter((newPolicy) => isWorkspaceEligibleForReportChange(newPolicy, report, session, policy)).length > 0; } +function isMoveTransactionAction(reportTransactions: Transaction[], reportActions?: ReportAction[]) { + const transaction = reportTransactions.at(0); + + if (reportTransactions.length !== 1 || !transaction || !reportActions) { + return false; + } + + const iouReportAction = getIOUActionForTransactionID(reportActions, transaction.transactionID); + + const canMoveExpense = canEditFieldOfMoneyRequest(iouReportAction, CONST.EDIT_REQUEST_FIELD.REPORT); + + return canMoveExpense; +} + function isDeleteAction(report: Report, reportTransactions: Transaction[], reportActions?: ReportAction[]): boolean { const transactionThreadReportID = getOneTransactionThreadReportID(report.reportID, reportActions ?? []); const isExpenseReport = isExpenseReportUtils(report); @@ -424,6 +439,10 @@ function getSecondaryReportActions( options.push(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE); } + if (isMoveTransactionAction(reportTransactions, reportActions)) { + options.push(CONST.REPORT.SECONDARY_ACTIONS.MOVE_EXPENSE); + } + options.push(CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS); if (isDeleteAction(report, reportTransactions, reportActions)) { diff --git a/src/pages/iou/request/step/IOURequestEditReport.tsx b/src/pages/iou/request/step/IOURequestEditReport.tsx new file mode 100644 index 000000000000..223ee5877fa3 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestEditReport.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import {useMoneyRequestReportContext} from '@components/MoneyRequestReportView/MoneyRequestReportContext'; +import type {ListItem} from '@components/SelectionList/types'; +import {changeTransactionsReport} from '@libs/actions/Transaction'; +import Navigation from '@libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import IOURequestEditReportCommon from './IOURequestEditReportCommon'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; + +type ReportListItem = ListItem & { + /** reportID of the report */ + value: string; +}; + +type IOURequestEditReportProps = WithWritableReportOrNotFoundProps; + +function IOURequestEditReport({route}: IOURequestEditReportProps) { + const {backTo, reportID} = route.params; + + const {selectedTransactionsID, setSelectedTransactionsID} = useMoneyRequestReportContext(); + + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: false}); + + const selectReport = (item: ReportListItem) => { + if (selectedTransactionsID.length === 0) { + return; + } + if (item.value !== transactionReport?.reportID) { + changeTransactionsReport(selectedTransactionsID, item.value); + setSelectedTransactionsID([]); + } + Navigation.dismissModalWithReport({reportID: item.value}); + }; + + return ( + + ); +} + +IOURequestEditReport.displayName = 'IOURequestEditReport'; + +export default withWritableReportOrNotFound(IOURequestEditReport); diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx new file mode 100644 index 000000000000..ac645e7dc186 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -0,0 +1,100 @@ +import React, {useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; +import SelectionList from '@components/SelectionList'; +import type {ListItem} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import {getOutstandingReportsForUser} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import type {Report} from '@src/types/onyx'; +import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems'; +import StepScreenWrapper from './StepScreenWrapper'; + +type ReportListItem = ListItem & { + /** reportID of the report */ + value: string; +}; + +/** + * This function narrows down the data from Onyx to just the properties that we want to trigger a re-render of the component. + * This helps minimize re-rendering and makes the entire component more performant. + */ +const reportSelector = (report: OnyxEntry): OnyxEntry => + report && { + ownerAccountID: report.ownerAccountID, + reportID: report.reportID, + policyID: report.policyID, + reportName: report.reportName, + stateNum: report.stateNum, + statusNum: report.statusNum, + type: report.type, + }; + +type Props = { + backTo: Route | undefined; + transactionReport: OnyxEntry; + selectReport: (item: ReportListItem) => void; +}; + +function IOURequestEditReportCommon({backTo, transactionReport, selectReport}: Props) { + const {translate} = useLocalize(); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: (reports) => mapOnyxCollectionItems(reports, reportSelector), canBeMissing: true}); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + + const expenseReports = getOutstandingReportsForUser(transactionReport?.policyID, transactionReport?.ownerAccountID ?? currentUserPersonalDetails.accountID, allReports ?? {}); + const reportOptions: ReportListItem[] = useMemo(() => { + if (!allReports) { + return []; + } + + const isTransactionReportCorrect = expenseReports.some((report) => report?.reportID === transactionReport?.reportID); + return expenseReports + .sort((a, b) => a?.reportName?.localeCompare(b?.reportName?.toLowerCase() ?? '') ?? 0) + .filter((report) => !debouncedSearchValue || report?.reportName?.toLowerCase().includes(debouncedSearchValue.toLowerCase())) + .filter((report): report is NonNullable => report !== undefined) + .map((report) => ({ + text: report.reportName, + value: report.reportID, + keyForList: report.reportID, + isSelected: isTransactionReportCorrect ? report.reportID === transactionReport?.reportID : expenseReports.at(0)?.reportID === report.reportID, + })); + }, [allReports, debouncedSearchValue, expenseReports, transactionReport?.reportID]); + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const headerMessage = useMemo(() => (searchValue && !reportOptions.length ? translate('common.noResultsFound') : ''), [searchValue, reportOptions, translate]); + + return ( + + = CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined} + shouldSingleExecuteRowSelect + headerMessage={headerMessage} + initiallyFocusedOptionKey={transactionReport?.reportID} + ListItem={UserListItem} + /> + + ); +} + +export default IOURequestEditReportCommon; diff --git a/src/pages/iou/request/step/IOURequestStepReport.tsx b/src/pages/iou/request/step/IOURequestStepReport.tsx index a7d3622e6b5f..df93e7d921be 100644 --- a/src/pages/iou/request/step/IOURequestStepReport.tsx +++ b/src/pages/iou/request/step/IOURequestStepReport.tsx @@ -1,21 +1,12 @@ -import React, {useMemo} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; +import React from 'react'; import {useOnyx} from 'react-native-onyx'; -import SelectionList from '@components/SelectionList'; import type {ListItem} from '@components/SelectionList/types'; -import UserListItem from '@components/SelectionList/UserListItem'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDebouncedState from '@hooks/useDebouncedState'; -import useLocalize from '@hooks/useLocalize'; import {changeTransactionsReport, setTransactionReport} from '@libs/actions/Transaction'; import Navigation from '@libs/Navigation/Navigation'; -import {getOutstandingReportsForUser} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type {Report} from '@src/types/onyx'; -import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems'; -import StepScreenWrapper from './StepScreenWrapper'; +import IOURequestEditReportCommon from './IOURequestEditReportCommon'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; @@ -28,54 +19,13 @@ type ReportListItem = ListItem & { type IOURequestStepReportProps = WithWritableReportOrNotFoundProps & WithFullTransactionOrNotFoundProps; -/** - * This function narrows down the data from Onyx to just the properties that we want to trigger a re-render of the component. - * This helps minimize re-rendering and makes the entire component more performant. - */ -const reportSelector = (report: OnyxEntry): OnyxEntry => - report && { - ownerAccountID: report.ownerAccountID, - reportID: report.reportID, - policyID: report.policyID, - reportName: report.reportName, - stateNum: report.stateNum, - statusNum: report.statusNum, - type: report.type, - }; - function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { - const {translate} = useLocalize(); const {backTo, action} = route.params; - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: (c) => mapOnyxCollectionItems(c, reportSelector), canBeMissing: true}); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); - const isEditing = action === CONST.IOU.ACTION.EDIT; - // We need to get the policyID because it's not defined in the transaction object before we select a report manually. - const transactionReport = Object.values(allReports ?? {}).find( - (report) => report?.reportID === transaction?.reportID || (transaction?.participants && report?.reportID === transaction?.participants?.at(0)?.reportID), - ); - const expenseReports = getOutstandingReportsForUser(transactionReport?.policyID, transactionReport?.ownerAccountID ?? currentUserPersonalDetails.accountID, allReports ?? {}); - const reportOptions: ReportListItem[] = useMemo(() => { - if (!allReports) { - return []; - } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const reportID = transaction?.reportID || transaction?.participants?.at(0)?.reportID; + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); - const isTransactionReportCorrect = expenseReports.some((report) => report?.reportID === transaction?.reportID); - return expenseReports - .sort((a, b) => a?.reportName?.localeCompare(b?.reportName?.toLowerCase() ?? '') ?? 0) - .filter((report) => !debouncedSearchValue || report?.reportName?.toLowerCase().includes(debouncedSearchValue.toLowerCase())) - .filter((report): report is NonNullable => report !== undefined) - .map((report) => ({ - text: report.reportName, - value: report.reportID, - keyForList: report.reportID, - isSelected: isTransactionReportCorrect ? report.reportID === transaction?.reportID : expenseReports.at(0)?.reportID === report.reportID, - })); - }, [allReports, debouncedSearchValue, expenseReports, transaction?.reportID]); - - const navigateBack = () => { - Navigation.goBack(backTo); - }; + const isEditing = action === CONST.IOU.ACTION.EDIT; const selectReport = (item: ReportListItem) => { if (!transaction) { @@ -87,32 +37,15 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { changeTransactionsReport([transaction.transactionID], item.value); } } - Navigation.goBack(backTo); + Navigation.dismissModalWithReport({reportID: item.value}); }; - const headerMessage = useMemo(() => (searchValue && !reportOptions.length ? translate('common.noResultsFound') : ''), [searchValue, reportOptions, translate]); - return ( - - = CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined} - shouldSingleExecuteRowSelect - headerMessage={headerMessage} - initiallyFocusedOptionKey={transaction?.reportID} - ListItem={UserListItem} - /> - + ); } diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index e522167e261e..0df2895adec3 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -48,7 +48,8 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_DESTINATION | typeof SCREENS.MONEY_REQUEST.STEP_TIME | typeof SCREENS.MONEY_REQUEST.STEP_TIME_EDIT - | typeof SCREENS.MONEY_REQUEST.STEP_SUBRATE; + | typeof SCREENS.MONEY_REQUEST.STEP_SUBRATE + | typeof SCREENS.MONEY_REQUEST.EDIT_REPORT; type WithWritableReportOrNotFoundProps = WithWritableReportOrNotFoundOnyxProps & PlatformStackScreenProps;