diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f02c5002a044..06962398576a 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2659,6 +2659,8 @@ const CONST = { MANUAL: 'manual', SCAN: 'scan', PER_DIEM: 'per-diem', + DISTANCE_MAP: 'distance-map', + DISTANCE_MANUAL: 'distance-manual', }, EXPENSE_TYPE: { DISTANCE: 'distance', @@ -2667,6 +2669,8 @@ const CONST = { PER_DIEM: 'per-diem', EXPENSIFY_CARD: 'expensifyCard', PENDING_EXPENSIFY_CARD: 'pendingExpensifyCard', + DISTANCE_MAP: 'distance-map', + DISTANCE_MANUAL: 'distance-manual', }, REPORT_ACTION_TYPE: { PAY: 'pay', @@ -5081,6 +5085,8 @@ const CONST = { SCAN: 'scan', DISTANCE: 'distance', PER_DIEM: 'per-diem', + DISTANCE_MAP: 'distance-map', + DISTANCE_MANUAL: 'distance-manual', }, STATUS_TEXT_MAX_LENGTH: 100, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 6da5dadba9cc..717c5641fd45 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -926,6 +926,21 @@ const ROUTES = { label ? `${backTo || state ? '&' : '?'}label=${encodeURIComponent(label)}` : '' }` as const, }, + DISTANCE_REQUEST_CREATE: { + route: ':action/:iouType/start/:transactionID/:reportID/distance-new/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance-new/${backToReport ?? ''}` as const, + }, + DISTANCE_REQUEST_CREATE_TAB_MAP: { + route: 'map/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance-new/map/${backToReport ?? ''}` as const, + }, + DISTANCE_REQUEST_CREATE_TAB_MANUAL: { + route: 'manual/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance-new/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 b14fdfdf1615..221f9c1ccff3 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -275,6 +275,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/IOUUtils.ts b/src/libs/IOUUtils.ts index e7b50ef35c79..0572cc3ed0bd 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -30,6 +30,12 @@ function navigateToStartMoneyRequestStep(requestType: IOURequestType, iouType: I case CONST.IOU.REQUEST_TYPE.DISTANCE: Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), {compareParams: false}); break; + case CONST.IOU.REQUEST_TYPE.DISTANCE_MAP: + Navigation.goBack(ROUTES.DISTANCE_REQUEST_CREATE_TAB_MAP.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), {compareParams: false}); + break; + case CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL: + Navigation.goBack(ROUTES.DISTANCE_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), {compareParams: false}); + break; case CONST.IOU.REQUEST_TYPE.SCAN: Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), {compareParams: false}); break; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 52d18d0f1b36..0bdb78b9be9a 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 785808c6cc05..7a6bb4370424 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1347,6 +1347,18 @@ 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, + }, + manual: { + path: ROUTES.DISTANCE_REQUEST_CREATE_TAB_MANUAL.route, + }, + }, + }, [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 a7ddb1cc1ed9..86406e46dcc5 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1515,6 +1515,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 IOURequestStepDistanceManual and IOURequestStepDistanceMap 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/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 46345784248a..9a3815ed9f70 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -149,7 +149,7 @@ Onyx.connect({ function isDistanceRequest(transaction: OnyxEntry): boolean { // This is used during the expense creation flow before the transaction has been saved to the server if (lodashHas(transaction, 'iouRequestType')) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE || transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; } // This is the case for transaction objects once they have been saved to the server diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 53a321417c26..1dcd99bc5316 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -931,7 +931,7 @@ function initMoneyRequest({reportID, policy, isFromGlobalCreate, currentIouReque let requestCategory: string | null = null; // Add initial empty waypoints when starting a distance expense - if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) { + if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE || newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP) { comment.waypoints = { waypoint0: {keyForList: 'start_waypoint'}, waypoint1: {keyForList: 'stop_waypoint'}, @@ -1014,6 +1014,20 @@ function startMoneyRequest(iouType: ValueOf, reportID: st } } +function startDistanceRequest(iouType: ValueOf, reportID: string, requestType?: IOURequestType, skipConfirmation = false, backToReport?: string) { + clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, skipConfirmation); + switch (requestType) { + case 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, reportID, backToReport)); + return; + case 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, reportID, backToReport)); + return; + default: + Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID, backToReport)); + } +} + function setMoneyRequestAmount(transactionID: string, amount: number, currency: string, shouldShowOriginalAmount = false) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {amount, currency, shouldShowOriginalAmount}); } @@ -3349,7 +3363,8 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma } // STEP 3: Build an optimistic transaction with the receipt - const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; + const isDistanceRequest = + existingTransaction && (existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE || existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP); let optimisticTransaction = buildOptimisticTransaction({ existingTransactionID, existingTransaction, @@ -12153,5 +12168,6 @@ export { updateSplitExpenseField, reopenReport, retractReport, + startDistanceRequest, }; export type {GPSPoint as GpsPoint, IOURequestType, StartSplitBilActionParams, CreateTrackExpenseParams, RequestMoneyInformation, ReplaceReceipt}; 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..359dba1ad317 100644 --- a/src/pages/iou/request/IOURequestRedirectToStartPage.tsx +++ b/src/pages/iou/request/IOURequestRedirectToStartPage.tsx @@ -2,7 +2,7 @@ import React, {useEffect} from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; +import {generateReportID} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -27,13 +27,17 @@ function IOURequestRedirectToStartPage({ Navigation.dismissModal(); // Redirect the person to the right start page using a random reportID - const optimisticReportID = ReportUtils.generateReportID(); + const optimisticReportID = generateReportID(); if (iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) { Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID)); } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL) { 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/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 35b393ec40e0..00a2e42824fb 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -34,6 +34,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {SelectedTabRequest} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type Transaction from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -324,7 +325,7 @@ function IOURequestStepAmount({ shouldKeepUserInput={transaction?.shouldShowOriginalAmount} onCurrencyButtonPress={navigateToCurrencySelectionPage} onSubmitButtonPress={saveAmountAndCurrency} - selectedTab={iouRequestType} + selectedTab={iouRequestType as SelectedTabRequest} chatReportID={reportID} /> 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) && ( + + )} +