From 30285636871bea94aa4741072f953c1051f35aa0 Mon Sep 17 00:00:00 2001 From: Sibtain Ali Date: Sat, 5 Jul 2025 23:00:20 +0500 Subject: [PATCH 1/9] add routes for the distance request --- src/CONST/index.ts | 2 + src/ROUTES.ts | 15 + src/SCREENS.ts | 3 + .../ModalStackNavigators/index.tsx | 3 + src/libs/Navigation/linkingConfig/config.ts | 14 + src/libs/Navigation/types.ts | 29 + .../iou/request/DistanceRequestStartPage.tsx | 17 + .../request/IOURequestRedirectToStartPage.tsx | 4 + .../step/IOURequestStepDistanceManual.tsx | 38 ++ .../step/IOURequestStepDistanceMap.tsx | 586 ++++++++++++++++++ .../step/withFullTransactionOrNotFound.tsx | 5 +- .../step/withWritableReportOrNotFound.tsx | 5 +- 12 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 src/pages/iou/request/DistanceRequestStartPage.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepDistanceManual.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepDistanceMap.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 353d210eaf7d..ef9c0abd94bc 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2632,6 +2632,8 @@ const CONST = { MANUAL: 'manual', SCAN: 'scan', PER_DIEM: 'per-diem', + DISTANCE_MAP: 'distanceMap', + DISTANCE_MANUAL: 'distanceManual', }, EXPENSE_TYPE: { DISTANCE: 'distance', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 15d7874dc603..851b912c0a69 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -925,6 +925,21 @@ const ROUTES = { label ? `${backTo || state ? '&' : '?'}label=${encodeURIComponent(label)}` : '' }` as const, }, + DISTANCE_REQUEST_CREATE: { + route: ':action/:iouType/start/:transactionID/:reportID/distance/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance/${backToReport ?? ''}` as const, + }, + DISTANCE_REQUEST_CREATE_TAB_MAP: { + route: ':action/:iouType/start/:transactionID/:reportID/distance/map/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance/map/${backToReport ?? ''}` as const, + }, + DISTANCE_REQUEST_CREATE_TAB_MANUAL: { + route: ':action/:iouType/start/:transactionID/:reportID/distance/manual/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance/manual/${backToReport ?? ''}` as const, + }, IOU_SEND_ADD_BANK_ACCOUNT: 'pay/new/add-bank-account', IOU_SEND_ADD_DEBIT_CARD: 'pay/new/add-debit-card', IOU_SEND_ENABLE_PAYMENTS: 'pay/new/enable-payments', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 926e5490ddff..2290980e549a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -274,6 +274,9 @@ const SCREENS = { EDIT_REPORT: 'Money_Request_Edit_Report', SPLIT_EXPENSE: 'Money_Request_Split_Expense', SPLIT_EXPENSE_EDIT: 'Money_Request_Split_Expense_Edit', + DISTANCE_CREATE: 'Money_Request_Distance_Create', + STEP_DISTANCE_MAP: 'Money_Request_Step_Distance_Map', + STEP_DISTANCE_MANUAL: 'Money_Request_Step_Distance_Manual', }, TRANSACTION_DUPLICATE: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index ed2f102c3055..44623baeced1 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -127,6 +127,9 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepScan/ReceiptViewModal').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE]: () => require('../../../../pages/iou/SplitExpensePage').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: () => require('../../../../pages/iou/SplitExpenseEditPage').default, + [SCREENS.MONEY_REQUEST.DISTANCE_CREATE]: () => require('../../../../pages/iou/request/DistanceRequestStartPage').default, + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MAP]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceMap').default, + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceManual').default, }); const TravelModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 9c5cbc519edf..a7a7f8694cc8 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1342,6 +1342,20 @@ const config: LinkingOptions['config'] = { }, }, }, + [SCREENS.MONEY_REQUEST.DISTANCE_CREATE]: { + path: ROUTES.DISTANCE_REQUEST_CREATE.route, + exact: true, + screens: { + map: { + path: ROUTES.DISTANCE_REQUEST_CREATE_TAB_MAP.route, + exact: true, + }, + manual: { + path: ROUTES.DISTANCE_REQUEST_CREATE_TAB_MANUAL.route, + exact: true, + }, + }, + }, [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, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 02220bff83f9..62341e181d09 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1511,6 +1511,35 @@ type MoneyRequestNavigatorParamList = { pageIndex: string; transactionID: string; }; + [SCREENS.MONEY_REQUEST.DISTANCE_CREATE]: { + iouType: IOUType; + reportID: string; + transactionID: string; + + // These are not used in the screen, but are needed for the navigation + // for IOURequestStepDistance and IOURequestStepAmount components + backTo: never; + action: never; + currency: never; + pageIndex?: string; + backToReport?: string; + }; + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL]: { + action: IOUAction; + iouType: IOUType; + transactionID: string; + reportID: string; + backTo: Routes; + backToReport?: string; + }; + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MAP]: { + action: IOUAction; + iouType: IOUType; + transactionID: string; + reportID: string; + backTo: Routes; + backToReport?: string; + }; }; type WorkspaceConfirmationNavigatorParamList = { diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx new file mode 100644 index 000000000000..8d2539ca7d6d --- /dev/null +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -0,0 +1,17 @@ +import type SCREENS from '@src/SCREENS'; +import type {SelectedTabRequest} from '@src/types/onyx'; +import type {WithWritableReportOrNotFoundProps} from './step/withWritableReportOrNotFound'; + +type DistanceRequestStartPageProps = WithWritableReportOrNotFoundProps & { + // eslint-disable-next-line react/no-unused-prop-types + defaultSelectedTab: SelectedTabRequest; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function DistanceRequestStartPage(props: DistanceRequestStartPageProps) { + return null; +} + +DistanceRequestStartPage.displayName = 'DistanceRequestStartPage'; + +export default DistanceRequestStartPage; diff --git a/src/pages/iou/request/IOURequestRedirectToStartPage.tsx b/src/pages/iou/request/IOURequestRedirectToStartPage.tsx index 0a10b760f950..4be0a06e6c75 100644 --- a/src/pages/iou/request/IOURequestRedirectToStartPage.tsx +++ b/src/pages/iou/request/IOURequestRedirectToStartPage.tsx @@ -34,6 +34,10 @@ function IOURequestRedirectToStartPage({ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID)); } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN) { Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID)); + } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP) { + Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE_TAB_MAP.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID)); + } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL) { + Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID)); } // This useEffect should only run on mount which is why there are no dependencies being passed in the second parameter diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx new file mode 100644 index 000000000000..7ed412dc8a98 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type {OnyxEntry} from 'react-native-onyx'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import type SCREENS from '@src/SCREENS'; +import type Transaction from '@src/types/onyx/Transaction'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type IOURequestStepDistanceManualProps = WithCurrentUserPersonalDetailsProps & + WithWritableReportOrNotFoundProps & { + /** The transaction object being modified in Onyx */ + transaction: OnyxEntry; + }; + +function IOURequestStepDistanceManual({ + report, + route: { + params: {action, iouType, reportID, transactionID, backTo, backToReport}, + }, + transaction, + currentUserPersonalDetails, +}: IOURequestStepDistanceManualProps) { + return null; +} + +IOURequestStepDistanceManual.displayName = 'IOURequestStepDistanceManual'; + +const IOURequestStepDistanceManualWithOnyx = IOURequestStepDistanceManual; + +const IOURequestStepDistanceManualWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepDistanceManualWithOnyx); +// eslint-disable-next-line rulesdir/no-negated-variables +const IOURequestStepDistanceManualWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepDistanceManualWithCurrentUserPersonalDetails, true); +// eslint-disable-next-line rulesdir/no-negated-variables +const IOURequestStepDistanceManualWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepDistanceManualWithWritableReportOrNotFound); + +export default IOURequestStepDistanceManualWithFullTransactionOrNotFound; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx new file mode 100644 index 000000000000..c41da19af5d0 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -0,0 +1,586 @@ +import {deepEqual} from 'fast-equals'; +import isEmpty from 'lodash/isEmpty'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {ScrollView as RNScrollView} from 'react-native'; +import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescript/types'; +import type {OnyxEntry} from 'react-native-onyx'; +import Button from '@components/Button'; +import DistanceRequestFooter from '@components/DistanceRequest/DistanceRequestFooter'; +import DistanceRequestRenderItem from '@components/DistanceRequest/DistanceRequestRenderItem'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import DraggableList from '@components/DraggableList'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useFetchRoute from '@hooks/useFetchRoute'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import usePrevious from '@hooks/usePrevious'; +import useThemeStyles from '@hooks/useThemeStyles'; +import { + createDistanceRequest, + getIOURequestPolicyID, + getMoneyRequestParticipantsFromReport, + resetSplitShares, + setCustomUnitRateID, + setMoneyRequestAmount, + setMoneyRequestMerchant, + setMoneyRequestParticipantsFromReport, + setMoneyRequestPendingFields, + setSplitShares, + trackExpense, + updateMoneyRequestDistance, +} from '@libs/actions/IOU'; +import {init, stop} from '@libs/actions/MapboxToken'; +import {openReport} from '@libs/actions/Report'; +import {openDraftDistanceExpense, removeWaypoint, updateWaypoints as updateWaypointsUtil} from '@libs/actions/Transaction'; +import {createBackupTransaction, removeBackupTransaction, restoreOriginalTransactionFromBackup} from '@libs/actions/TransactionEdit'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import type {MileageRate} from '@libs/DistanceRequestUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import {navigateToParticipantPage, shouldUseTransactionDraft} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; +import {getPersonalPolicy, getPolicy, isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {getPolicyExpenseChat, isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import {getDistanceInMeters, getRateID, getRequestType, getValidWaypoints, hasRoute, isCustomUnitRateIDForP2P} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Participant} from '@src/types/onyx/IOU'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; +import type Transaction from '@src/types/onyx/Transaction'; +import StepScreenWrapper from './StepScreenWrapper'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type IOURequestStepDistanceMapProps = WithCurrentUserPersonalDetailsProps & + WithWritableReportOrNotFoundProps & { + /** The transaction object being modified in Onyx */ + transaction: OnyxEntry; + }; + +function IOURequestStepDistanceMap({ + report, + route: { + params: {action, iouType, reportID, transactionID, backTo, backToReport}, + }, + transaction, + currentUserPersonalDetails, +}: IOURequestStepDistanceMapProps) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {canBeMissing: true}); + const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, {canBeMissing: true}); + const policy = usePolicy(report?.policyID); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: false}); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {canBeMissing: false}); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`, {canBeMissing: false}); + const [optimisticWaypoints, setOptimisticWaypoints] = useState(null); + const waypoints = useMemo( + () => + optimisticWaypoints ?? + transaction?.comment?.waypoints ?? { + waypoint0: {keyForList: 'start_waypoint'}, + waypoint1: {keyForList: 'stop_waypoint'}, + }, + [optimisticWaypoints, transaction], + ); + + const backupWaypoints = transactionBackup?.pendingFields?.waypoints ? transactionBackup?.comment?.waypoints : undefined; + // When online, fetch the backup route to ensure the map is populated even if the user does not save the transaction. + // Fetch the backup route first to ensure the backup transaction map is updated before the main transaction map. + // This prevents a scenario where the main map loads, the user dismisses the map editor, and the backup map has not yet loaded due to delay. + useFetchRoute(transactionBackup, backupWaypoints, action, CONST.TRANSACTION.STATE.BACKUP); + const {shouldFetchRoute, validatedWaypoints} = useFetchRoute( + transaction, + waypoints, + action, + shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT, + ); + const waypointsList = Object.keys(waypoints); + const previousWaypoints = usePrevious(waypoints); + const numberOfWaypoints = Object.keys(waypoints).length; + const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; + const scrollViewRef = useRef(null); + const isLoadingRoute = transaction?.comment?.isLoading ?? false; + const isLoading = transaction?.isLoading ?? false; + const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; + const hasRouteError = !!transaction?.errorFields?.route; + const [shouldShowAtLeastTwoDifferentWaypointsError, setShouldShowAtLeastTwoDifferentWaypointsError] = useState(false); + const isWaypointEmpty = (waypoint?: Waypoint) => { + if (!waypoint) { + return true; + } + const {keyForList, ...waypointWithoutKey} = waypoint; + return isEmpty(waypointWithoutKey); + }; + const nonEmptyWaypointsCount = useMemo(() => Object.keys(waypoints).filter((key) => !isWaypointEmpty(waypoints[key])).length, [waypoints]); + + const duplicateWaypointsError = useMemo( + () => nonEmptyWaypointsCount >= 2 && Object.keys(validatedWaypoints).length !== nonEmptyWaypointsCount, + [nonEmptyWaypointsCount, validatedWaypoints], + ); + const atLeastTwoDifferentWaypointsError = useMemo(() => Object.keys(validatedWaypoints).length < 2, [validatedWaypoints]); + const isEditing = action === CONST.IOU.ACTION.EDIT; + const transactionWasSaved = useRef(false); + const isCreatingNewRequest = !(backTo || isEditing); + const [recentWaypoints, {status: recentWaypointsStatus}] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS, {canBeMissing: true}); + const iouRequestType = getRequestType(transaction); + const customUnitRateID = getRateID(transaction); + + // Sets `amount` and `split` share data before moving to the next step to avoid briefly showing `0.00` as the split share for participants + const setDistanceRequestData = useCallback( + (participants: Participant[]) => { + // Get policy report based on transaction participants + const isPolicyExpenseChat = participants?.some((participant) => participant.isPolicyExpenseChat); + const selectedReportID = participants?.length === 1 ? (participants.at(0)?.reportID ?? reportID) : reportID; + const policyReport = participants.at(0) ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`] : report; + + const IOUpolicyID = getIOURequestPolicyID(transaction, policyReport); + // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 + // eslint-disable-next-line deprecation/deprecation + const IOUpolicy = getPolicy(report?.policyID ?? IOUpolicyID); + const policyCurrency = policy?.outputCurrency ?? getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; + + const mileageRates = DistanceRequestUtils.getMileageRates(IOUpolicy); + const defaultMileageRate = DistanceRequestUtils.getDefaultMileageRate(IOUpolicy); + const mileageRate: MileageRate | undefined = isCustomUnitRateIDForP2P(transaction) + ? DistanceRequestUtils.getRateForP2P(policyCurrency, transaction) + : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (customUnitRateID && mileageRates?.[customUnitRateID]) || defaultMileageRate; + + const {unit, rate} = mileageRate ?? {}; + const distance = getDistanceInMeters(transaction, unit); + const currency = mileageRate?.currency ?? policyCurrency; + const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); + setMoneyRequestAmount(transactionID, amount, currency); + + const participantAccountIDs: number[] | undefined = participants?.map((participant) => Number(participant.accountID ?? CONST.DEFAULT_NUMBER_ID)); + if (isSplitRequest && amount && currency && !isPolicyExpenseChat) { + setSplitShares(transaction, amount, currency ?? '', participantAccountIDs ?? []); + } + }, + [report, allReports, transaction, transactionID, isSplitRequest, policy?.outputCurrency, reportID, customUnitRateID], + ); + + // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace + // request and the workspace requires a category or a tag + const shouldSkipConfirmation: boolean = useMemo(() => { + if (!skipConfirmation || !report?.reportID) { + return false; + } + + return ( + iouType !== CONST.IOU.TYPE.SPLIT && + !isArchivedReport(reportNameValuePairs) && + !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))) + ); + }, [report, skipConfirmation, policy, reportNameValuePairs, iouType]); + let buttonText = !isCreatingNewRequest ? translate('common.save') : translate('common.next'); + if (shouldSkipConfirmation) { + if (iouType === CONST.IOU.TYPE.SPLIT) { + buttonText = translate('iou.split'); + } else { + buttonText = translate('iou.createExpense'); + } + } + + useEffect(() => { + if (iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE || isOffline || recentWaypointsStatus === 'loading' || recentWaypoints !== undefined) { + return; + } + + // Only load the recent waypoints if they have been read from Onyx as undefined + // If the account doesn't have recent waypoints they will be returned as an empty array + openDraftDistanceExpense(); + }, [iouRequestType, recentWaypointsStatus, recentWaypoints, isOffline]); + + useEffect(() => { + init(); + return stop; + }, []); + + useEffect(() => { + if (numberOfWaypoints <= numberOfPreviousWaypoints) { + return; + } + scrollViewRef.current?.scrollToEnd({animated: true}); + }, [numberOfPreviousWaypoints, numberOfWaypoints]); + + useEffect(() => { + if (nonEmptyWaypointsCount >= 2 && (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || isLoading)) { + return; + } + setShouldShowAtLeastTwoDifferentWaypointsError(false); + }, [atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, isLoading, isLoadingRoute, nonEmptyWaypointsCount, transaction]); + + // This effect runs when the component is mounted and unmounted. It's purpose is to be able to properly + // discard changes if the user cancels out of making any changes. This is accomplished by backing up the + // original transaction, letting the user modify the current transaction, and then if the user ever + // cancels out of the modal without saving changes, the original transaction is restored from the backup. + useEffect(() => { + if (isCreatingNewRequest) { + return () => {}; + } + const isDraft = shouldUseTransactionDraft(action); + // On mount, create the backup transaction. + createBackupTransaction(transaction, isDraft); + + return () => { + // If the user cancels out of the modal without saving changes, then the original transaction + // needs to be restored from the backup so that all changes are removed. + if (transactionWasSaved.current) { + removeBackupTransaction(transaction?.transactionID); + return; + } + restoreOriginalTransactionFromBackup(transaction?.transactionID, isDraft); + + // If the user opens IOURequestStepDistanceMap in offline mode and then goes online, re-open the report to fill in missing fields from the transaction backup + if (!transaction?.reportID || hasRoute(transaction, true)) { + return; + } + openReport(transaction?.reportID); + }; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + + const navigateBack = useCallback(() => { + Navigation.goBack(backTo); + }, [backTo]); + + /** + * Takes the user to the page for editing a specific waypoint + * @param index of the waypoint to edit + */ + const navigateToWaypointEditPage = useCallback( + (index: number) => { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, report?.reportID ?? reportID, index.toString(), Navigation.getActiveRoute()), + ); + }, + [action, transactionID, report?.reportID, reportID], + ); + + const navigateToConfirmationPage = useCallback(() => { + switch (iouType) { + case CONST.IOU.TYPE.REQUEST: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, transactionID, reportID, backToReport)); + break; + case CONST.IOU.TYPE.SEND: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.PAY, transactionID, reportID)); + break; + default: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport)); + } + }, [backToReport, iouType, reportID, transactionID]); + + const navigateToNextStep = useCallback(() => { + if (transaction?.splitShares) { + resetSplitShares(transaction); + } + if (backTo) { + Navigation.goBack(backTo); + return; + } + + // If a reportID exists in the report object, it's because either: + // - The user started this flow from using the + button in the composer inside a report. + // - The user started this flow from using the global create menu by selecting the Track expense option. + // In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight + // to the confirm step. + // If the user started this flow using the Create expense option (combined submit/track flow), they should be redirected to the participants page. + if (report?.reportID && !isArchivedReport(reportNameValuePairs) && iouType !== CONST.IOU.TYPE.CREATE) { + const selectedParticipants = getMoneyRequestParticipantsFromReport(report); + const participants = selectedParticipants.map((participant) => { + const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; + return participantAccountID ? getParticipantsOption(participant, personalDetails) : getReportOption(participant); + }); + setDistanceRequestData(participants); + if (shouldSkipConfirmation) { + setMoneyRequestPendingFields(transactionID, {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + setMoneyRequestMerchant(transactionID, translate('iou.fieldPending'), false); + const participant = participants.at(0); + if (iouType === CONST.IOU.TYPE.TRACK && participant) { + trackExpense({ + report, + isDraftPolicy: false, + participantParams: { + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + }, + policyParams: { + policy, + }, + transactionParams: { + amount: 0, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: translate('iou.fieldPending'), + receipt: {}, + billable: false, + validWaypoints: getValidWaypoints(waypoints, true), + customUnitRateID, + attendees: transaction?.comment?.attendees, + }, + }); + return; + } + + createDistanceRequest({ + report, + participants, + currentUserLogin: currentUserPersonalDetails.login, + currentUserAccountID: currentUserPersonalDetails.accountID, + iouType, + existingTransaction: transaction, + transactionParams: { + amount: 0, + comment: '', + created: transaction?.created ?? '', + currency: transaction?.currency ?? 'USD', + merchant: translate('iou.fieldPending'), + billable: !!policy?.defaultBillable, + validWaypoints: getValidWaypoints(waypoints, true), + customUnitRateID: DistanceRequestUtils.getCustomUnitRateID(report.reportID), + splitShares: transaction?.splitShares, + attendees: transaction?.comment?.attendees, + }, + backToReport, + }); + return; + } + setMoneyRequestParticipantsFromReport(transactionID, report).then(() => { + navigateToConfirmationPage(); + }); + return; + } + + // If there was no reportID, then that means the user started this flow from the global menu + // and an optimistic reportID was generated. In that case, the next step is to select the participants for this expense. + if (iouType === CONST.IOU.TYPE.CREATE && isPaidGroupPolicy(activePolicy) && activePolicy?.isPolicyExpenseChatEnabled && !shouldRestrictUserBillableActions(activePolicy.id)) { + const activePolicyExpenseChat = getPolicyExpenseChat(currentUserPersonalDetails.accountID, activePolicy?.id); + const rateID = DistanceRequestUtils.getCustomUnitRateID(activePolicyExpenseChat?.reportID); + setCustomUnitRateID(transactionID, rateID); + setMoneyRequestParticipantsFromReport(transactionID, activePolicyExpenseChat).then(() => { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( + CONST.IOU.ACTION.CREATE, + iouType === CONST.IOU.TYPE.CREATE ? CONST.IOU.TYPE.SUBMIT : iouType, + transactionID, + activePolicyExpenseChat?.reportID, + ), + ); + }); + } else { + navigateToParticipantPage(iouType, transactionID, reportID); + } + }, [ + transaction, + backTo, + report, + reportID, + reportNameValuePairs, + iouType, + activePolicy, + setDistanceRequestData, + shouldSkipConfirmation, + transactionID, + personalDetails, + translate, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + policy, + waypoints, + backToReport, + customUnitRateID, + navigateToConfirmationPage, + ]); + + const getError = () => { + // Get route error if available else show the invalid number of waypoints error. + if (hasRouteError) { + return getLatestErrorField(transaction, 'route'); + } + if (duplicateWaypointsError) { + return {duplicateWaypointsError: translate('iou.error.duplicateWaypointsErrorMessage')} as Errors; + } + if (atLeastTwoDifferentWaypointsError) { + return {atLeastTwoDifferentWaypointsError: translate('iou.error.atLeastTwoDifferentWaypoints')} as Errors; + } + return {}; + }; + + type DataParams = { + data: string[]; + }; + + const updateWaypoints = useCallback( + ({data}: DataParams) => { + if (deepEqual(waypointsList, data)) { + return; + } + + const newWaypoints: WaypointCollection = {}; + let emptyWaypointIndex = -1; + data.forEach((waypoint, index) => { + newWaypoints[`waypoint${index}`] = waypoints[waypoint] ?? {}; + // Find waypoint that BECOMES empty after dragging + if (isWaypointEmpty(newWaypoints[`waypoint${index}`]) && !isWaypointEmpty(waypoints[`waypoint${index}`])) { + emptyWaypointIndex = index; + } + }); + + setOptimisticWaypoints(newWaypoints); + Promise.all([ + removeWaypoint(transaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action)), + updateWaypointsUtil(transactionID, newWaypoints, shouldUseTransactionDraft(action)), + ]).then(() => { + setOptimisticWaypoints(null); + }); + }, + [transactionID, transaction, waypoints, waypointsList, action], + ); + + const submitWaypoints = useCallback(() => { + // If there is any error or loading state, don't let user go to next page. + if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || (!isEditing && isLoading)) { + setShouldShowAtLeastTwoDifferentWaypointsError(true); + return; + } + if (!isCreatingNewRequest && !isEditing) { + transactionWasSaved.current = true; + } + if (isEditing) { + // If nothing was changed, simply go to transaction thread + // We compare only addresses because numbers are rounded while backup + const oldWaypoints = transactionBackup?.comment?.waypoints ?? {}; + const oldAddresses = Object.fromEntries(Object.entries(oldWaypoints).map(([key, waypoint]) => [key, 'address' in waypoint ? waypoint.address : {}])); + const addresses = Object.fromEntries(Object.entries(waypoints).map(([key, waypoint]) => [key, 'address' in waypoint ? waypoint.address : {}])); + const hasRouteChanged = !deepEqual(transactionBackup?.routes, transaction?.routes); + if (deepEqual(oldAddresses, addresses)) { + navigateBack(); + return; + } + if (transaction?.transactionID && report?.reportID) { + updateMoneyRequestDistance({ + transactionID: transaction?.transactionID, + transactionThreadReportID: report?.reportID, + waypoints, + ...(hasRouteChanged ? {routes: transaction?.routes} : {}), + policy, + transactionBackup, + }); + } + transactionWasSaved.current = true; + navigateBack(); + return; + } + + navigateToNextStep(); + }, [ + navigateBack, + duplicateWaypointsError, + atLeastTwoDifferentWaypointsError, + hasRouteError, + isLoadingRoute, + isLoading, + isCreatingNewRequest, + isEditing, + navigateToNextStep, + transactionBackup, + waypoints, + transaction?.transactionID, + transaction?.routes, + report?.reportID, + policy, + ]); + + const renderItem = useCallback( + ({item, drag, isActive, getIndex}: RenderItemParams) => ( + + ), + [isLoadingRoute, navigateToWaypointEditPage, waypoints], + ); + + return ( + + <> + + (waypoints[item]?.keyForList ?? waypoints[item]?.address ?? '') + item} + onDragEnd={updateWaypoints} + ref={scrollViewRef} + renderItem={renderItem} + ListFooterComponent={ + + } + /> + + + {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} + {((shouldShowAtLeastTwoDifferentWaypointsError && atLeastTwoDifferentWaypointsError) || duplicateWaypointsError || hasRouteError) && ( + + )} +