diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ced14aca92ca1..e904f1a1530d6 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -729,6 +729,7 @@ const CONST = { IS_TRAVEL_VERIFIED: 'isTravelVerified', TRAVEL_INVOICING: 'travelInvoicing', EXPENSIFY_CARD_EU_UK: 'expensifyCardEuUk', + TIME_TRACKING: 'timeTracking', EUR_BILLING: 'eurBilling', NO_OPTIMISTIC_TRANSACTION_THREADS: 'noOptimisticTransactionThreads', UBER_FOR_BUSINESS: 'uberForBusiness', @@ -2927,6 +2928,7 @@ const CONST = { DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', DISTANCE_ODOMETER: 'distance-odometer', + TIME: 'time', }, EXPENSE_TYPE: { DISTANCE: 'distance', @@ -2939,6 +2941,7 @@ const CONST = { DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', DISTANCE_ODOMETER: 'distance-odometer', + TIME: 'time', }, REPORT_ACTION_TYPE: { @@ -5522,6 +5525,7 @@ const CONST = { DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', DISTANCE_ODOMETER: 'distance-odometer', + TIME: 'time', }, STATUS_TEXT_MAX_LENGTH: 100, @@ -6665,6 +6669,7 @@ const CONST = { MAX_TAX_RATE_DECIMAL_PLACES: 4, MIN_TAX_RATE_DECIMAL_PLACES: 2, DISTANCE_DECIMAL_PLACES: 2, + HOURS_DECIMAL_PLACES: 2, DOWNLOADS_PATH: '/Downloads', DOWNLOADS_TIMEOUT: 5000, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a222b9d07b2a9..88889c618d084 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1269,6 +1269,11 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => `create/${iouType as string}/start/${transactionID}/${reportID}/per-diem/${backToReport ?? ''}` as const, }, + MONEY_REQUEST_CREATE_TAB_TIME: { + route: 'time/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `create/${iouType as string}/start/${transactionID}/${reportID}/time/${backToReport ?? ''}` as const, + }, MONEY_REQUEST_RECEIPT_VIEW: { route: 'receipt-view/:transactionID', diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 4b10e712364e1..4da78c1ab8189 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -158,6 +158,9 @@ type MoneyRequestConfirmationListProps = { /** Whether the expense is a per diem expense */ isPerDiemRequest?: boolean; + /** Whether the expense is a time expense */ + isTimeRequest?: boolean; + /** Whether we're editing a split expense */ isEditingSplitBill?: boolean; @@ -249,6 +252,7 @@ function MoneyRequestConfirmationList({ iouIsReimbursable = true, onToggleReimbursable, showRemoveExpenseConfirmModal, + isTimeRequest = false, }: MoneyRequestConfirmationListProps) { const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); @@ -347,7 +351,7 @@ function MoneyRequestConfirmationList({ const policyTagLists = useMemo(() => getTagLists(policyTags), [policyTags]); - const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest, isPerDiemRequest); + const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); // Update the tax code when the default changes (for example, because the transaction currency changed) const defaultTaxCode = getDefaultTaxCode(policy, transaction) ?? ''; @@ -1233,5 +1237,6 @@ export default memo( prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed && prevProps.reportActionID === nextProps.reportActionID && prevProps.action === nextProps.action && - prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt, + prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt && + prevProps.isTimeRequest === nextProps.isTimeRequest, ); diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index 52fc9ea147c2c..22d36d92ae64f 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -51,6 +51,7 @@ const MEMOIZED_LAZY_TAB_SELECTOR_ICONS = [ 'Percent', 'Crosshair', 'Meter', + 'Clock', ] as const; function getIconTitleAndTestID( @@ -95,6 +96,8 @@ function getIconTitleAndTestID( return {icon: icons.Percent, title: translate('iou.percent'), testID: 'split-percentage'}; case CONST.TAB.SPLIT.DATE: return {icon: icons.CalendarSolid, title: translate('iou.date'), testID: 'split-date'}; + case CONST.TAB_REQUEST.TIME: + return {icon: icons.Clock, title: translate('iou.time'), testID: 'time'}; default: throw new Error(`Route ${route} has no icon nor title set.`); } diff --git a/src/languages/de.ts b/src/languages/de.ts index 46836462f58c7..8deed25209112 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1466,6 +1466,7 @@ const translations: TranslationDeepObject = { splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} bis ${endDate} (${count} Tage)`, splitByDate: 'Nach Datum aufteilen', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `bericht aufgrund eines benutzerdefinierten Genehmigungsworkflows an ${to} weitergeleitet`, + timeTracking: {hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'} @ ${rate} / Stunde`, hrs: 'Std.'}, }, transactionMerge: { listPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index b5b84ef5596c2..514b2a3350daf 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1446,6 +1446,10 @@ const translations = { }, chooseWorkspace: 'Choose a workspace', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `report routed to ${to} due to custom approval workflow`, + timeTracking: { + hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? 'hour' : 'hours'} @ ${rate} / hour`, + hrs: 'hrs', + }, }, transactionMerge: { listPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 82d5b8343cbb6..5f3ba900c6bc9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1178,6 +1178,10 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: 'Elige un espacio de trabajo', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `informe enviado a ${to} debido a un flujo de aprobación personalizado`, + timeTracking: { + hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? 'hora' : 'horas'} a ${rate} / hora`, + hrs: 'h', + }, }, transactionMerge: { listPage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 57d1365afdbc3..4116963025b20 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1467,6 +1467,7 @@ const translations: TranslationDeepObject = { splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `Du ${startDate} au ${endDate} (${count} jours)`, splitByDate: 'Scinder par date', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `rapport acheminé vers ${to} en raison d'un workflow d'approbation personnalisé`, + timeTracking: {hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? 'heure' : 'heures'} @ ${rate} / heure`, hrs: 'h'}, }, transactionMerge: { listPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index a5e523e9952bd..b56f7d0f3f889 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1461,6 +1461,7 @@ const translations: TranslationDeepObject = { splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} a ${endDate} (${count} giorni)`, splitByDate: 'Dividi per data', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `rapporto inoltrato a ${to} a causa del flusso di lavoro di approvazione personalizzato`, + timeTracking: {hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? 'ora' : 'ore'} @ ${rate} / ora`, hrs: 'ore'}, }, transactionMerge: { listPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c767fc6a6ff78..51e3c11dfbf7a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1460,6 +1460,7 @@ const translations: TranslationDeepObject = { splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} から ${endDate} まで(${count} 日間)`, splitByDate: '日付で分割', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `カスタム承認ワークフローにより、${to} 宛にルーティングされたレポート`, + timeTracking: {hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? '時間' : '時間'} @ ${rate} / 時間`, hrs: '時間'}, }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index dd1a42f9f8dbd..ae97a0f20ae8c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1460,6 +1460,7 @@ const translations: TranslationDeepObject = { splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} tot ${endDate} (${count} dagen)`, splitByDate: 'Splitsen op datum', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `rapport doorgestuurd naar ${to} vanwege aangepaste goedkeuringsworkflow`, + timeTracking: {hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? 'uur' : 'uren'} @ ${rate} / uur`, hrs: 'uur'}, }, transactionMerge: { listPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7307a129f55e8..6b8a0c0d6c449 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1458,6 +1458,7 @@ const translations: TranslationDeepObject = { splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} do ${endDate} (${count} dni)`, splitByDate: 'Podziel według daty', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `raport przekazany do ${to} z powodu niestandardowego procesu zatwierdzania`, + timeTracking: {hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? 'godzina' : 'godziny'} @ ${rate} / godzinę`, hrs: 'godz.'}, }, transactionMerge: { listPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 8375f523043d1..57103c37e24f9 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1457,6 +1457,7 @@ const translations: TranslationDeepObject = { splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} a ${endDate} (${count} dias)`, splitByDate: 'Dividir por data', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `relatório encaminhado para ${to} devido ao fluxo de trabalho de aprovação personalizado`, + timeTracking: {hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? 'hora' : 'horas'} @ ${rate} / hora`, hrs: 'h'}, }, transactionMerge: { listPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5769dc20b2491..de0eb3921fc6b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1435,6 +1435,7 @@ const translations: TranslationDeepObject = { splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} 至 ${endDate}(${count} 天)`, splitByDate: '按日期拆分', routedDueToDEW: ({to}: RoutedDueToDEWParams) => `报告因自定义审批工作流而转发至 ${to}`, + timeTracking: {hoursAt: (hours: number, rate: string) => `${hours} ${hours === 1 ? '小时' : '小时'} @ ${rate} / 小时`, hrs: '小时'}, }, transactionMerge: { listPage: { diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index f9755553e1072..86cf50f568deb 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1444,6 +1444,9 @@ const config: LinkingOptions['config'] = { 'per-diem': { path: ROUTES.MONEY_REQUEST_CREATE_TAB_PER_DIEM.route, }, + time: { + path: ROUTES.MONEY_REQUEST_CREATE_TAB_TIME.route, + }, }, }, [SCREENS.MONEY_REQUEST.DISTANCE_CREATE]: { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f36d86534aceb..203735d086d57 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -35,6 +35,7 @@ import {getBankAccountFromID} from './actions/BankAccounts'; import {hasSynchronizationErrorMessage, isConnectionUnverified} from './actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from './actions/connections/QuickbooksOnline'; import {getCategoryApproverRule} from './CategoryUtils'; +import {convertToBackendAmount} from './CurrencyUtils'; import Navigation from './Navigation/Navigation'; import {isOffline as isOfflineNetworkStore} from './Network/NetworkStore'; import {formatMemberForList} from './OptionsListUtils'; @@ -1672,6 +1673,14 @@ function getConnectionExporters(policy: OnyxInputOrEntry): Array): boolean { + return !!policy?.units?.time?.enabled; +} + +function getDefaultTimeTrackingRate(policy: Policy): number | undefined { + return policy.units?.time?.rate ? convertToBackendAmount(policy.units?.time?.rate) : undefined; +} + export { canEditTaxRate, escapeTagName, @@ -1835,6 +1844,8 @@ export { getTravelStep, getActivePoliciesWithExpenseChatAndPerDiemEnabledAndHasRates, isDefaultTagName, + isTimeTrackingEnabled, + getDefaultTimeTrackingRate, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/TimeTrackingUtils.ts b/src/libs/TimeTrackingUtils.ts new file mode 100644 index 0000000000000..39bc88d5b2b52 --- /dev/null +++ b/src/libs/TimeTrackingUtils.ts @@ -0,0 +1,18 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import {convertToDisplayString} from './CurrencyUtils'; + +/** + * Computes the transaction amount for given hourly rate (in cents) and hour count. + */ +function computeTimeAmount(rateInCents: number, count: number): number { + return Math.round(rateInCents * count); +} + +/** + * Creates an automatic merchant value for time requests. + */ +function formatTimeMerchant(hours: number, rate: number, currency: string, translate: LocalizedTranslate): string { + return translate('iou.timeTracking.hoursAt', hours, convertToDisplayString(rate, currency)); +} + +export {computeTimeAmount, formatTimeMerchant}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index d7a040d75ac42..9f4628c472c85 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -114,6 +114,10 @@ type TransactionParams = { distance?: number; odometerStart?: number; odometerEnd?: number; + type?: ValueOf; + count?: number; + rate?: number; + unit?: ValueOf; }; type BuildOptimisticTransactionParams = { @@ -235,6 +239,12 @@ function isPerDiemRequest(transaction: OnyxEntry): boolean { } function isTimeRequest(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.TIME; + } + + // This is the case for transaction objects once they have been saved to the server return transaction?.comment?.type === CONST.TRANSACTION.TYPE.TIME; } @@ -261,6 +271,9 @@ function getRequestType(transaction: OnyxEntry): IOURequestType { if (isPerDiemRequest(transaction)) { return CONST.IOU.REQUEST_TYPE.PER_DIEM; } + if (isTimeRequest(transaction)) { + return CONST.IOU.REQUEST_TYPE.TIME; + } return CONST.IOU.REQUEST_TYPE.MANUAL; } @@ -390,6 +403,10 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, odometerStart, odometerEnd, + type, + count, + rate, + unit, } = 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) @@ -439,6 +456,15 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T lodashSet(commentJSON, 'customUnit', customUnit); } + if (type === CONST.TRANSACTION.TYPE.TIME) { + commentJSON.units = { + count, + rate, + unit, + }; + commentJSON.type = type; + } + return { ...(!isEmptyObject(pendingFields) ? {pendingFields} : {}), transactionID, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index f9b6227fb453e..c45e3242c70ca 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1280,6 +1280,14 @@ function setMoneyRequestPendingFields(transactionID: string, pendingFields: Onyx Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); } +function setMoneyRequestTimeRate(transactionID: string, rate: number, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {units: {rate}}}); +} + +function setMoneyRequestTimeCount(transactionID: string, count: number, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {units: {count}}}); +} + /** * Sets the category for a money request transaction draft. * @param transactionID - The transaction ID @@ -3031,6 +3039,10 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma linkedTrackedExpenseReportAction, pendingAction, pendingFields = {}, + type, + count, + rate, + unit, } = transactionParams; const payerEmail = addSMSDomainIfPhoneNumber(participant.login ?? ''); @@ -3161,6 +3173,10 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma pendingAction, pendingFields: isDistanceRequest && !isManualDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, ...pendingFields} : pendingFields, reimbursable: isPolicyExpenseChat ? reimbursable : true, + type, + count, + rate, + unit, }, isDemoTransactionParam: isSelectedManagerMcTest(participant.login) || transactionParams.receipt?.isTestDriveReceipt, }); @@ -14652,6 +14668,8 @@ export { getUserAccountID, getReceiptError, getSearchOnyxUpdate, + setMoneyRequestTimeRate, + setMoneyRequestTimeCount, }; export type { GPSPoint as GpsPoint, diff --git a/src/pages/iou/request/IOURequestRedirectToStartPage.tsx b/src/pages/iou/request/IOURequestRedirectToStartPage.tsx index a08421eca54d3..f274795ee6879 100644 --- a/src/pages/iou/request/IOURequestRedirectToStartPage.tsx +++ b/src/pages/iou/request/IOURequestRedirectToStartPage.tsx @@ -42,6 +42,8 @@ function IOURequestRedirectToStartPage({ 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)); + } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.TIME) { + Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_TIME.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/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index b2e52369c9f9f..9469c6d8acdfd 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -27,7 +27,12 @@ import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {getIsUserSubmittedExpenseOrScannedReceipt} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; -import {getActivePoliciesWithExpenseChatAndPerDiemEnabledAndHasRates, getPerDiemCustomUnit, hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; +import { + getActivePoliciesWithExpenseChatAndPerDiemEnabledAndHasRates, + getPerDiemCustomUnit, + hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil, + isTimeTrackingEnabled, +} from '@libs/PolicyUtils'; import {getPayeeName} from '@libs/ReportUtils'; import {endSpan} from '@libs/telemetry/activeSpans'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -42,6 +47,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import IOURequestStepAmount from './step/IOURequestStepAmount'; import IOURequestStepDestination from './step/IOURequestStepDestination'; import IOURequestStepDistance from './step/IOURequestStepDistance'; +import IOURequestStepHours from './step/IOURequestStepHours'; import IOURequestStepPerDiemWorkspace from './step/IOURequestStepPerDiemWorkspace'; import IOURequestStepScan from './step/IOURequestStepScan'; import type {WithWritableReportOrNotFoundProps} from './step/withWritableReportOrNotFound'; @@ -108,6 +114,7 @@ function IOURequestStartPage({ const doesPerDiemPolicyExist = policiesWithPerDiemEnabledAndHasRates.length > 0; const moreThanOnePerDiemExist = policiesWithPerDiemEnabledAndHasRates.length > 1; const hasCurrentPolicyPerDiemEnabled = !!policy?.arePerDiemRatesEnabled; + const hasCurrentPolicyTimeTrackingEnabled = policy ? isTimeTrackingEnabled(policy) : false; const perDiemCustomUnit = getPerDiemCustomUnit(policy); const hasPolicyPerDiemRates = !isEmptyObject(perDiemCustomUnit?.rates); const shouldShowPerDiemOption = @@ -233,6 +240,7 @@ function IOURequestStartPage({ }, }, ); + const shouldShowTimeOption = isBetaEnabled(CONST.BETAS.TIME_TRACKING) && iouType === CONST.IOU.TYPE.SUBMIT && !isFromGlobalCreate && hasCurrentPolicyTimeTrackingEnabled; const onBackButtonPress = () => { navigateBack(); @@ -341,6 +349,18 @@ function IOURequestStartPage({ )} )} + {shouldShowTimeOption && ( + + {() => ( + + + + )} + + )} ) : ( diff --git a/src/pages/iou/request/step/IOURequestStepHours.tsx b/src/pages/iou/request/step/IOURequestStepHours.tsx new file mode 100644 index 0000000000000..78a44fbdab12d --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepHours.tsx @@ -0,0 +1,143 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useEffect, useRef, useState} from 'react'; +import Button from '@components/Button'; +import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; +import type {NumberWithSymbolFormRef} from '@components/NumberWithSymbolForm'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; +import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getDefaultTimeTrackingRate} from '@libs/PolicyUtils'; +import {computeTimeAmount, formatTimeMerchant} from '@libs/TimeTrackingUtils'; +import variables from '@styles/variables'; +import {setMoneyRequestAmount, setMoneyRequestMerchant, setMoneyRequestParticipantsFromReport, setMoneyRequestTimeCount, setMoneyRequestTimeRate} from '@userActions/IOU'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import StepScreenWrapper from './StepScreenWrapper'; +import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type IOURequestStepHoursProps = WithWritableReportOrNotFoundProps & WithFullTransactionOrNotFoundProps; + +function IOURequestStepHours({ + report, + route: { + params: {iouType, reportID, transactionID = '-1', action, reportActionID}, + }, + transaction, +}: IOURequestStepHoursProps) { + const policyID = report?.policyID; + const isTransactionDraft = shouldUseTransactionDraft(action); + const [selectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.IOU_REQUEST_TYPE}`, {canBeMissing: true}); + const {accountID} = useCurrentUserPersonalDetails(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); + const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + const rate = policy ? getDefaultTimeTrackingRate(policy) : undefined; + + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {isExtraSmallScreenHeight} = useResponsiveLayout(); + const canUseTouchScreen = canUseTouchScreenUtil(); + const textInputRef = useRef(null); + const focusTimeoutRef = useRef(null); + const moneyRequestTimeInputRef = useRef(null); + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); + const [formError, setFormError] = useState(''); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setFormError(''); + moneyRequestTimeInputRef.current?.updateNumber(''); + }, [selectedTab]); + + useFocusEffect(() => { + focusTimeoutRef.current = setTimeout(() => textInputRef.current?.focus(), CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }); + + const saveTime = () => { + if (rate === undefined) { + return; + } + + const count = parseFloat(moneyRequestTimeInputRef.current?.getNumber() ?? ''); + if (Number.isNaN(count) || count <= 0) { + setFormError(translate('iou.error.quantityGreaterThanZero')); + return; + } + + setMoneyRequestAmount(transactionID, computeTimeAmount(rate, count), currency); + setMoneyRequestMerchant(transactionID, formatTimeMerchant(count, rate, currency, translate), isTransactionDraft); + setMoneyRequestTimeRate(transactionID, rate, isTransactionDraft); + setMoneyRequestTimeCount(transactionID, count, isTransactionDraft); + + setMoneyRequestParticipantsFromReport(transactionID, report, accountID).then(() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)), + ); + }; + + return ( + + { + if (!formError) { + return; + } + setFormError(''); + }} + footer={ +