From a1688cbd76d3a7e5942492fa962f7ba353eec652 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 19 Dec 2025 16:33:07 +0100 Subject: [PATCH 01/27] Create time expenses in workspace reports --- src/CONST/index.ts | 3 + src/ROUTES.ts | 5 + src/SCREENS.ts | 1 + .../MoneyRequestConfirmationList.tsx | 9 +- src/components/TabSelector/TabSelector.tsx | 18 ++- src/languages/en.ts | 2 + src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 10 ++ src/libs/PolicyUtils.ts | 16 +- src/libs/TimeTrackingUtils.ts | 12 ++ src/libs/TransactionUtils/index.ts | 14 ++ src/libs/actions/IOU.ts | 15 ++ .../request/IOURequestRedirectToStartPage.tsx | 2 + src/pages/iou/request/IOURequestStartPage.tsx | 23 ++- .../step/IOURequestStepConfirmation.tsx | 6 + .../iou/request/step/IOURequestStepHours.tsx | 143 ++++++++++++++++++ .../step/withFullTransactionOrNotFound.tsx | 3 +- .../step/withWritableReportOrNotFound.tsx | 3 +- 18 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 src/libs/TimeTrackingUtils.ts create mode 100644 src/pages/iou/request/step/IOURequestStepHours.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index de9d7aca301aa..7f2a9c052b4d1 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2907,6 +2907,7 @@ const CONST = { DISTANCE_MAP: 'distance-map', DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', + TIME: 'time', }, EXPENSE_TYPE: { DISTANCE: 'distance', @@ -2918,6 +2919,7 @@ const CONST = { DISTANCE_MAP: 'distance-map', DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', + TIME: 'time', }, REPORT_ACTION_TYPE: { PAY: 'pay', @@ -5487,6 +5489,7 @@ const CONST = { DISTANCE_MAP: 'distance-map', DISTANCE_MANUAL: 'distance-manual', DISTANCE_GPS: 'distance-gps', + TIME: 'time', }, STATUS_TEXT_MAX_LENGTH: 100, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 964a287fa1a0a..1aa505e92a4e6 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1233,6 +1233,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/SCREENS.ts b/src/SCREENS.ts index 97fe65d1f3818..0d703f77c36bd 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -329,6 +329,7 @@ const SCREENS = { STEP_DISTANCE_MANUAL: 'Money_Request_Step_Distance_Manual', STEP_DISTANCE_GPS: 'Money_Request_Step_Distance_GPS', RECEIPT_PREVIEW: 'Money_Request_Receipt_preview', + STEP_HOURS: 'Money_Request_Step_Hours', }, TRANSACTION_DUPLICATE: { diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index d11f607158c40..7400b78da30e5 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -156,6 +156,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; @@ -246,6 +249,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}); @@ -344,7 +348,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) ?? ''; @@ -1241,5 +1245,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 c23b711f669ea..756d5ded63878 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', + 'Clock', +] as const; function getIconTitleAndTestID( icons: Record, IconAsset>, @@ -79,6 +93,8 @@ function getIconTitleAndTestID( return {icon: icons.Percent, title: translate('iou.percent'), testID: 'split-percentage'}; case CONST.IOU.SPLIT_TYPE.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/en.ts b/src/languages/en.ts index b05a57ea006d6..df9cd509753a2 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1484,6 +1484,8 @@ const translations = { }, }, chooseWorkspace: 'Choose a workspace', + hoursAt: (hours: number, rate: string) => `${hours} hours @ ${rate} / hour`, + hrs: 'hrs', }, transactionMerge: { listPage: { diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 699cd8b36fe01..071ff5c84156f 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1412,6 +1412,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/Navigation/types.ts b/src/libs/Navigation/types.ts index 5de3fa103a277..e12e648099a5b 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1889,6 +1889,16 @@ type MoneyRequestNavigatorParamList = { [SCREENS.SET_DEFAULT_WORKSPACE]: { navigateTo?: Routes; }; + [SCREENS.MONEY_REQUEST.STEP_HOURS]: { + iouType: typeof CONST.IOU.TYPE.SUBMIT | typeof CONST.IOU.TYPE.CREATE; + reportID: string; + transactionID: string; + action: IOUAction; + pageIndex?: string; + currency?: string; + backToReport?: string; + reportActionID?: string; + }; }; type WorkspaceConfirmationNavigatorParamList = { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 13374a6ac3609..6d2efee40438f 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -594,8 +594,8 @@ function isCollectPolicy(policy: OnyxEntry): boolean { return policy?.type === CONST.POLICY.TYPE.TEAM; } -function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry, isDistanceRequest: boolean, isPerDiemRequest = false): boolean { - if (isPerDiemRequest) { +function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry, isDistanceRequest: boolean, isPerDiemRequest = false, isTimeRequest = false): boolean { + if (isPerDiemRequest || isTimeRequest) { return false; } const distanceUnit = getDistanceRateCustomUnit(policy); @@ -1588,6 +1588,16 @@ function isMemberPolicyAdmin(policy: OnyxEntry, memberEmail: string | un return admins.some((admin) => admin.email === memberEmail); } +function isTimeTrackingEnabled(policy: Policy): boolean { + return true; + return !!policy.units?.time?.enabled; +} + +function getDefaultTimeTrackingRate(policy: Policy): number | undefined { + return 20; + return policy.units?.time?.rate; +} + export { canEditTaxRate, escapeTagName, @@ -1747,6 +1757,8 @@ export { getActivePoliciesWithExpenseChatAndPerDiemEnabled, 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..9e958f298e66a --- /dev/null +++ b/src/libs/TimeTrackingUtils.ts @@ -0,0 +1,12 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import {convertToDisplayString} from './CurrencyUtils'; + +function computeTimeAmount(rateInCents: number, count: number): number { + return Math.round(rateInCents * count); +} + +function formatTimeMerchant(hours: number, rate: number, currency: string, translate: LocalizedTranslate): string { + return translate('iou.hoursAt', hours, convertToDisplayString(rate, currency)); +} + +export {computeTimeAmount, formatTimeMerchant}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index b52233516379b..cf9f3e5b78497 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -211,6 +211,16 @@ function isPerDiemRequest(transaction: OnyxEntry): boolean { return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL; } +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; +} + function isCorporateCardTransaction(transaction: OnyxEntry): boolean { return isManagedCardTransaction(transaction) && transaction?.comment?.liabilityType === CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT; } @@ -231,6 +241,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; } @@ -2428,6 +2441,7 @@ export { getReportOwnerAsAttendee, getExchangeRate, shouldReuseInitialTransaction, + isTimeRequest, }; export type {TransactionChanges}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index c6095be3feeba..2793ea62aedf0 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1247,6 +1247,18 @@ 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}}}); +} + +function setMoneyRequestTimeType(transactionID: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {type: 'time', units: {unit: 'h'}}}); +} + /** * Sets the category for a money request transaction draft. * @param transactionID - The transaction ID @@ -15503,6 +15515,9 @@ export { getUpdateMoneyRequestParams, getUpdateTrackExpenseParams, getReportPreviewAction, + setMoneyRequestTimeRate, + setMoneyRequestTimeCount, + setMoneyRequestTimeType, }; export type { GPSPoint as GpsPoint, diff --git a/src/pages/iou/request/IOURequestRedirectToStartPage.tsx b/src/pages/iou/request/IOURequestRedirectToStartPage.tsx index 084b14c02a843..e486773ac56d2 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.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 0872252edc02e..9225bfa7e443c 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -26,7 +26,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'; @@ -41,6 +46,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'; @@ -104,6 +110,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 = @@ -231,6 +238,7 @@ function IOURequestStartPage({ }, }, ); + const shouldShowTimeOption = isBetaEnabled(CONST.BETAS.TIME_TRACKING) && iouType === CONST.IOU.TYPE.SUBMIT && !isFromGlobalCreate && hasCurrentPolicyTimeTrackingEnabled; const onBackButtonPress = () => { navigateBack(); @@ -343,6 +351,19 @@ function IOURequestStartPage({ )} )} + {shouldShowTimeOption && ( + + {() => ( + + + + )} + + )} ) : ( & { + /** The transaction object being modified in Onyx */ + transaction: OnyxEntry; + + /** Whether the user input should be kept or not */ + shouldKeepUserInput?: boolean; +}; + +function IOURequestStepTimeHours({ + report, + route: { + params: {iouType, reportID, transactionID = '-1', action, reportActionID}, + }, + transaction, + shouldKeepUserInput = false, +}: IOURequestStepHoursProps) { + const {translate} = useLocalize(); + const textInput = useRef(null); + const focusTimeoutRef = useRef(null); + const policyID = report?.policyID; + const {accountID} = useCurrentUserPersonalDetails(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); + const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + const rate = policy ? (getDefaultTimeTrackingRate(policy) ?? 0) : 0; + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); + + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, []), + ); + + const saveTime = (count: number) => { + setMoneyRequestAmount(transactionID, computeTimeAmount(rate, count), currency, shouldKeepUserInput); + setMoneyRequestMerchant(transactionID, formatTimeMerchant(count, rate, currency, translate), shouldKeepUserInput); + setMoneyRequestTimeType(transactionID, shouldKeepUserInput); + setMoneyRequestTimeRate(transactionID, rate, shouldKeepUserInput); + setMoneyRequestTimeCount(transactionID, count, shouldKeepUserInput); + + setMoneyRequestParticipantsFromReport(transactionID, report, accountID).then(() => { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); + }); + }; + + const styles = useThemeStyles(); + const {isExtraSmallScreenHeight} = useResponsiveLayout(); + const canUseTouchScreen = canUseTouchScreenUtil(); + const moneyRequestTimeInputRef = useRef(null); + + return ( + Navigation.goBack()} + testID={IOURequestStepTimeHours.displayName} + shouldShowWrapper={false} + includeSafeAreaPaddingBottom + shouldShowNotFoundPage={shouldShowNotFoundPage} + > + + +