diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 64af334faba6..e85193038607 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4106,6 +4106,12 @@ const CONST = { FAKE_P2P_ID: '_FAKE_P2P_ID_', MILES_TO_KILOMETERS: 1.609344, KILOMETERS_TO_MILES: 0.621371, + RATE_STATUS: { + ACTIVE: 'active', + FUTURE: 'future', + EXPIRED: 'expired', + INACTIVE: 'inactive', + }, }, TERMS: { diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx new file mode 100644 index 000000000000..8c729a92e36e --- /dev/null +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -0,0 +1,167 @@ +import {format, parseISO} from 'date-fns'; +import React from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import Icon from '@components/Icon'; +import Table from '@components/Table'; +import Text from '@components/Text'; +import TextWithTooltip from '@components/TextWithTooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getRateStatus} from '@libs/PolicyDistanceRatesUtils'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type {Rate} from '@src/types/onyx/Policy'; + +type DistanceRateTableItemData = { + rateID: string; + rate: Rate; + formattedRate: string; + pendingAction?: OnyxCommon.PendingAction; + errors?: OnyxCommon.Errors; + onDismissError?: () => void; +}; + +type WorkspaceDistanceRatesTableRowProps = { + item: DistanceRateTableItemData; + rowIndex: number; + isSelected: boolean; + canSelectMultiple: boolean; + onToggle: () => void; + onPress: () => void; + onLongPress?: () => void; + shouldUseNarrowTableLayout: boolean; + statusLabels: Record; +}; + +function formatDateColumn(dateString: string | undefined): string { + if (!dateString) { + return ''; + } + return format(parseISO(dateString), CONST.DATE.MONTH_DAY_YEAR_FORMAT); +} + +function WorkspaceDistanceRatesTableRow({ + item, + rowIndex, + isSelected, + canSelectMultiple, + onToggle, + onPress, + onLongPress, + shouldUseNarrowTableLayout, + statusLabels, +}: WorkspaceDistanceRatesTableRowProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const Expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + + const {rate, formattedRate, pendingAction, errors, onDismissError} = item; + const isDeleting = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + const status = getRateStatus(rate); + + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'WorkspaceDistanceRatesTableItem', + isDeleting, + }; + + return ( + + {({hovered}) => ( + <> + {canSelectMultiple && ( + + + + )} + + + + {shouldUseNarrowTableLayout && ( + + )} + + + {!shouldUseNarrowTableLayout && ( + + + {rate.name} + + + )} + + {!shouldUseNarrowTableLayout && ( + + + {formattedRate} + + + )} + + {!shouldUseNarrowTableLayout && ( + + + {formatDateColumn(rate.startDate)} + + + )} + + {!shouldUseNarrowTableLayout && ( + + + {formatDateColumn(rate.endDate)} + + + )} + + + + )} + + ); +} + +export default WorkspaceDistanceRatesTableRow; +export type {DistanceRateTableItemData}; diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx new file mode 100644 index 000000000000..783146b3e584 --- /dev/null +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -0,0 +1,238 @@ +import type {ListRenderItemInfo} from '@shopify/flash-list'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import Table from '@components/Table'; +import type {ActiveSorting, CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableHandle} from '@components/Table'; +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; +import {getRateStatus} from '@libs/PolicyDistanceRatesUtils'; +import tokenizedSearch from '@libs/tokenizedSearch'; +import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type {Rate} from '@src/types/onyx/Policy'; +import WorkspaceDistanceRatesTableRow from './WorkspaceDistanceRatesTableRow'; +import type {DistanceRateTableItemData} from './WorkspaceDistanceRatesTableRow'; + +type DistanceRatesTableColumnKey = 'status' | 'name' | 'rate' | 'startDate' | 'endDate'; + +type WorkspaceDistanceRatesTableProps = { + customUnitRates: Record; + unitTranslation: string; + selectedDistanceRates: string[]; + canSelectMultiple: boolean; + onToggleRate: (rateID: string) => void; + onToggleAllRates: () => void; + onPressRate: (rateID: string) => void; + onLongPressRate?: (rateID: string) => void; + onDismissError: (rateID: string) => void; + pendingAction?: OnyxCommon.PendingAction; + pendingFields?: OnyxCommon.PendingFields; +}; + +const STATUS_ORDER: Record = { + [CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE]: 0, + [CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE]: 1, + [CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED]: 2, + [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: 3, +}; + +function WorkspaceDistanceRatesTable({ + customUnitRates, + unitTranslation, + selectedDistanceRates, + canSelectMultiple, + onToggleRate, + onToggleAllRates, + onPressRate, + onLongPressRate, + onDismissError, + pendingAction, + pendingFields, +}: WorkspaceDistanceRatesTableProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; + + const tableRef = useRef>(null); + + const columns: Array> = [ + {key: 'status', label: translate('workspace.distanceRates.status')}, + {key: 'name', label: translate('common.name')}, + {key: 'rate', label: translate('workspace.distanceRates.rate')}, + {key: 'startDate', label: translate('workspace.distanceRates.startDate')}, + {key: 'endDate', label: translate('workspace.distanceRates.endDate')}, + ]; + + const ratesData: DistanceRateTableItemData[] = Object.values(customUnitRates).map((rate) => { + const resolvedPendingAction = + rate.pendingAction ?? + rate.pendingFields?.rate ?? + rate.pendingFields?.enabled ?? + rate.pendingFields?.currency ?? + rate.pendingFields?.name ?? + pendingFields?.attributes ?? + (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? pendingAction : undefined); + + return { + rateID: rate.customUnitRateID, + rate, + formattedRate: `${convertAmountToDisplayString(rate.rate, rate.currency ?? CONST.CURRENCY.USD)} / ${unitTranslation}`, + pendingAction: resolvedPendingAction ?? undefined, + errors: rate.errors ?? undefined, + onDismissError: () => onDismissError(rate.customUnitRateID), + }; + }); + + const keyExtractor = (item: DistanceRateTableItemData) => item.rateID; + + const tableBodyContentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ + addBottomSafeAreaPadding: true, + addOfflineIndicatorBottomSafeAreaPadding: true, + style: styles.pb4, + }); + + const compareItems: CompareItemsCallback = (a, b, activeSorting) => { + const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; + + if (activeSorting.columnKey === 'status') { + const aStatus = getRateStatus(a.rate); + const bStatus = getRateStatus(b.rate); + const diff = (STATUS_ORDER[aStatus] ?? 0) - (STATUS_ORDER[bStatus] ?? 0); + if (diff !== 0) { + return diff * orderMultiplier; + } + return localeCompare(a.rate.name ?? '', b.rate.name ?? '') * orderMultiplier; + } + + if (activeSorting.columnKey === 'name') { + return localeCompare(a.rate.name ?? '', b.rate.name ?? '') * orderMultiplier; + } + + if (activeSorting.columnKey === 'rate') { + return ((a.rate.rate ?? 0) - (b.rate.rate ?? 0)) * orderMultiplier; + } + + if (activeSorting.columnKey === 'startDate') { + return localeCompare(a.rate.startDate ?? '', b.rate.startDate ?? '') * orderMultiplier; + } + + if (activeSorting.columnKey === 'endDate') { + return localeCompare(a.rate.endDate ?? '', b.rate.endDate ?? '') * orderMultiplier; + } + + return 0; + }; + + const isItemInSearch: IsItemInSearchCallback = (item, searchString) => { + const matchingItems = tokenizedSearch([item], searchString, (i) => [i.rate.name ?? '', i.formattedRate]); + return matchingItems.length > 0; + }; + + const statusLabels = useMemo( + () => ({ + [CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE]: translate('workspace.distanceRates.statusActive'), + [CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE]: translate('workspace.distanceRates.statusFuture'), + [CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED]: translate('workspace.distanceRates.statusExpired'), + [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: translate('workspace.distanceRates.statusInactive'), + }), + [translate], + ); + + const renderItem = ({item, index}: ListRenderItemInfo) => ( + onToggleRate(item.rateID)} + onPress={() => onPressRate(item.rateID)} + onLongPress={onLongPressRate ? () => onLongPressRate(item.rateID) : undefined} + shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} + statusLabels={statusLabels} + /> + ); + + const isNarrowLayoutRef = useRef(shouldUseNarrowTableLayout); + const [activeSortingInWideLayout, setActiveSortingInWideLayout] = useState | undefined>(undefined); + + useEffect(() => { + if (shouldUseNarrowTableLayout) { + if (isNarrowLayoutRef.current) { + return; + } + isNarrowLayoutRef.current = true; + const activeSorting = tableRef.current?.getActiveSorting(); + setActiveSortingInWideLayout(activeSorting); + tableRef.current?.updateSorting({columnKey: 'name', order: 'asc'}); + return; + } + + if (!activeSortingInWideLayout || !isNarrowLayoutRef.current) { + return; + } + + isNarrowLayoutRef.current = false; + tableRef.current?.updateSorting(activeSortingInWideLayout); + }, [activeSortingInWideLayout, shouldUseNarrowTableLayout]); + + const allSelected = ratesData.length > 0 && ratesData.every((item) => selectedDistanceRates.includes(item.rateID)); + const someSelected = selectedDistanceRates.length > 0 && !allSelected; + + const SelectAllHeader = canSelectMultiple ? ( + + + + ) : null; + + const shouldShowSearchBar = Object.keys(customUnitRates).length >= CONST.STANDARD_LIST_ITEM_LIMIT; + + const ListHeader = ( + <> + {shouldShowSearchBar && } + {!shouldUseNarrowTableLayout && } + + ); + + return ( + + {!shouldUseNarrowTableLayout && ( + <> + {shouldShowSearchBar && } + + {SelectAllHeader} + + + + + + )} + + +
+ ); +} + +export default WorkspaceDistanceRatesTable; +export type {DistanceRateTableItemData, DistanceRatesTableColumnKey}; diff --git a/src/languages/en.ts b/src/languages/en.ts index 936a6fe85299..c4c390511848 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6606,6 +6606,10 @@ const translations = { }), enableRate: 'Enable rate', status: 'Status', + statusActive: 'Active', + statusFuture: 'Future', + statusExpired: 'Expired', + statusInactive: 'Inactive', unit: 'Unit', taxFeatureNotEnabledMessage: 'Taxes must be enabled on the workspace to use this feature. Head over to More features to make that change.', diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 3fcc4d079992..121427c8de37 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -171,4 +171,22 @@ function buildOnyxDataForPolicyDistanceRateUpdates( return {optimisticData, successData, failureData}; } -export {validateRateValue, getOptimisticRateName, validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates}; +function getRateStatus(rate: Rate): string { + if (!rate.enabled) { + return CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE; + } + + const now = new Date().toISOString().slice(0, 10); + + if (rate.startDate && rate.startDate > now) { + return CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE; + } + + if (rate.endDate && rate.endDate < now) { + return CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED; + } + + return CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE; +} + +export {validateRateValue, getOptimisticRateName, validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates, getRateStatus}; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 0bae0e6909ea..c5ee0aa78261 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -15,6 +15,7 @@ import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import Switch from '@components/Switch'; +import WorkspaceDistanceRatesTable from '@components/Tables/WorkspaceDistanceRatesTable'; import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import useFilteredSelection from '@hooks/useFilteredSelection'; @@ -23,6 +24,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; @@ -31,7 +33,7 @@ import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButton import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolation from '@hooks/useTransactionViolation'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; -import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import { clearCreateDistanceRateItemAndError, clearDeleteDistanceRateError, @@ -66,6 +68,8 @@ function PolicyDistanceRatesPage({ }, }: PolicyDistanceRatesPageProps) { const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close', 'Gear', 'Plus', 'Trashcan']); + const {isBetaEnabled} = usePermissions(); + const isDateBoundMileageRateEnabled = isBetaEnabled(CONST.BETAS.DATE_BOUND_MILEAGE_RATE); const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -326,13 +330,20 @@ function PolicyDistanceRatesPage({ setSelectedDistanceRates([]); }; + const toggleRateByID = useCallback( + (rateID: string) => { + setSelectedDistanceRates((prevSelectedRates) => { + if (prevSelectedRates.includes(rateID)) { + return prevSelectedRates.filter((selectedRate) => selectedRate !== rateID); + } + return [...prevSelectedRates, rateID]; + }); + }, + [setSelectedDistanceRates], + ); + const toggleRate = (rate: RateForList) => { - setSelectedDistanceRates((prevSelectedRates) => { - if (prevSelectedRates.includes(rate.value)) { - return prevSelectedRates.filter((selectedRate) => selectedRate !== rate.value); - } - return [...prevSelectedRates, rate.value]; - }); + toggleRateByID(rate.value); }; const toggleAllRates = () => { @@ -347,6 +358,46 @@ function PolicyDistanceRatesPage({ } }; + const toggleAllRatesForTable = useCallback(() => { + if (selectedDistanceRates.length > 0) { + setSelectedDistanceRates([]); + } else { + setSelectedDistanceRates( + Object.entries(selectableRates) + .filter(([, rate]) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key), + ); + } + }, [selectedDistanceRates.length, selectableRates, setSelectedDistanceRates]); + + const dismissErrorByID = (rateID: string) => { + if (!customUnit?.customUnitID) { + return; + } + if (customUnitRates[rateID]?.errors) { + clearDeleteDistanceRateError(policyID, customUnit.customUnitID, rateID); + return; + } + clearCreateDistanceRateItemAndError(policyID, customUnit.customUnitID, rateID); + }; + + const openRateDetailsByID = useCallback( + (rateID: string) => { + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.getRoute(policyID, rateID)); + }, + [policyID], + ); + + const handleLongPressRate = useCallback( + (rateID: string) => { + if (shouldUseNarrowLayout && !isMobileSelectionModeEnabled) { + turnOnMobileSelectionMode(); + } + toggleRateByID(rateID); + }, + [shouldUseNarrowLayout, isMobileSelectionModeEnabled, toggleRateByID], + ); + const getCustomListHeader = () => { if (filteredDistanceRatesList.length === 0) { return null; @@ -525,28 +576,43 @@ function PolicyDistanceRatesPage({ reasonAttributes={reasonAttributes} /> )} - {Object.values(customUnitRates).length > 0 && ( - item && toggleRate(item)} - onSelectAll={filteredDistanceRatesList.length > 0 ? toggleAllRates : undefined} - shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - customListHeaderContent={headerContent} - canSelectMultiple={canSelectMultiple} - selectAllAccessibilityLabel={translate('accessibilityHints.selectAllDistanceRates')} - onDismissError={dismissError} - shouldShowListEmptyContent={false} - showScrollIndicator={false} - turnOnSelectionModeOnLongPress - shouldHeaderBeInsideList - shouldShowRightCaret - /> - )} + {Object.values(customUnitRates).length > 0 && + (isDateBoundMileageRateEnabled ? ( + + ) : ( + item && toggleRate(item)} + onSelectAll={filteredDistanceRatesList.length > 0 ? toggleAllRates : undefined} + shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + customListHeaderContent={headerContent} + canSelectMultiple={canSelectMultiple} + selectAllAccessibilityLabel={translate('accessibilityHints.selectAllDistanceRates')} + onDismissError={dismissError} + shouldShowListEmptyContent={false} + showScrollIndicator={false} + turnOnSelectionModeOnLongPress + shouldHeaderBeInsideList + shouldShowRightCaret + /> + ))} );