diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3098f434f6c4..c956e97e736c 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2913,6 +2913,7 @@ const CONST = { DISTANCE_MAP: 'distance-map', DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', + DISTANCE_ODOMETER: 'distance-odometer', }, EXPENSE_TYPE: { DISTANCE: 'distance', @@ -2924,7 +2925,9 @@ const CONST = { DISTANCE_MAP: 'distance-map', DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', + DISTANCE_ODOMETER: 'distance-odometer', }, + REPORT_ACTION_TYPE: { PAY: 'pay', CREATE: 'create', @@ -5505,6 +5508,7 @@ const CONST = { DISTANCE_MAP: 'distance-map', DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', + DISTANCE_ODOMETER: 'distance-odometer', }, STATUS_TEXT_MAX_LENGTH: 100, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 641e74f94a1d..87cf0b33472d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1175,6 +1175,17 @@ const ROUTES = { return getUrlWithBackToParam(`${action as string}/${iouType as string}/distance-manual/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo); }, }, + MONEY_REQUEST_STEP_DISTANCE_ODOMETER: { + route: ':action/:iouType/distance-odometer/:transactionID/:reportID', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '') => { + if (!transactionID || !reportID) { + Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_DISTANCE_ODOMETER route'); + } + + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + return getUrlWithBackToParam(`${action as string}/${iouType as string}/distance-odometer/${transactionID}/${reportID}`, backTo); + }, + }, MONEY_REQUEST_STEP_DISTANCE_RATE: { route: ':action/:iouType/distanceRate/:transactionID/:reportID/:reportActionID?', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '', reportActionID?: string) => { @@ -1294,6 +1305,11 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backToReport?: string) => `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance-new${backToReport ? `/${backToReport}` : ''}/distance-gps` as const, }, + DISTANCE_REQUEST_CREATE_TAB_ODOMETER: { + route: 'distance-odometer', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance-new${backToReport ? `/${backToReport}` : ''}/distance-odometer` 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 860419234f2d..1393fcf63841 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -329,6 +329,7 @@ const SCREENS = { STEP_DISTANCE_MAP: 'Money_Request_Step_Distance_Map', STEP_DISTANCE_MANUAL: 'Money_Request_Step_Distance_Manual', STEP_DISTANCE_GPS: 'Money_Request_Step_Distance_GPS', + STEP_DISTANCE_ODOMETER: 'Money_Request_Step_Distance_Odometer', RECEIPT_PREVIEW: 'Money_Request_Receipt_preview', }, diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 76c09481b5d8..102bd778c953 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -152,6 +152,9 @@ type MoneyRequestConfirmationListProps = { /** Whether the expense is a manual distance expense */ isManualDistanceRequest: boolean; + /** Whether the expense is an odometer distance expense */ + isOdometerDistanceRequest?: boolean; + /** Whether the expense is a per diem expense */ isPerDiemRequest?: boolean; @@ -213,6 +216,7 @@ function MoneyRequestConfirmationList({ iouAmount, isDistanceRequest, isManualDistanceRequest, + isOdometerDistanceRequest = false, isPerDiemRequest = false, isPolicyExpenseChat = false, iouCategory = '', @@ -1144,6 +1148,7 @@ function MoneyRequestConfirmationList({ isCategoryRequired={isCategoryRequired} isDistanceRequest={isDistanceRequest} isManualDistanceRequest={isManualDistanceRequest} + isOdometerDistanceRequest={isOdometerDistanceRequest} isPerDiemRequest={isPerDiemRequest} isMerchantEmpty={isMerchantEmpty} isMerchantRequired={isMerchantRequired} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 4e4acba9a7df..bce89a190df1 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -41,6 +41,7 @@ import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; @@ -114,6 +115,9 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if it is a manual distance request */ isManualDistanceRequest: boolean; + /** Flag indicating if it is an odometer distance request */ + isOdometerDistanceRequest?: boolean; + /** Flag indicating if it is a per diem request */ isPerDiemRequest: boolean; @@ -231,6 +235,7 @@ function MoneyRequestConfirmationListFooter({ isCategoryRequired, isDistanceRequest, isManualDistanceRequest, + isOdometerDistanceRequest = false, isPerDiemRequest, isMerchantEmpty, isMerchantRequired, @@ -301,8 +306,8 @@ function MoneyRequestConfirmationListFooter({ const hasPendingWaypoints = transaction && isFetchingWaypointsFromServer(transaction); const hasErrors = !isEmptyObject(transaction?.errors) || !isEmptyObject(transaction?.errorFields?.route) || !isEmptyObject(transaction?.errorFields?.waypoints); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldShowMap = isDistanceRequest && !isManualDistanceRequest && !!(hasErrors || hasPendingWaypoints || iouType !== CONST.IOU.TYPE.SPLIT || !isReadOnly); + const shouldShowMap = + isDistanceRequest && !isManualDistanceRequest && !isOdometerDistanceRequest && [hasErrors, hasPendingWaypoints, iouType !== CONST.IOU.TYPE.SPLIT, !isReadOnly].some(Boolean); const isFromGlobalCreate = !!transaction?.isFromGlobalCreate; const senderWorkspace = useMemo(() => { @@ -504,6 +509,11 @@ function MoneyRequestConfirmationListFooter({ return; } + if (isOdometerDistanceRequest) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute()) as Route); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute(), reportActionID)); }} disabled={didConfirm} @@ -1031,7 +1041,7 @@ function MoneyRequestConfirmationListFooter({ )} - {(!shouldShowMap || isManualDistanceRequest) && ( + {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && ( {hasReceiptImageOrThumbnail ? receiptThumbnailContent diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 56c41e036a7b..6b4e58fb292a 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -90,6 +90,7 @@ import { isDistanceRequest as isDistanceRequestTransactionUtils, isExpenseUnreported as isExpenseUnreportedTransactionUtils, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, + isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, isPerDiemRequest as isPerDiemRequestTransactionUtils, isScanning, isTimeRequest as isTimeRequestTransactionUtils, @@ -278,6 +279,7 @@ function MoneyRequestView({ transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transactionMerchant === CONST.TRANSACTION.DEFAULT_MERCHANT; const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction, !!mergeTransactionID); + const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); const isMapDistanceRequest = isDistanceRequest && !isManualDistanceRequest; const isTransactionScanning = isScanning(updatedTransaction ?? transaction); const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); @@ -555,6 +557,19 @@ function MoneyRequestView({ return; } + if (isOdometerDistanceRequest) { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport.reportID, + getReportRHPActiveRoute(), + ), + ); + return; + } + if (isManualDistanceRequest) { Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute( @@ -834,7 +849,7 @@ function MoneyRequestView({ copyable={!!descriptionCopyValue} /> - {isManualDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( + {isManualDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( distanceRequestFields ) : ( diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index 53364fafb1e3..52fc9ea147c2 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -37,7 +37,21 @@ type IconTitleAndTestID = { testID?: string; }; -const MEMOIZED_LAZY_TAB_SELECTOR_ICONS = ['CalendarSolid', 'UploadAlt', 'User', 'Car', 'Hashtag', 'Map', 'Pencil', 'ReceiptScan', 'Receipt', 'MoneyCircle', 'Percent', 'Crosshair'] as const; +const MEMOIZED_LAZY_TAB_SELECTOR_ICONS = [ + 'CalendarSolid', + 'UploadAlt', + 'User', + 'Car', + 'Hashtag', + 'Map', + 'Pencil', + 'ReceiptScan', + 'Receipt', + 'MoneyCircle', + 'Percent', + 'Crosshair', + 'Meter', +] as const; function getIconTitleAndTestID( icons: Record, IconAsset>, @@ -73,6 +87,8 @@ function getIconTitleAndTestID( return {icon: icons.Pencil, title: translate('tabSelector.manual'), testID: 'distanceManual'}; case CONST.TAB_REQUEST.DISTANCE_GPS: return {icon: icons.Crosshair, title: translate('tabSelector.gps'), testID: 'distanceGPS'}; + case CONST.TAB_REQUEST.DISTANCE_ODOMETER: + return {icon: icons.Meter, title: translate('tabSelector.odometer'), testID: 'distanceOdometer'}; case CONST.TAB.SPLIT.AMOUNT: return {icon: icons.MoneyCircle, title: translate('iou.amount'), testID: 'split-amount'}; case CONST.TAB.SPLIT.PERCENTAGE: diff --git a/src/hooks/useBeforeRemove.tsx b/src/hooks/useBeforeRemove.tsx index 835d5a30babe..908bb659960c 100644 --- a/src/hooks/useBeforeRemove.tsx +++ b/src/hooks/useBeforeRemove.tsx @@ -3,13 +3,16 @@ import type {EventListenerCallback, EventMapCore, NavigationState} from '@react- import {useEffect} from 'react'; // beforeRemove have some limitations. When the react-navigation is upgraded to 7.x, update this to use usePreventRemove hook. -const useBeforeRemove = (onBeforeRemove: EventListenerCallback, 'beforeRemove'>) => { +const useBeforeRemove = (onBeforeRemove: EventListenerCallback, 'beforeRemove'>, isEnabled = true) => { const navigation = useNavigation(); useEffect(() => { + if (!isEnabled) { + return undefined; + } const unsubscribe = navigation.addListener('beforeRemove', onBeforeRemove); return unsubscribe; - }, [navigation, onBeforeRemove]); + }, [navigation, onBeforeRemove, isEnabled]); }; export default useBeforeRemove; diff --git a/src/languages/de.ts b/src/languages/de.ts index d484e3951f26..42e5aa3a5f4c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -956,15 +956,7 @@ const translations: TranslationDeepObject = { subscription: 'Abonnement', domains: 'Domänen', }, - tabSelector: { - chat: 'Chat', - room: 'Raum', - distance: 'Entfernung', - manual: 'Manuell', - scan: 'Scannen', - map: 'Karte', - gps: 'GPS', - }, + tabSelector: {chat: 'Chat', room: 'Raum', distance: 'Entfernung', manual: 'Manuell', scan: 'Scannen', map: 'Karte', gps: 'GPS', odometer: 'Kilometerzähler'}, spreadsheet: { upload: 'Eine Tabellenkalkulation hochladen', import: 'Tabellenkalkulation importieren', @@ -1292,6 +1284,8 @@ const translations: TranslationDeepObject = { invalidRate: 'Satz für diesen Workspace ungültig. Bitte wählen Sie einen verfügbaren Satz aus dem Workspace aus.', endDateBeforeStartDate: 'Das Enddatum darf nicht vor dem Startdatum liegen', endDateSameAsStartDate: 'Das Enddatum darf nicht mit dem Startdatum identisch sein', + invalidReadings: 'Bitte geben Sie sowohl Anfangs- als auch Endstand ein', + negativeDistanceNotAllowed: 'Endablesung muss größer als Startablesung sein', }, dismissReceiptError: 'Fehler ausblenden', dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler verwirfst, wird dein hochgeladener Beleg vollständig entfernt. Bist du sicher?', @@ -7104,6 +7098,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard error: { selectSuggestedAddress: 'Bitte wählen Sie eine vorgeschlagene Adresse aus oder verwenden Sie den aktuellen Standort', }, + odometer: {startReading: 'Mit dem Lesen beginnen', endReading: 'Lesen beenden', saveForLater: 'Für später speichern', totalDistance: 'Gesamtdistanz'}, }, reportCardLostOrDamaged: { screenTitle: 'Zeugnis verloren oder beschädigt', diff --git a/src/languages/en.ts b/src/languages/en.ts index 54356f33f9a3..1442ab13454b 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -946,6 +946,7 @@ const translations = { scan: 'Scan', map: 'Map', gps: 'GPS', + odometer: 'Odometer', }, spreadsheet: { upload: 'Upload a spreadsheet', @@ -1242,6 +1243,8 @@ const translations = { invalidTagLength: 'The tag name exceeds 255 characters. Please shorten it or choose a different tag.', invalidAmount: 'Please enter a valid amount before continuing', invalidDistance: 'Please enter a valid distance before continuing', + invalidReadings: 'Please enter both start and end readings', + negativeDistanceNotAllowed: 'End reading must be greater than start reading', invalidIntegerAmount: 'Please enter a whole dollar amount before continuing', invalidTaxAmount: ({amount}: RequestAmountParams) => `Maximum tax amount is ${amount}`, invalidSplit: 'The sum of splits must equal the total amount', @@ -6974,6 +6977,12 @@ const translations = { error: { selectSuggestedAddress: 'Please select a suggested address or use current location', }, + odometer: { + startReading: 'Start reading', + endReading: 'End reading', + saveForLater: 'Save for later', + totalDistance: 'Total distance', + }, }, gps: { tooltip: "GPS tracking in progress! When you're done, stop tracking below.", diff --git a/src/languages/es.ts b/src/languages/es.ts index 7612dead1b77..5cd2eb601de2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -692,6 +692,7 @@ const translations: TranslationDeepObject = { scan: 'Escanear', map: 'Map', gps: 'GPS', + odometer: 'Odómetro', }, spreadsheet: { upload: 'Importar', @@ -989,6 +990,8 @@ const translations: TranslationDeepObject = { invalidTagLength: 'La longitud de la etiqueta escogida excede el máximo permitido (255). Por favor, escoge otra etiqueta o acorta la etiqueta primero.', invalidAmount: 'Por favor, ingresa un importe válido antes de continuar', invalidDistance: 'Por favor, ingresa una distancia válida antes de continuar', + invalidReadings: 'Por favor ingrese ambas lecturas de inicio y fin', + negativeDistanceNotAllowed: 'La lectura final debe ser mayor que la lectura inicial', invalidIntegerAmount: 'Por favor, introduce una cantidad entera en dólares antes de continuar', invalidTaxAmount: ({amount}) => `El importe máximo del impuesto es ${amount}`, invalidSplit: 'La suma de las partes debe ser igual al importe total', @@ -7183,6 +7186,12 @@ ${amount} para ${merchant} - ${date}`, error: { selectSuggestedAddress: 'Por favor, selecciona una dirección sugerida o usa la ubicación actual', }, + odometer: { + startReading: 'Lectura inicial', + endReading: 'Lectura final', + saveForLater: 'Guardar para después', + totalDistance: 'Distancia total', + }, }, reportCardLostOrDamaged: { screenTitle: 'Notificar la pérdida o deterioro de la tarjeta', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2a542dc40119..790d9a445998 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -957,15 +957,7 @@ const translations: TranslationDeepObject = { subscription: 'Abonnement', domains: 'Domaines', }, - tabSelector: { - chat: 'Discussion', - room: 'Salle', - distance: 'Distance', - manual: 'Manuel', - scan: 'Scanner', - map: 'Carte', - gps: 'GPS', - }, + tabSelector: {chat: 'Discussion', room: 'Salle', distance: 'Distance', manual: 'Manuel', scan: 'Scanner', map: 'Carte', gps: 'GPS', odometer: 'Compteur kilométrique'}, spreadsheet: { upload: 'Téléverser une feuille de calcul', import: 'Importer une feuille de calcul', @@ -1294,6 +1286,8 @@ const translations: TranslationDeepObject = { invalidRate: 'Taux non valide pour cet espace de travail. Veuillez sélectionner un taux disponible dans l’espace de travail.', endDateBeforeStartDate: 'La date de fin ne peut pas être antérieure à la date de début', endDateSameAsStartDate: 'La date de fin ne peut pas être identique à la date de début', + invalidReadings: 'Veuillez saisir les relevés de début et de fin', + negativeDistanceNotAllowed: 'La lecture de fin doit être supérieure à la lecture de début', }, dismissReceiptError: 'Ignorer l’erreur', dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera entièrement votre reçu téléchargé. Êtes-vous sûr ?', @@ -7117,6 +7111,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin error: { selectSuggestedAddress: 'Veuillez sélectionner une adresse suggérée ou utiliser la position actuelle', }, + odometer: {startReading: 'Commencer la lecture', endReading: 'Fin de lecture', saveForLater: 'Enregistrer pour plus tard', totalDistance: 'Distance totale'}, }, reportCardLostOrDamaged: { screenTitle: 'Bulletin perdu ou endommagé', diff --git a/src/languages/it.ts b/src/languages/it.ts index 39ec147b302b..b4f61df4e698 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -954,15 +954,7 @@ const translations: TranslationDeepObject = { subscription: 'Abbonamento', domains: 'Domini', }, - tabSelector: { - chat: 'Chat', - room: 'Stanza', - distance: 'Distanza', - manual: 'Manuale', - scan: 'Scannerizza', - map: 'Mappa', - gps: 'GPS', - }, + tabSelector: {chat: 'Chat', room: 'Stanza', distance: 'Distanza', manual: 'Manuale', scan: 'Scannerizza', map: 'Mappa', gps: 'GPS', odometer: 'Contachilometri'}, spreadsheet: { upload: 'Carica un foglio di calcolo', import: 'Importa foglio di calcolo', @@ -1289,6 +1281,8 @@ const translations: TranslationDeepObject = { invalidRate: 'Tariffa non valida per questo workspace. Seleziona una tariffa disponibile dal workspace.', endDateBeforeStartDate: 'La data di fine non può essere precedente alla data di inizio', endDateSameAsStartDate: 'La data di fine non può essere uguale alla data di inizio', + invalidReadings: 'Inserisci sia la lettura iniziale che quella finale', + negativeDistanceNotAllowed: 'La lettura finale deve essere maggiore della lettura iniziale', }, dismissReceiptError: 'Ignora errore', dismissReceiptErrorConfirmation: 'Attenzione! Se ignori questo errore, la ricevuta caricata verrà rimossa completamente. Sei sicuro?', @@ -7091,6 +7085,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori error: { selectSuggestedAddress: 'Seleziona un indirizzo suggerito o usa la posizione attuale', }, + odometer: {startReading: 'Inizia a leggere', endReading: 'Termina lettura', saveForLater: 'Salva per dopo', totalDistance: 'Distanza totale'}, }, reportCardLostOrDamaged: { screenTitle: 'Pagella smarrita o danneggiata', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 585887c4c221..a243c75852fd 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -953,15 +953,7 @@ const translations: TranslationDeepObject = { subscription: 'サブスクリプション', domains: 'ドメイン', }, - tabSelector: { - chat: 'チャット', - room: '部屋', - distance: '距離', - manual: '手動', - scan: 'スキャン', - map: '地図', - gps: 'GPS', - }, + tabSelector: {chat: 'チャット', room: '部屋', distance: '距離', manual: '手動', scan: 'スキャン', map: '地図', gps: 'GPS', odometer: 'オドメーター'}, spreadsheet: { upload: 'スプレッドシートをアップロード', import: 'スプレッドシートをインポート', @@ -1287,6 +1279,8 @@ const translations: TranslationDeepObject = { invalidRate: 'このワークスペースでは無効なレートです。ワークスペースから利用可能なレートを選択してください。', endDateBeforeStartDate: '終了日は開始日より前にはできません', endDateSameAsStartDate: '終了日は開始日と同じにはできません', + invalidReadings: '開始と終了の両方の読みを入力してください', + negativeDistanceNotAllowed: '終了値は開始値より大きくなければなりません', }, dismissReceiptError: 'エラーを閉じる', dismissReceiptErrorConfirmation: '注意!このエラーを無視すると、アップロードした領収書が完全に削除されます。本当に実行しますか?', @@ -7035,6 +7029,7 @@ ${reportName} error: { selectSuggestedAddress: '候補の住所を選択するか、現在地を使用してください', }, + odometer: {startReading: '読み始める', endReading: '読み取り終了', saveForLater: '後で保存', totalDistance: '合計距離'}, }, reportCardLostOrDamaged: { screenTitle: '成績証明書の紛失または損傷', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 026e47d8dd03..36e16819de4f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -954,15 +954,7 @@ const translations: TranslationDeepObject = { subscription: 'Abonnement', domains: 'Domeinen', }, - tabSelector: { - chat: 'Chat', - room: 'Kamer', - distance: 'Afstand', - manual: 'Handmatig', - scan: 'Scannen', - map: 'Kaart', - gps: 'GPS', - }, + tabSelector: {chat: 'Chat', room: 'Kamer', distance: 'Afstand', manual: 'Handmatig', scan: 'Scannen', map: 'Kaart', gps: 'GPS', odometer: 'Kilometerstand'}, spreadsheet: { upload: 'Een spreadsheet uploaden', import: 'Spreadsheet importeren', @@ -1288,6 +1280,8 @@ const translations: TranslationDeepObject = { invalidRate: 'Tarief is niet geldig voor deze workspace. Selecteer een beschikbaar tarief uit de workspace.', endDateBeforeStartDate: 'De einddatum kan niet vóór de startdatum liggen', endDateSameAsStartDate: 'De einddatum mag niet hetzelfde zijn als de startdatum', + negativeDistanceNotAllowed: 'Eindstand moet groter zijn dan beginstand', + invalidReadings: 'Voer zowel de begin- als eindstanden in', }, dismissReceiptError: 'Foutmelding sluiten', dismissReceiptErrorConfirmation: 'Let op! Als je deze foutmelding negeert, wordt je geüploade bon volledig verwijderd. Weet je het zeker?', @@ -7078,6 +7072,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten error: { selectSuggestedAddress: 'Selecteer een voorgesteld adres of gebruik huidige locatie', }, + odometer: {startReading: 'Begin met lezen', endReading: 'Lezen beëindigen', saveForLater: 'Voor later bewaren', totalDistance: 'Totale afstand'}, }, reportCardLostOrDamaged: { screenTitle: 'Rapportkaart kwijt of beschadigd', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 069ba463425b..01845991375c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -954,15 +954,7 @@ const translations: TranslationDeepObject = { subscription: 'Subskrypcja', domains: 'Domeny', }, - tabSelector: { - chat: 'Czat', - room: 'Pokój', - distance: 'Dystans', - manual: 'Ręczny', - scan: 'Skanuj', - map: 'Mapa', - gps: 'GPS', - }, + tabSelector: {chat: 'Czat', room: 'Pokój', distance: 'Dystans', manual: 'Ręczny', scan: 'Skanuj', map: 'Mapa', gps: 'GPS', odometer: 'Licznik przebiegu'}, spreadsheet: { upload: 'Prześlij arkusz kalkulacyjny', import: 'Importuj arkusz kalkulacyjny', @@ -1287,6 +1279,8 @@ const translations: TranslationDeepObject = { invalidRate: 'Stawka nie jest prawidłowa dla tego przestrzeni roboczej. Wybierz dostępną stawkę z tej przestrzeni roboczej.', endDateBeforeStartDate: 'Data zakończenia nie może być wcześniejsza niż data rozpoczęcia', endDateSameAsStartDate: 'Data zakończenia nie może być taka sama jak data rozpoczęcia', + negativeDistanceNotAllowed: 'Odczyt końcowy musi być większy niż odczyt początkowy', + invalidReadings: 'Wprowadź zarówno odczyt początkowy, jak i końcowy', }, dismissReceiptError: 'Odrzuć błąd', dismissReceiptErrorConfirmation: 'Uwaga! Odrzucenie tego błędu spowoduje całkowite usunięcie przesłanego paragonu. Czy na pewno chcesz kontynuować?', @@ -7067,6 +7061,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i error: { selectSuggestedAddress: 'Wybierz sugerowany adres lub użyj bieżącej lokalizacji', }, + odometer: {startReading: 'Rozpocznij czytanie', endReading: 'Zakończ czytanie', saveForLater: 'Zapisz na później', totalDistance: 'Całkowity dystans'}, }, reportCardLostOrDamaged: { screenTitle: 'Świadectwo zgubione lub uszkodzone', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 74ee00186045..9a2ce1915a2c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -953,15 +953,7 @@ const translations: TranslationDeepObject = { subscription: 'Assinatura', domains: 'Domínios', }, - tabSelector: { - chat: 'Chat', - room: 'Sala', - distance: 'Distância', - manual: 'Manual', - scan: 'Escanear', - map: 'Mapa', - gps: 'GPS', - }, + tabSelector: {chat: 'Chat', room: 'Sala', distance: 'Distância', manual: 'Manual', scan: 'Escanear', map: 'Mapa', gps: 'GPS', odometer: 'Odômetro'}, spreadsheet: { upload: 'Enviar uma planilha', import: 'Importar planilha', @@ -1285,6 +1277,8 @@ const translations: TranslationDeepObject = { invalidRate: 'Taxa inválida para este workspace. Selecione uma taxa disponível do workspace.', endDateBeforeStartDate: 'A data de término não pode ser anterior à data de início', endDateSameAsStartDate: 'A data de término não pode ser igual à data de início', + invalidReadings: 'Insira as leituras de início e fim', + negativeDistanceNotAllowed: 'A leitura final deve ser maior que a leitura inicial', }, dismissReceiptError: 'Dispensar erro', dismissReceiptErrorConfirmation: 'Atenção! Ignorar este erro removerá completamente o seu recibo enviado. Tem certeza?', @@ -7070,6 +7064,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe error: { selectSuggestedAddress: 'Selecione um endereço sugerido ou use a localização atual', }, + odometer: {startReading: 'Começar a ler', endReading: 'Encerrar leitura', saveForLater: 'Salvar para depois', totalDistance: 'Distância total'}, }, reportCardLostOrDamaged: { screenTitle: 'Boletim perdido ou danificado', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 80332e27b648..c120fa37a572 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -941,15 +941,7 @@ const translations: TranslationDeepObject = { subscription: '订阅', domains: '域名', }, - tabSelector: { - chat: '聊天', - room: '房间', - distance: '距离', - manual: '手动', - scan: '扫描', - map: '地图', - gps: 'GPS', - }, + tabSelector: {chat: '聊天', room: '房间', distance: '距离', manual: '手动', scan: '扫描', map: '地图', gps: 'GPS', odometer: '里程表'}, spreadsheet: { upload: '上传电子表格', import: '导入电子表格', @@ -1266,6 +1258,8 @@ const translations: TranslationDeepObject = { invalidRate: '此汇率对该工作区无效。请选择此工作区中的可用汇率。', endDateBeforeStartDate: '结束日期不能早于开始日期', endDateSameAsStartDate: '结束日期不能与开始日期相同', + invalidReadings: '请输入起始读数和结束读数', + negativeDistanceNotAllowed: '结束读数必须大于开始读数', }, dismissReceiptError: '忽略错误', dismissReceiptErrorConfirmation: '提醒!关闭此错误会完全删除你上传的收据。确定要继续吗?', @@ -6918,6 +6912,7 @@ ${reportName} error: { selectSuggestedAddress: '请选择一个建议地址或使用当前位置', }, + odometer: {startReading: '开始阅读', endReading: '结束阅读', saveForLater: '稍后保存', totalDistance: '总距离'}, }, reportCardLostOrDamaged: { screenTitle: '成绩单遗失或损坏', diff --git a/src/libs/API/parameters/CreateDistanceRequestParams.ts b/src/libs/API/parameters/CreateDistanceRequestParams.ts index f7c7e8e65060..04a853d17be6 100644 --- a/src/libs/API/parameters/CreateDistanceRequestParams.ts +++ b/src/libs/API/parameters/CreateDistanceRequestParams.ts @@ -27,6 +27,8 @@ type CreateDistanceRequestParams = { description?: string; attendees?: string; distance?: number; + odometerStart?: number; + odometerEnd?: number; }; export default CreateDistanceRequestParams; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 128a9613e254..8d7af62b6891 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1054,6 +1054,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) name: CONST.RED_BRICK_ROAD_PENDING_ACTION, defaultP2PRate: CONST.RED_BRICK_ROAD_PENDING_ACTION, distanceUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION, + odometerStart: CONST.RED_BRICK_ROAD_PENDING_ACTION, + odometerEnd: CONST.RED_BRICK_ROAD_PENDING_ACTION, attendees: CONST.RED_BRICK_ROAD_PENDING_ACTION, amount: CONST.RED_BRICK_ROAD_PENDING_ACTION, taxAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION, @@ -1160,6 +1162,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) units: 'object', splitsStartDate: 'string', splitsEndDate: 'string', + odometerStart: 'number', + odometerEnd: 'number', }); case 'accountant': return validateObject>(value, { diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 8f74ac4ae6df..04d6f7baebf2 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -32,6 +32,9 @@ function navigateToStartMoneyRequestStep(requestType: IOURequestType, iouType: I case CONST.IOU.REQUEST_TYPE.DISTANCE_GPS: Navigation.goBack(ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), {compareParams: false}); break; + case CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER: + Navigation.goBack(ROUTES.DISTANCE_REQUEST_CREATE_TAB_ODOMETER.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 c4cf60aedee8..74b9b20d7b44 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -183,6 +183,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepDistanceMap').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceManual').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_GPS]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceGPS').default, + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceOdometer').default, [SCREENS.SET_DEFAULT_WORKSPACE]: () => require('../../../../pages/SetDefaultWorkspacePage').default, }); diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index ab2dc0ca7a99..245b34e64cb8 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1458,6 +1458,10 @@ const config: LinkingOptions['config'] = { 'distance-gps': { path: ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.route, }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'distance-odometer': { + path: ROUTES.DISTANCE_REQUEST_CREATE_TAB_ODOMETER.route, + }, }, }, [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT]: ROUTES.SETTINGS_CATEGORIES_ROOT.route, @@ -1474,6 +1478,7 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.route, [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE.route, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL]: ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.route, + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER]: ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.route, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.route, [SCREENS.MONEY_REQUEST.HOLD]: ROUTES.MONEY_REQUEST_HOLD_REASON.route, [SCREENS.MONEY_REQUEST.REJECT]: ROUTES.REJECT_MONEY_REQUEST_REASON.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index f24b231bb490..30eaaae910ff 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1727,6 +1727,16 @@ type MoneyRequestNavigatorParamList = { backToReport?: string; reportActionID?: string; }; + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER]: { + action: IOUAction; + iouType: IOUType; + transactionID: string; + reportID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo: Routes; + backToReport?: string; + reportActionID?: string; + }; [SCREENS.MONEY_REQUEST.CREATE]: { iouType: IOUType; reportID: string; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 14b3b0da5791..15a85201a2ae 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -786,6 +786,8 @@ type TransactionDetails = { postedDate: string; transactionID: string; distance?: number; + odometerStart?: number; + odometerEnd?: number; }; type OptimisticIOUReport = Pick< diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 62213616a440..416eee2f3dac 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -114,6 +114,8 @@ type TransactionParams = { splitsStartDate?: string; splitsEndDate?: string; distance?: number; + odometerStart?: number; + odometerEnd?: number; }; type BuildOptimisticTransactionParams = { @@ -159,7 +161,8 @@ function isDistanceRequest(transaction: OnyxEntry): boolean { return ( transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE || transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP || - transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL + transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || + transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER ); } @@ -194,7 +197,28 @@ function isManualDistanceRequest(transaction: OnyxEntry, isUpdatedM } // This is the case for transaction objects once they have been saved to the server - return hasDistanceCustomUnit(transaction) && isEmptyObject(transaction?.comment?.waypoints); + // Exclude odometer requests which also have no waypoints but have odometer readings + return ( + hasDistanceCustomUnit(transaction) && + isEmptyObject(transaction?.comment?.waypoints) && + transaction?.comment?.odometerStart === undefined && + transaction?.comment?.odometerEnd === undefined + ); +} + +function isOdometerDistanceRequest(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_ODOMETER; + } + + // This is the case for transaction objects once they have been saved to the server + // Odometer requests have odometerStart and odometerEnd in comment, and no waypoints + return ( + hasDistanceCustomUnit(transaction) && + isEmptyObject(transaction?.comment?.waypoints) && + (transaction?.comment?.odometerStart !== undefined || transaction?.comment?.odometerEnd !== undefined) + ); } function isScanRequest(transaction: OnyxEntry | Partial): boolean { @@ -232,6 +256,9 @@ function isCorporateCardTransaction(transaction: OnyxEntry): boolea } function getRequestType(transaction: OnyxEntry): IOURequestType { + if (isOdometerDistanceRequest(transaction)) { + return CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER; + } if (isManualDistanceRequest(transaction)) { return CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; } @@ -267,7 +294,8 @@ function getExpenseType(transaction: OnyxEntry): ValueOf; } /** @@ -373,12 +401,20 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T splitExpensesTotal, participants, pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + odometerStart, + odometerEnd, } = transactionParams; // transactionIDs are random, positive, 64-bit numeric strings. // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID) const transactionID = existingTransactionID ?? rand64(); const commentJSON: Comment = {comment, attendees}; + if (odometerStart !== undefined) { + commentJSON.odometerStart = odometerStart; + } + if (odometerEnd !== undefined) { + commentJSON.odometerEnd = odometerEnd; + } if (isDemoTransactionParam) { commentJSON.isDemoTransaction = true; } @@ -403,7 +439,8 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T const isMapDistanceTransaction = !!pendingFields?.waypoints; const isManualDistanceTransaction = isManualDistanceRequest(existingTransaction); - if (isMapDistanceTransaction || isManualDistanceTransaction) { + const isOdometerDistanceTransaction = isOdometerDistanceRequest(existingTransaction); + if (isMapDistanceTransaction || isManualDistanceTransaction || isOdometerDistanceTransaction) { // Set the distance unit, which comes from the policy distance unit or the P2P rate data lodashSet(commentJSON, 'customUnit.distanceUnit', DistanceRequestUtils.getUpdatedDistanceUnit({transaction: existingTransaction, policy})); lodashSet(commentJSON, 'customUnit.quantity', distance); @@ -2550,6 +2587,7 @@ export { isDistanceRequest, isMapDistanceRequest, isManualDistanceRequest, + isOdometerDistanceRequest, isFetchingWaypointsFromServer, isExpensifyCardTransaction, isManagedCardTransaction, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 746b3ad83c7b..85a177980975 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -223,6 +223,7 @@ import { isFetchingWaypointsFromServer, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, isMapDistanceRequest, + isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, isOnHold, isPending, isPendingCardOrScanningTransaction, @@ -604,6 +605,8 @@ type DistanceRequestTransactionParams = BaseTransactionParams & { splitShares?: SplitShares; distance?: number; receipt?: Receipt; + odometerStart?: number; + odometerEnd?: number; }; type CreateDistanceRequestInformation = { @@ -664,6 +667,8 @@ type TrackExpenseTransactionParams = { customUnitRateID?: string; attendees?: Attendee[]; isLinkedTrackedExpenseReportArchived?: boolean; + odometerStart?: number; + odometerEnd?: number; }; type TrackExpenseAccountantParams = { @@ -705,6 +710,8 @@ type GetTrackExpenseInformationTransactionParams = { linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction; attendees?: Attendee[]; distance?: number; + odometerStart?: number; + odometerEnd?: number; }; type GetTrackExpenseInformationParticipantParams = { @@ -1056,7 +1063,12 @@ function initMoneyRequest({ let requestCategory: string | null = null; // Set up initial distance expense state - if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE || newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP || newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL) { + if ( + newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE || + newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP || + newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || + newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER + ) { if (!isFromGlobalCreate) { const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report) || isPolicyExpenseChatReportUtil(parentReport); const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID({reportID, isPolicyExpenseChat, policy, lastSelectedDistanceRates}); @@ -1067,7 +1079,7 @@ function initMoneyRequest({ if (comment.customUnit) { comment.customUnit.quantity = null; } - if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL) { + if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { comment.waypoints = undefined; } else { comment.waypoints = { @@ -1075,6 +1087,11 @@ function initMoneyRequest({ waypoint1: {keyForList: 'stop_waypoint'}, }; } + // Initialize odometer readings for odometer type + if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { + comment.odometerStart = undefined; + comment.odometerEnd = undefined; + } } if (newIouRequestType === CONST.IOU.REQUEST_TYPE.PER_DIEM) { @@ -1423,6 +1440,18 @@ function setMoneyRequestDistance(transactionID: string, distanceAsFloat: number, Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {customUnit: {quantity: distanceAsFloat}}}); } +/** + * Set the odometer readings for a transaction + */ +function setMoneyRequestOdometerReading(transactionID: string, startReading: number, endReading: number, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + comment: { + odometerStart: startReading, + odometerEnd: endReading, + }, + }); +} + /** * Set the distance rate of a transaction. * Used when creating a new transaction or moving an existing one from Self DM @@ -3045,7 +3074,8 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma existingTransaction && (existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE || existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP || - existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); + existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || + existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER); const isManualDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; let optimisticTransaction = buildOptimisticTransaction({ existingTransactionID, @@ -3575,8 +3605,25 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T } = params; const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams; const {policy, policyCategories, policyTagList} = policyParams; - const {comment, amount, currency, created, distance, merchant, receipt, category, tag, taxCode, taxAmount, billable, reimbursable, linkedTrackedExpenseReportAction, attendees} = - transactionParams; + const { + comment, + amount, + currency, + created, + distance, + merchant, + receipt, + category, + tag, + taxCode, + taxAmount, + billable, + reimbursable, + linkedTrackedExpenseReportAction, + attendees, + odometerStart, + odometerEnd, + } = transactionParams; const optimisticData: OnyxUpdate[] = []; const successData: OnyxUpdate[] = []; @@ -3736,6 +3783,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; const isDistanceRequest = existingTransaction && isDistanceRequestTransactionUtils(existingTransaction); const isManualDistanceRequest = existingTransaction && isManualDistanceRequestTransactionUtils(existingTransaction); + const isOdometerDistanceRequest = existingTransaction && isOdometerDistanceRequestTransactionUtils(existingTransaction); let optimisticTransaction = buildOptimisticTransaction({ existingTransactionID, existingTransaction, @@ -3758,6 +3806,8 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T reimbursable, filename: existingTransaction?.receipt?.filename, attendees, + odometerStart: isOdometerDistanceRequest ? odometerStart : undefined, + odometerEnd: isOdometerDistanceRequest ? odometerEnd : undefined, }, }); if (iouReport) { @@ -4950,6 +5000,8 @@ type UpdateMoneyRequestDistanceParams = { currentUserAccountIDParam: number; currentUserEmailParam: string; isASAPSubmitBetaEnabled: boolean; + odometerStart?: number; + odometerEnd?: number; }; /** Updates the waypoints of a distance expense */ @@ -4966,11 +5018,15 @@ function updateMoneyRequestDistance({ currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, + odometerStart, + odometerEnd, }: UpdateMoneyRequestDistanceParams) { const transactionChanges: TransactionChanges = { ...(waypoints && {waypoints: sanitizeRecentWaypoints(waypoints)}), routes, ...(distance && {distance}), + ...(odometerStart !== undefined && {odometerStart}), + ...(odometerEnd !== undefined && {odometerEnd}), }; const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; @@ -4993,6 +5049,13 @@ function updateMoneyRequestDistance({ } const {params, onyxData} = data; + if (odometerStart !== undefined) { + params.odometerStart = odometerStart; + } + if (odometerEnd !== undefined) { + params.odometerEnd = odometerEnd; + } + if (!distance) { const recentServerValidatedWaypoints = recentWaypoints.filter((item) => !item.pendingAction); onyxData?.failureData?.push({ @@ -6168,6 +6231,8 @@ function trackExpense(params: CreateTrackExpenseParams) { linkedTrackedExpenseReportID, customUnitRateID, attendees, + odometerStart, + odometerEnd, } = transactionData; const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; @@ -6256,6 +6321,8 @@ function trackExpense(params: CreateTrackExpenseParams) { reimbursable, linkedTrackedExpenseReportAction, attendees, + odometerStart, + odometerEnd, }, policyParams: { policy, @@ -6279,7 +6346,7 @@ function trackExpense(params: CreateTrackExpenseParams) { value: recentServerValidatedWaypoints, }); - if (isMapDistanceRequest(transaction) || isManualDistanceRequestTransactionUtils(transaction)) { + if (isMapDistanceRequest(transaction) || isManualDistanceRequestTransactionUtils(transaction) || isOdometerDistanceRequestTransactionUtils(transaction)) { // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 onyxData?.optimisticData?.push({ onyxMethod: Onyx.METHOD.SET, @@ -8007,6 +8074,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest splitShares = {}, attendees, receipt, + odometerStart, + odometerEnd, } = transactionParams; // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function @@ -8080,6 +8149,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest chatType: splitData.chatType, description: parsedComment, attendees: attendees ? JSON.stringify(attendees) : undefined, + odometerStart, + odometerEnd, }; } else { const participant = participants.at(0) ?? {}; @@ -8136,7 +8207,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest onyxData = moneyRequestOnyxData; - if (transaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP || isManualDistanceRequest) { + if (transaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP || isManualDistanceRequest || transaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 onyxData?.optimisticData?.push({ onyxMethod: Onyx.METHOD.SET, @@ -8157,6 +8228,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest waypoints: JSON.stringify(sanitizedWaypoints), distance, receipt, + odometerStart, + odometerEnd, created, category, tag, @@ -14597,6 +14670,7 @@ export { setMoneyRequestDescription, setMoneyRequestDistance, setMoneyRequestDistanceRate, + setMoneyRequestOdometerReading, setMoneyRequestMerchant, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport, diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 74dd77260d17..e2bd8a4a32ea 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -33,6 +33,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import IOURequestStepDistanceGPS from './step/IOURequestStepDistanceGPS'; import IOURequestStepDistanceManual from './step/IOURequestStepDistanceManual'; import IOURequestStepDistanceMap from './step/IOURequestStepDistanceMap'; +import IOURequestStepDistanceOdometer from './step/IOURequestStepDistanceOdometer'; import type {WithWritableReportOrNotFoundProps} from './step/withWritableReportOrNotFound'; type DistanceRequestStartPageProps = WithWritableReportOrNotFoundProps & { @@ -220,6 +221,18 @@ function DistanceRequestStartPage({ )} )} + {false && ( + + {() => ( + + + + )} + + )} diff --git a/src/pages/iou/request/IOURequestRedirectToStartPage.tsx b/src/pages/iou/request/IOURequestRedirectToStartPage.tsx index 084b14c02a84..f0e07e68c011 100644 --- a/src/pages/iou/request/IOURequestRedirectToStartPage.tsx +++ b/src/pages/iou/request/IOURequestRedirectToStartPage.tsx @@ -40,6 +40,8 @@ function IOURequestRedirectToStartPage({ Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID)); } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS) { Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID)); + } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { + Navigation.navigate(ROUTES.DISTANCE_REQUEST_CREATE_TAB_ODOMETER.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/DiscardChangesConfirmation/index.native.tsx b/src/pages/iou/request/step/DiscardChangesConfirmation/index.native.tsx index 31cae7433fd7..da853ae17a96 100644 --- a/src/pages/iou/request/step/DiscardChangesConfirmation/index.native.tsx +++ b/src/pages/iou/request/step/DiscardChangesConfirmation/index.native.tsx @@ -1,18 +1,19 @@ import type {NavigationAction} from '@react-navigation/native'; -import {usePreventRemove} from '@react-navigation/native'; +import {useIsFocused, usePreventRemove} from '@react-navigation/native'; import React, {memo, useCallback, useRef, useState} from 'react'; import ConfirmModal from '@components/ConfirmModal'; import useLocalize from '@hooks/useLocalize'; import navigationRef from '@libs/Navigation/navigationRef'; import type DiscardChangesConfirmationProps from './types'; -function DiscardChangesConfirmation({getHasUnsavedChanges}: DiscardChangesConfirmationProps) { +function DiscardChangesConfirmation({getHasUnsavedChanges, isEnabled = true}: DiscardChangesConfirmationProps) { const {translate} = useLocalize(); + const isFocused = useIsFocused(); const [isVisible, setIsVisible] = useState(false); const shouldAllowNavigation = useRef(false); const blockedNavigationAction = useRef(undefined); - const hasUnsavedChanges = getHasUnsavedChanges(); + const hasUnsavedChanges = isEnabled && isFocused && getHasUnsavedChanges(); const shouldPrevent = hasUnsavedChanges && !shouldAllowNavigation.current; usePreventRemove( diff --git a/src/pages/iou/request/step/DiscardChangesConfirmation/index.tsx b/src/pages/iou/request/step/DiscardChangesConfirmation/index.tsx index 9b213b878e81..40a2ab96c88c 100644 --- a/src/pages/iou/request/step/DiscardChangesConfirmation/index.tsx +++ b/src/pages/iou/request/step/DiscardChangesConfirmation/index.tsx @@ -1,5 +1,5 @@ import type {NavigationAction} from '@react-navigation/native'; -import {useNavigation} from '@react-navigation/native'; +import {useIsFocused, useNavigation} from '@react-navigation/native'; import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; import ConfirmModal from '@components/ConfirmModal'; import useBeforeRemove from '@hooks/useBeforeRemove'; @@ -11,8 +11,9 @@ import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNa import type {RootNavigatorParamList} from '@libs/Navigation/types'; import type DiscardChangesConfirmationProps from './types'; -function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel}: DiscardChangesConfirmationProps) { +function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel, isEnabled = true}: DiscardChangesConfirmationProps) { const navigation = useNavigation>(); + const isFocused = useIsFocused(); const {translate} = useLocalize(); const [isVisible, setIsVisible] = useState(false); const blockedNavigationAction = useRef(undefined); @@ -22,7 +23,7 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel}: DiscardCha useBeforeRemove( useCallback( (e) => { - if (!getHasUnsavedChanges() || shouldNavigateBack.current) { + if (!isEnabled || !isFocused || !getHasUnsavedChanges() || shouldNavigateBack.current) { return; } @@ -30,8 +31,9 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel}: DiscardCha blockedNavigationAction.current = e.data.action; navigateAfterInteraction(() => setIsVisible((prev) => !prev)); }, - [getHasUnsavedChanges], + [getHasUnsavedChanges, isFocused, isEnabled], ), + isEnabled && isFocused, ); /** @@ -40,6 +42,9 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel}: DiscardCha * So we need to go forward to get back to the current page */ useEffect(() => { + if (!isEnabled || !isFocused) { + return undefined; + } // transitionStart is triggered before the previous page is fully loaded so RHP sliding animation // could be less "glitchy" when going back and forth between the previous and current pages const unsubscribe = navigation.addListener('transitionStart', ({data: {closing}}) => { @@ -58,7 +63,16 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel}: DiscardCha }); return unsubscribe; - }, [navigation, getHasUnsavedChanges]); + }, [navigation, getHasUnsavedChanges, isFocused, isEnabled]); + + useEffect(() => { + if ((isFocused && isEnabled) || !isVisible) { + return; + } + setIsVisible(false); + blockedNavigationAction.current = undefined; + shouldNavigateBack.current = false; + }, [isFocused, isVisible, isEnabled]); const navigateBack = useCallback(() => { if (blockedNavigationAction.current) { diff --git a/src/pages/iou/request/step/DiscardChangesConfirmation/types.ts b/src/pages/iou/request/step/DiscardChangesConfirmation/types.ts index 2fdd9d24b5f2..95ee6cfa4da4 100644 --- a/src/pages/iou/request/step/DiscardChangesConfirmation/types.ts +++ b/src/pages/iou/request/step/DiscardChangesConfirmation/types.ts @@ -1,6 +1,7 @@ type DiscardChangesConfirmationProps = { getHasUnsavedChanges: () => boolean; onCancel?: () => void; + isEnabled?: boolean; }; export default DiscardChangesConfirmationProps; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 2458ceaad6b7..01165442aadb 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -68,6 +68,7 @@ import { hasReceipt, isDistanceRequest as isDistanceRequestTransactionUtils, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, + isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, isScanRequest, } from '@libs/TransactionUtils'; import type {GpsPoint} from '@userActions/IOU'; @@ -240,6 +241,8 @@ function IOURequestStepConfirmation({ const requestType = getRequestType(transaction); const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); + const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); + const transactionDistance = isManualDistanceRequest || isOdometerDistanceRequest ? (transaction?.comment?.customUnit?.quantity ?? undefined) : undefined; const isPerDiemRequest = requestType === CONST.IOU.REQUEST_TYPE.PER_DIEM; const [lastLocationPermissionPrompt] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, {canBeMissing: true}); const { @@ -723,6 +726,7 @@ function IOURequestStepConfirmation({ for (const [index, item] of transactions.entries()) { const isLinkedTrackedExpenseReportArchived = !!item.linkedTrackedExpenseReportID && archivedReportsIdSet.has(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`); + const itemDistance = isManualDistanceRequest || isOdometerDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined; trackExpenseIOUActions({ report, @@ -740,7 +744,7 @@ function IOURequestStepConfirmation({ }, transactionParams: { amount: item.amount, - distance: isManualDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined, + distance: itemDistance, currency: item.currency, created: item.created, merchant: item.merchant, @@ -760,6 +764,8 @@ function IOURequestStepConfirmation({ customUnitRateID, attendees: item.comment?.attendees, isLinkedTrackedExpenseReportArchived, + odometerStart: isOdometerDistanceRequest ? item.comment?.odometerStart : undefined, + odometerEnd: isOdometerDistanceRequest ? item.comment?.odometerEnd : undefined, }, accountantParams: { accountant: item.accountant, @@ -789,6 +795,7 @@ function IOURequestStepConfirmation({ customUnitRateID, isDraftPolicy, isManualDistanceRequest, + isOdometerDistanceRequest, archivedReportsIdSet, isASAPSubmitBetaEnabled, introSelected, @@ -802,6 +809,7 @@ function IOURequestStepConfirmation({ if (!transaction) { return; } + createDistanceRequestIOUActions({ report, participants: selectedParticipants, @@ -819,7 +827,7 @@ function IOURequestStepConfirmation({ transactionParams: { amount: transaction.amount, comment: trimmedComment, - distance: isManualDistanceRequest ? (transaction.comment?.customUnit?.quantity ?? undefined) : undefined, + distance: transactionDistance, created: transaction.created, currency: transaction.currency, merchant: transaction.merchant, @@ -833,7 +841,9 @@ function IOURequestStepConfirmation({ billable: transaction.billable, reimbursable: transaction.reimbursable, attendees: transaction.comment?.attendees, - receipt: isManualDistanceRequest ? receiptFiles[transaction.transactionID] : undefined, + receipt: isManualDistanceRequest || isOdometerDistanceRequest ? receiptFiles[transaction.transactionID] : undefined, + odometerStart: isOdometerDistanceRequest ? transaction.comment?.odometerStart : undefined, + odometerEnd: isOdometerDistanceRequest ? transaction.comment?.odometerEnd : undefined, }, backToReport, isASAPSubmitBetaEnabled, @@ -849,11 +859,13 @@ function IOURequestStepConfirmation({ currentUserPersonalDetails.accountID, iouType, policy, + isOdometerDistanceRequest, policyCategories, policyTags, policyRecentlyUsedCategories, policyRecentlyUsedTags, isManualDistanceRequest, + transactionDistance, transactionTaxCode, transactionTaxAmount, customUnitRateID, @@ -1364,13 +1376,14 @@ function IOURequestStepConfirmation({ receiptFilename={receiptFilename} iouType={iouType} reportID={reportID} - shouldDisplayReceipt={!isMovingTransactionFromTrackExpense && (!isDistanceRequest || isManualDistanceRequest) && !isPerDiemRequest} + shouldDisplayReceipt={!isMovingTransactionFromTrackExpense && (!isDistanceRequest || isManualDistanceRequest || isOdometerDistanceRequest) && !isPerDiemRequest} isPolicyExpenseChat={isPolicyExpenseChat} policyID={policyID} iouMerchant={transaction?.merchant} iouCreated={transaction?.created} isDistanceRequest={isDistanceRequest} isManualDistanceRequest={isManualDistanceRequest} + isOdometerDistanceRequest={isOdometerDistanceRequest} isPerDiemRequest={isPerDiemRequest} shouldShowSmartScanFields={shouldShowSmartScanFields} action={action} diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx new file mode 100644 index 000000000000..c443be8c93bf --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -0,0 +1,581 @@ +import {useIsFocused} from '@react-navigation/native'; +import reportsSelector from '@selectors/Attributes'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import Button from '@components/Button'; +import FormHelpMessage from '@components/FormHelpMessage'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import usePolicy from '@hooks/usePolicy'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import { + createDistanceRequest, + getMoneyRequestParticipantsFromReport, + setCustomUnitRateID, + setMoneyRequestDistance, + setMoneyRequestMerchant, + setMoneyRequestOdometerReading, + setMoneyRequestParticipantsFromReport, + setMoneyRequestPendingFields, + trackExpense, + updateMoneyRequestDistance, +} from '@libs/actions/IOU'; +import {setTransactionReport} from '@libs/actions/Transaction'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {navigateToParticipantPage, shouldUseTransactionDraft} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; +import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; +import {isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {getPolicyExpenseChat, isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; +import type Transaction from '@src/types/onyx/Transaction'; +import DiscardChangesConfirmation from './DiscardChangesConfirmation'; +import StepScreenWrapper from './StepScreenWrapper'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type IOURequestStepDistanceOdometerProps = WithCurrentUserPersonalDetailsProps & + WithWritableReportOrNotFoundProps & { + /** The transaction object being modified in Onyx */ + transaction: OnyxEntry; + }; + +function IOURequestStepDistanceOdometer({ + report, + route: { + params: {action, iouType, reportID, transactionID, backTo, backToReport}, + }, + transaction, + currentUserPersonalDetails, +}: IOURequestStepDistanceOdometerProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {isExtraSmallScreenHeight} = useResponsiveLayout(); + const isFocused = useIsFocused(); + + const startReadingInputRef = useRef(null); + const endReadingInputRef = useRef(null); + + const [startReading, setStartReading] = useState(''); + const [endReading, setEndReading] = useState(''); + const [formError, setFormError] = useState(''); + // Key to force TextInput remount when resetting state after tab switch + const [inputKey, setInputKey] = useState(0); + + // Track initial values for DiscardChangesConfirmation + const initialStartReadingRef = useRef(''); + const initialEndReadingRef = useRef(''); + const hasInitializedRefs = useRef(false); + // Track previous transaction values to detect when transaction is cleared (e.g., tab switch) + const prevTransactionStartRef = useRef(undefined); + const prevTransactionEndRef = useRef(undefined); + // Track local state via refs to avoid including them in useEffect dependencies + const startReadingRef = useRef(''); + const endReadingRef = useRef(''); + + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {canBeMissing: true}); + const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, {canBeMissing: true}); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {canBeMissing: true, selector: reportsSelector}); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, {canBeMissing: true}); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES, {canBeMissing: true}); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`, {canBeMissing: true}); + const policy = usePolicy(report?.policyID); + const personalPolicy = usePersonalPolicy(); + const defaultExpensePolicy = useDefaultExpensePolicy(); + + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isCreatingNewRequest = !(backTo ?? isEditing); + const isTransactionDraft = shouldUseTransactionDraft(action, iouType); + const currentUserAccountIDParam = currentUserPersonalDetails.accountID; + const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; + + const shouldUseDefaultExpensePolicy = useMemo( + () => + iouType === CONST.IOU.TYPE.CREATE && + isPaidGroupPolicy(defaultExpensePolicy) && + defaultExpensePolicy?.isPolicyExpenseChatEnabled && + !shouldRestrictUserBillableActions(defaultExpensePolicy.id), + [iouType, defaultExpensePolicy], + ); + + const unit = DistanceRequestUtils.getRate({transaction, policy: shouldUseDefaultExpensePolicy ? defaultExpensePolicy : policy}).unit; + + const shouldSkipConfirmation: boolean = !skipConfirmation || !report?.reportID ? false : !(isArchivedReport(reportNameValuePairs) || isPolicyExpenseChatUtils(report)); + + // Reset component state when transaction has no odometer data (happens when switching tabs) + // In Phase 1, we don't persist data from transaction since users can't save and exit + useEffect(() => { + if (!isFocused) { + return; + } + + const currentStart = transaction?.comment?.odometerStart; + const currentEnd = transaction?.comment?.odometerEnd; + + // Check if transaction was cleared (had values before, now null/undefined) + // This happens when switching tabs, not during normal typing + const wasCleared = + (prevTransactionStartRef.current !== null && prevTransactionStartRef.current !== undefined && (currentStart === null || currentStart === undefined)) || + (prevTransactionEndRef.current !== null && prevTransactionEndRef.current !== undefined && (currentEnd === null || currentEnd === undefined)); + + const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined); + + // Reset if transaction was cleared (had values before, now null) + // This happens when switching tabs - transaction data is cleared but local state persists + // Also reset if transaction is empty (component remounted after tab switch) + // Don't reset in edit mode as we want to preserve user's changes + const shouldReset = + hasInitializedRefs.current && + !isEditing && + !backToReport && + !hasTransactionData && + (wasCleared || (prevTransactionStartRef.current === undefined && prevTransactionEndRef.current === undefined)); + + if (shouldReset) { + setStartReading(''); + setEndReading(''); + startReadingRef.current = ''; + endReadingRef.current = ''; + initialStartReadingRef.current = ''; + initialEndReadingRef.current = ''; + setFormError(''); + // Force TextInput remount to reset label position + setInputKey((prev) => prev + 1); + } + + // Update refs to track previous values + prevTransactionStartRef.current = currentStart; + prevTransactionEndRef.current = currentEnd; + }, [isFocused, isEditing, backToReport, transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd]); + + // Initialize initial values refs on mount for DiscardChangesConfirmation + // These should never be updated after mount - they represent the "baseline" state + useEffect(() => { + if (hasInitializedRefs.current) { + return; + } + const currentStart = transaction?.comment?.odometerStart; + const currentEnd = transaction?.comment?.odometerEnd; + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; + initialStartReadingRef.current = startValue; + initialEndReadingRef.current = endValue; + hasInitializedRefs.current = true; + }, [transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd]); + + // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) + // This updates the current state, but NOT the initial refs (those are set only once on mount) + useEffect(() => { + const currentStart = transaction?.comment?.odometerStart; + const currentEnd = transaction?.comment?.odometerEnd; + + // Only initialize if: + // 1. We haven't initialized yet AND transaction has data, OR + // 2. We're editing and transaction has data (to load existing values), OR + // 3. Transaction has data but local state is empty (user navigated back from another page) + const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined); + const hasLocalState = startReadingRef.current || endReadingRef.current; + const shouldInitialize = + (!hasInitializedRefs.current && hasTransactionData) || + (isEditing && hasTransactionData && !hasLocalState) || + (hasTransactionData && !hasLocalState && hasInitializedRefs.current); + + if (shouldInitialize) { + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; + + if (startValue || endValue) { + setStartReading(startValue); + setEndReading(endValue); + startReadingRef.current = startValue; + endReadingRef.current = endValue; + } + } + }, [transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd, isEditing]); + + // Calculate total distance - updated live after every input change + const totalDistance = (() => { + const start = parseFloat(startReading); + const end = parseFloat(endReading); + if (Number.isNaN(start) || Number.isNaN(end) || !startReading || !endReading) { + return null; + } + const distance = end - start; + // Show 0 if distance is negative + return distance <= 0 ? 0 : distance; + })(); + + const buttonText = (() => { + if (shouldSkipConfirmation) { + return translate('iou.createExpense'); + } + const shouldShowSave = isEditing || backToReport; + return shouldShowSave ? translate('common.save') : translate('common.next'); + })(); + + const cleanOdometerReading = (text: string): string => { + // Allow digits and one decimal point or comma + // Remove all characters except digits, dots, and commas + let cleaned = text.replaceAll(/[^0-9.,]/g, ''); + // Replace comma with dot for consistency + cleaned = cleaned.replaceAll(',', '.'); + // Allow only one decimal point + const parts = cleaned.split('.'); + if (parts.length > 2) { + cleaned = `${parts.at(0) ?? ''}.${parts.slice(1).join('')}`; + } + // Don't allow decimal point at the start + if (cleaned.startsWith('.')) { + cleaned = `0${cleaned}`; + } + return cleaned; + }; + + const handleStartReadingChange = (text: string) => { + const cleaned = cleanOdometerReading(text); + setStartReading(cleaned); + startReadingRef.current = cleaned; + if (formError) { + setFormError(''); + } + }; + + const handleEndReadingChange = (text: string) => { + const cleaned = cleanOdometerReading(text); + setEndReading(cleaned); + endReadingRef.current = cleaned; + if (formError) { + setFormError(''); + } + }; + + // Navigate to confirmation page helper - following Manual tab pattern + const navigateToConfirmationPage = () => { + if (!transactionID || !reportID) { + return; + } + 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; + default: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport)); + } + }; + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + // Navigate to next page following Manual tab pattern + const navigateToNextPage = () => { + const start = parseFloat(startReading); + const end = parseFloat(endReading); + + // Store odometer readings in transaction.comment.odometerStart/odometerEnd + setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); + + // Calculate total distance (endReading - startReading) + const distance = end - start; + const calculatedDistance = roundToTwoDecimalPlaces(distance); + + // Store total distance in transaction.comment.customUnit.quantity + setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft); + + if (isEditing) { + // Update existing transaction + const previousDistance = transaction?.comment?.customUnit?.quantity; + const previousStart = transaction?.comment?.odometerStart; + const previousEnd = transaction?.comment?.odometerEnd; + const hasChanges = previousDistance !== calculatedDistance || previousStart !== start || previousEnd !== end; + + if (hasChanges) { + // Update distance (which will also update amount and merchant) + updateMoneyRequestDistance({ + transactionID: transaction?.transactionID, + transactionThreadReportID: reportID, + distance: calculatedDistance, + odometerStart: start, + odometerEnd: end, + // Not required for odometer distance request + transactionBackup: undefined, + policy, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled: false, + }); + } + Navigation.goBack(); + return; + } + + if (backToReport) { + Navigation.goBack(backTo); + return; + } + + // If a reportID exists in the report object, use it to set participants and navigate to confirmation + // Following Manual tab pattern + if (report?.reportID && !isArchivedReport(reportNameValuePairs) && iouType !== CONST.IOU.TYPE.CREATE) { + const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); + const derivedReports = (reportAttributesDerived as ReportAttributesDerivedValue | undefined)?.reports; + const participants = selectedParticipants.map((participant) => { + const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; + return participantAccountID ? getParticipantsOption(participant, personalDetails) : getReportOption(participant, derivedReports); + }); + + if (shouldSkipConfirmation) { + setMoneyRequestPendingFields(transactionID, {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + setMoneyRequestMerchant(transactionID, translate('iou.fieldPending'), false); + + const participant = participants.at(0); + const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID: report.reportID, + isPolicyExpenseChat: !!participant?.isPolicyExpenseChat, + policy, + lastSelectedDistanceRates, + }); + + if (iouType === CONST.IOU.TYPE.TRACK && participant) { + trackExpense({ + report, + isDraftPolicy: false, + participantParams: { + payeeEmail: currentUserEmailParam, + payeeAccountID: currentUserAccountIDParam, + participant, + }, + policyParams: { + policy, + }, + transactionParams: { + amount: 0, + distance: calculatedDistance, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: translate('iou.fieldPending'), + receipt: {}, + billable: false, + customUnitRateID, + attendees: transaction?.comment?.attendees, + odometerStart: start, + odometerEnd: end, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam, + currentUserEmailParam, + introSelected, + activePolicyID, + quickAction, + }); + return; + } + + createDistanceRequest({ + report, + participants, + currentUserLogin: currentUserEmailParam, + currentUserAccountID: currentUserAccountIDParam, + iouType, + existingTransaction: transaction, + transactionParams: { + amount: 0, + distance: calculatedDistance, + comment: '', + created: transaction?.created ?? '', + currency: transaction?.currency ?? 'USD', + merchant: translate('iou.fieldPending'), + billable: !!policy?.defaultBillable, + customUnitRateID, + splitShares: transaction?.splitShares, + attendees: transaction?.comment?.attendees, + odometerStart: start, + odometerEnd: end, + }, + backToReport, + isASAPSubmitBetaEnabled: false, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + }); + return; + } + + setMoneyRequestParticipantsFromReport(transactionID, report, currentUserPersonalDetails.accountID).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 (shouldUseDefaultExpensePolicy) { + const activePolicyExpenseChat = getPolicyExpenseChat(currentUserAccountIDParam, defaultExpensePolicy?.id); + const shouldAutoReport = !!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting; + const transactionReportID = shouldAutoReport ? activePolicyExpenseChat?.reportID : CONST.REPORT.UNREPORTED_REPORT_ID; + const rateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID: transactionReportID, + isPolicyExpenseChat: true, + policy: defaultExpensePolicy, + lastSelectedDistanceRates, + }); + setTransactionReport(transactionID, {reportID: transactionReportID}, true); + setCustomUnitRateID(transactionID, rateID); + setMoneyRequestParticipantsFromReport(transactionID, activePolicyExpenseChat, currentUserPersonalDetails.accountID).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 if (transactionID && reportID) { + navigateToParticipantPage(iouType, transactionID, reportID); + } + }; + + // Handle form submission with validation + const handleNext = () => { + // Validation: Start and end readings must not be empty + if (!startReading || !endReading) { + setFormError(translate('iou.error.invalidReadings')); + return; + } + + const start = parseFloat(startReading); + const end = parseFloat(endReading); + + if (Number.isNaN(start) || Number.isNaN(end)) { + setFormError(translate('iou.error.invalidReadings')); + return; + } + + // Validation: Calculated distance (end - start) must be > 0 + const distance = end - start; + if (distance <= 0) { + setFormError(translate('iou.error.negativeDistanceNotAllowed')); + return; + } + + // When validation passes, call navigateToNextPage + navigateToNextPage(); + }; + + const shouldEnableDiscardConfirmation = !backToReport && !shouldSkipConfirmation && !isEditing; + + return ( + + + + {/* Start Reading */} + + + + + + {/* End Reading */} + + + + + + + {/* Total Distance Display - always shown, updated live */} + + + {`${translate('distance.odometer.totalDistance')}: ${totalDistance !== null ? roundToTwoDecimalPlaces(totalDistance) : 0} ${unit}`} + + + + + {/* Form Error Message */} + {!!formError && ( + + )} + + {/* Next/Save Button */} +