diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index e5c85a8f5f6d..7955e5993ba9 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -24,9 +24,12 @@ type CategoryPickerProps = CategoryPickerOnyxProps & { policyID: string; selectedCategory?: string; onSubmit: (item: ListItem) => void; + + /** Whether SectionList should use custom ScrollView */ + shouldUseCustomScrollView?: boolean; }; -function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedCategories, policyCategoriesDraft, onSubmit}: CategoryPickerProps) { +function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedCategories, policyCategoriesDraft, onSubmit, shouldUseCustomScrollView = false}: CategoryPickerProps) { const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); @@ -84,6 +87,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC ListItem={RadioListItem} initiallyFocusedOptionKey={selectedOptionKey ?? undefined} isRowMultilineSupported + shouldUseCustomScrollView={shouldUseCustomScrollView} /> ); } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 7d8f4c1738c8..ffeeb12150b1 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -2,7 +2,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import isEmpty from 'lodash/isEmpty'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListData, SectionListRenderItemInfo} from 'react-native'; +import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, ScrollViewProps, SectionListData, SectionListRenderItemInfo} from 'react-native'; import {View} from 'react-native'; import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; @@ -10,6 +10,7 @@ import FixedFooter from '@components/FixedFooter'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import {PressableWithFeedback} from '@components/Pressable'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; +import ScrollView from '@components/ScrollView'; import SectionList from '@components/SectionList'; import ShowMoreButton from '@components/ShowMoreButton'; import Text from '@components/Text'; @@ -73,6 +74,7 @@ function BaseSelectionList( shouldStopPropagation = false, shouldShowTooltips = true, shouldUseDynamicMaxToRenderPerBatch = false, + shouldUseCustomScrollView = false, rightHandSideComponent, isLoadingNewOptions = false, onLayout, @@ -423,6 +425,9 @@ function BaseSelectionList( ); + // eslint-disable-next-line react/jsx-props-no-spreading + const scrollComponent = shouldUseCustomScrollView ? (props: ScrollViewProps) => : undefined; + const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => { const normalizedIndex = index + (section?.indexOffset ?? 0); const isDisabled = !!section.isDisabled || item.isDisabled; @@ -701,6 +706,7 @@ function BaseSelectionList( {!listHeaderContent && header()} = Partial & { /** Whether to use dynamic maxToRenderPerBatch depending on the visible number of elements */ shouldUseDynamicMaxToRenderPerBatch?: boolean; + /** Whether SectionList should use custom ScrollView */ + shouldUseCustomScrollView?: boolean; + /** Whether keyboard shortcuts should be disabled */ disableKeyboardShortcuts?: boolean; diff --git a/src/languages/en.ts b/src/languages/en.ts index 30e2dad391d9..526394a54732 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2823,6 +2823,8 @@ export default { disableCategory: 'Disable category', enableCategories: 'Enable categories', enableCategory: 'Enable category', + defaultSpendCategories: 'Default spend categories', + spendCategoriesDescription: 'Customize how merchant spend is categorized for credit card transactions and scanned receipts.', deleteFailureMessage: 'An error occurred while deleting the category, please try again.', categoryName: 'Category name', requiresCategory: 'Members must categorize all expenses', diff --git a/src/languages/es.ts b/src/languages/es.ts index 16b8f6f42ed2..84777f56a3b9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2871,6 +2871,8 @@ export default { disableCategory: 'Desactivar categoría', enableCategories: 'Activar categorías', enableCategory: 'Activar categoría', + defaultSpendCategories: 'Categorías de gasto predeterminadas', + spendCategoriesDescription: 'Personaliza cómo se categorizan los gastos de los comerciantes para las transacciones con tarjeta de crédito y los recibos escaneados.', deleteFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.', categoryName: 'Nombre de la categoría', requiresCategory: 'Los miembros deben clasificar todos los gastos', diff --git a/src/libs/API/parameters/SetWorkspaceDefaultSpendCategoryParams.ts b/src/libs/API/parameters/SetWorkspaceDefaultSpendCategoryParams.ts new file mode 100644 index 000000000000..84c2deacb546 --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceDefaultSpendCategoryParams.ts @@ -0,0 +1,7 @@ +type SetWorkspaceDefaultSpendCategoryParams = { + policyID: string; + groupID: string; + category: string; +}; + +export default SetWorkspaceDefaultSpendCategoryParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 997ee7dc1fc9..a94e4744fffb 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -178,6 +178,7 @@ export type {default as SetWorkspaceAutoReportingMonthlyOffsetParams} from './Se export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams'; export type {default as SetWorkspacePayerParams} from './SetWorkspacePayerParams'; export type {default as SetWorkspaceReimbursementParams} from './SetWorkspaceReimbursementParams'; +export type {default as SetWorkspaceDefaultSpendCategoryParams} from './SetWorkspaceDefaultSpendCategoryParams'; export type {default as SetPolicyRequiresTag} from './SetPolicyRequiresTag'; export type {default as SetPolicyTagsRequired} from './SetPolicyTagsRequired'; export type {default as RenamePolicyTaglistParams} from './RenamePolicyTaglistParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 42cfc8d01aa1..850b186975c5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -14,6 +14,7 @@ const WRITE_COMMANDS = { SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode', SET_WORKSPACE_PAYER: 'SetWorkspacePayer', SET_WORKSPACE_REIMBURSEMENT: 'SetWorkspaceReimbursement', + SET_WORKSPACE_DEFAULT_SPEND_CATEGORY: 'SetPolicyDefaultSpendCategory', DISMISS_REFERRAL_BANNER: 'DismissReferralBanner', UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale', OPEN_APP: 'OpenApp', @@ -540,6 +541,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams; [WRITE_COMMANDS.SET_WORKSPACE_PAYER]: Parameters.SetWorkspacePayerParams; [WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT]: Parameters.SetWorkspaceReimbursementParams; + [WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY]: Parameters.SetWorkspaceDefaultSpendCategoryParams; [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams; [WRITE_COMMANDS.TRACK_EXPENSE]: Parameters.TrackExpenseParams; [WRITE_COMMANDS.ENABLE_POLICY_CATEGORIES]: Parameters.EnablePolicyCategoriesParams; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 3ccbb7873cef..49bfb1c20b09 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -3454,6 +3454,46 @@ function upgradeToCorporate(policyID: string, featureName: string) { API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {optimisticData, successData, failureData}); } +function setWorkspaceDefaultSpendCategory(policyID: string, groupID: string, category: string) { + const policy = getPolicy(policyID); + if (!policy) { + return; + } + + const {mccGroup} = policy; + + const optimisticData: OnyxUpdate[] = mccGroup + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + mccGroup: { + ...mccGroup, + [groupID]: { + category, + groupID, + }, + }, + }, + }, + ] + : []; + + const failureData: OnyxUpdate[] = mccGroup + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + mccGroup, + }, + }, + ] + : []; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY, {policyID, groupID, category}, {optimisticData, successData: [], failureData}); +} /** * Call the API to set the receipt required amount for the given policy * @param policyID - id of the policy to set the receipt required amount @@ -3841,6 +3881,7 @@ export { openWorkspaceReimburseView, setPolicyIDForReimburseView, clearOnyxDataForReimburseView, + setWorkspaceDefaultSpendCategory, setRateForReimburseView, setUnitForReimburseView, generateDefaultWorkspaceName, diff --git a/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx b/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx new file mode 100644 index 000000000000..76b0f11b467a --- /dev/null +++ b/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx @@ -0,0 +1,61 @@ +import React, {useState} from 'react'; +import BaseListItem from '@components/SelectionList/BaseListItem'; +import type {BaseListItemProps, ListItem} from '@components/SelectionList/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import blurActiveElement from '@libs/Accessibility/blurActiveElement'; +import CategorySelector from '@pages/workspace/distanceRates/CategorySelector'; +import * as Policy from '@userActions/Policy/Policy'; + +function SpendCategorySelectorListItem({item, onSelectRow, isFocused}: BaseListItemProps) { + const styles = useThemeStyles(); + const [isCategoryPickerVisible, setIsCategoryPickerVisible] = useState(false); + const {policyID, groupID, categoryID} = item; + + if (!policyID || !groupID) { + return; + } + + const onSelect = (data: TItem) => { + setIsCategoryPickerVisible(true); + onSelectRow(data); + }; + + const setNewCategory = (selectedCategory: ListItem) => { + if (!selectedCategory.text) { + return; + } + Policy.setWorkspaceDefaultSpendCategory(policyID, groupID, selectedCategory.text); + }; + + return ( + + setIsCategoryPickerVisible(true)} + hidePickerModal={() => { + setIsCategoryPickerVisible(false); + blurActiveElement(); + }} + shouldUseCustomScrollView + /> + + ); +} + +SpendCategorySelectorListItem.displayName = 'SpendCategorySelectorListItem'; + +export default SpendCategorySelectorListItem; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index 3c9edfd5fbf2..03c01e5a7264 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -1,8 +1,11 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -15,6 +18,7 @@ import {setWorkspaceRequiresCategory} from '@userActions/Policy/Category'; import * as Policy from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import SpendCategorySelectorListItem from './SpendCategorySelectorListItem'; type WorkspaceCategoriesSettingsPageProps = WithPolicyConnectionsProps; @@ -24,6 +28,7 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; const policyID = route.params.policyID ?? '-1'; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [currentPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const currentConnectionName = PolicyUtils.getCurrentConnectionName(policy); const toggleSubtitle = isConnectedToAccounting && currentConnectionName ? `${translate('workspace.categories.needCategoryForExportToIntegration')} ${currentConnectionName}.` : undefined; @@ -32,7 +37,32 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet setWorkspaceRequiresCategory(policyID, value); }; + const {sections} = useMemo(() => { + if (!(currentPolicy && currentPolicy.mccGroup)) { + return {sections: [{data: []}]}; + } + + return { + sections: [ + { + data: Object.entries(currentPolicy.mccGroup).map( + ([mccKey, mccGroup]) => + ({ + categoryID: mccGroup.category, + keyForList: mccKey, + groupID: mccKey, + policyID, + tabIndex: -1, + } as ListItem), + ), + }, + ], + }; + }, [currentPolicy, policyID]); + const hasEnabledOptions = OptionsListUtils.hasEnabledOptions(policyCategories ?? {}); + const isToggleDisabled = !policy?.areCategoriesEnabled || !hasEnabledOptions || isConnectedToAccounting; + return ( - - Policy.clearPolicyErrorField(policy?.id ?? '-1', 'requiresCategory')} - shouldPlaceSubtitleBelowSwitch - /> + Policy.clearPolicyErrorField(policy?.id ?? '-1', 'requiresCategory')} + shouldPlaceSubtitleBelowSwitch + /> + + {!!currentPolicy && sections[0].data.length > 0 && ( + + {translate('workspace.categories.defaultSpendCategories')} + {translate('workspace.categories.spendCategoriesDescription')} + + } + sections={sections} + ListItem={SpendCategorySelectorListItem} + onSelectRow={() => {}} + /> + )} diff --git a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx b/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx index b48456ecce79..ce40753a23fc 100644 --- a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx +++ b/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx @@ -25,9 +25,12 @@ type CategorySelectorModalProps = { /** Label to display on field */ label: string; + + /** Whether SectionList should use custom ScrollView */ + shouldUseCustomScrollView: boolean; }; -function CategorySelectorModal({policyID, isVisible, currentCategory, onCategorySelected, onClose, label}: CategorySelectorModalProps) { +function CategorySelectorModal({policyID, isVisible, currentCategory, onCategorySelected, onClose, label, shouldUseCustomScrollView}: CategorySelectorModalProps) { const styles = useThemeStyles(); return ( @@ -54,6 +57,7 @@ function CategorySelectorModal({policyID, isVisible, currentCategory, onCategory policyID={policyID} selectedCategory={currentCategory} onSubmit={onCategorySelected} + shouldUseCustomScrollView={shouldUseCustomScrollView} /> diff --git a/src/pages/workspace/distanceRates/CategorySelector/index.tsx b/src/pages/workspace/distanceRates/CategorySelector/index.tsx index f7a1ba49d91e..8628a4df0178 100644 --- a/src/pages/workspace/distanceRates/CategorySelector/index.tsx +++ b/src/pages/workspace/distanceRates/CategorySelector/index.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -21,20 +21,36 @@ type CategorySelectorProps = { /** Any additional styles to apply */ wrapperStyle: StyleProp; -}; -function CategorySelector({defaultValue = '', wrapperStyle, label, setNewCategory, policyID}: CategorySelectorProps) { - const styles = useThemeStyles(); + /** Whether item is focused or active */ + focused?: boolean; - const [isPickerVisible, setIsPickerVisible] = useState(false); + /** Whether category item picker is visible */ + isPickerVisible: boolean; - const showPickerModal = () => { - setIsPickerVisible(true); - }; + /** Callback to show category picker */ + showPickerModal: () => void; - const hidePickerModal = () => { - setIsPickerVisible(false); - }; + /** Callback to hide category picker */ + hidePickerModal: () => void; + + /** Whether SectionList should use custom ScrollView */ + shouldUseCustomScrollView?: boolean; +}; + +function CategorySelector({ + defaultValue = '', + wrapperStyle, + label, + setNewCategory, + policyID, + focused, + isPickerVisible, + showPickerModal, + hidePickerModal, + shouldUseCustomScrollView = false, +}: CategorySelectorProps) { + const styles = useThemeStyles(); const updateCategoryInput = (categoryItem: ListItem) => { setNewCategory(categoryItem); @@ -53,6 +69,7 @@ function CategorySelector({defaultValue = '', wrapperStyle, label, setNewCategor descriptionTextStyle={descStyle} onPress={showPickerModal} wrapperStyle={wrapperStyle} + focused={focused} /> ); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index 8ab9f87bfe56..53c1fe14237c 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -1,5 +1,5 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; @@ -44,6 +44,7 @@ type PolicyDistanceRatesSettingsPageProps = PolicyDistanceRatesSettingsPageOnyxP function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: PolicyDistanceRatesSettingsPageProps) { const styles = useThemeStyles(); + const [isCategoryPickerVisible, setIsCategoryPickerVisible] = useState(false); const {translate} = useLocalize(); const policyID = route.params.policyID; const customUnits = policy?.customUnits ?? {}; @@ -126,6 +127,9 @@ function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: Poli defaultValue={defaultCategory} wrapperStyle={[styles.ph5, styles.mt3]} setNewCategory={setNewCategory} + isPickerVisible={isCategoryPickerVisible} + showPickerModal={() => setIsCategoryPickerVisible(true)} + hidePickerModal={() => setIsCategoryPickerVisible(false)} /> )} diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 0260b3354f79..eab706ccc429 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1226,6 +1226,15 @@ type Connections = { /** Names of integration connections */ type ConnectionName = keyof Connections; +/** Merchant Category Code. This is a way to identify the type of merchant (and type of spend) when a credit card is swiped. */ +type MccGroup = { + /** Default category for provided MCC Group */ + category: string; + + /** ID of the Merchant Category Code */ + groupID: string; +}; + /** Model of verified reimbursement bank account linked to policy */ type ACHAccount = { /** ID of the bank account */ @@ -1561,6 +1570,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether GL codes are enabled */ glCodes?: boolean; + /** Policy MCC Group settings */ + mccGroup?: Record; + /** Workspace account ID configured for Expensify Card */ workspaceAccountID?: number; } & Partial,