diff --git a/src/CONST.ts b/src/CONST.ts index bdccecded23e..cf3facb0d1d8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2340,6 +2340,15 @@ const CONST = { DEFAULT_MAX_EXPENSE_AGE: 90, DEFAULT_MAX_EXPENSE_AMOUNT: 200000, DEFAULT_MAX_AMOUNT_NO_RECEIPT: 2500, + REQUIRE_RECEIPTS_OVER_OPTIONS: { + DEFAULT: 'default', + NEVER: 'never', + ALWAYS: 'always', + }, + EXPENSE_LIMIT_TYPES: { + EXPENSE: 'expense', + DAILY: 'daily', + }, }, CUSTOM_UNITS: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 45b9a8c68bbb..707c66b89c8c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -504,6 +504,10 @@ const ONYXKEYS = { WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', WORKSPACE_CATEGORY_FORM: 'workspaceCategoryForm', WORKSPACE_CATEGORY_FORM_DRAFT: 'workspaceCategoryFormDraft', + WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM: 'workspaceCategoryDescriptionHintForm', + WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM_DRAFT: 'workspaceCategoryDescriptionHintFormDraft', + WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM: 'workspaceCategoryFlagAmountsOverForm', + WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM_DRAFT: 'workspaceCategoryFlagAmountsOverFormDraft', WORKSPACE_TAG_FORM: 'workspaceTagForm', WORKSPACE_TAG_FORM_DRAFT: 'workspaceTagFormDraft', WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', @@ -677,6 +681,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; [ONYXKEYS.FORMS.WORKSPACE_COMPANY_CARD_FEED_NAME]: FormTypes.WorkspaceCompanyCardFeedName; [ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM]: FormTypes.WorkspaceReportFieldForm; + [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM]: FormTypes.WorkspaceCategoryDescriptionHintForm; + [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM]: FormTypes.WorkspaceCategoryFlagAmountsOverForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 61444f4bad33..f4eee3bb7971 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -790,6 +790,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/categories/:categoryName/gl-code', getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/gl-code` as const, }, + WORSKPACE_CATEGORY_DEFAULT_TAX_RATE: { + route: 'settings/workspaces/:policyID/categories/:categoryName/tax-rate', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/tax-rate` as const, + }, + WORSKPACE_CATEGORY_FLAG_AMOUNTS_OVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/flag-amounts', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/flag-amounts` as const, + }, + WORSKPACE_CATEGORY_DESCRIPTION_HINT: { + route: 'settings/workspaces/:policyID/categories/:categoryName/description-hint', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/description-hint` as const, + }, + WORSKPACE_CATEGORY_REQUIRE_RECEIPTS_OVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/require-receipts-over', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/require-receipts-over` as const, + }, + WORSKPACE_CATEGORY_APPROVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/approver', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/approver` as const, + }, WORKSPACE_MORE_FEATURES: { route: 'settings/workspaces/:policyID/more-features', getRoute: (policyID: string) => `settings/workspaces/${policyID}/more-features` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 0d44f2f4ea2e..a0db6a121c3c 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -437,6 +437,11 @@ const SCREENS = { CATEGORY_PAYROLL_CODE: 'Category_Payroll_Code', CATEGORY_GL_CODE: 'Category_GL_Code', CATEGORY_SETTINGS: 'Category_Settings', + CATEGORY_DEFAULT_TAX_RATE: 'Category_Default_Tax_Rate', + CATEGORY_FLAG_AMOUNTS_OVER: 'Category_Flag_Amounts_Over', + CATEGORY_DESCRIPTION_HINT: 'Category_Description_Hint', + CATEGORY_APPROVER: 'Category_Approver', + CATEGORY_REQUIRE_RECEIPTS_OVER: 'Category_Require_Receipts_Over', CATEGORIES_SETTINGS: 'Categories_Settings', CATEGORIES_IMPORT: 'Categories_Import', CATEGORIES_IMPORTED: 'Categories_Imported', diff --git a/src/components/WorkspaceMembersSelectionList.tsx b/src/components/WorkspaceMembersSelectionList.tsx new file mode 100644 index 000000000000..99f949903a2b --- /dev/null +++ b/src/components/WorkspaceMembersSelectionList.tsx @@ -0,0 +1,115 @@ +import React, {useMemo} from 'react'; +import type {SectionListData} from 'react-native'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; +import Badge from './Badge'; +import {FallbackAvatar} from './Icon/Expensicons'; +import {usePersonalDetails} from './OnyxProvider'; +import SelectionList from './SelectionList'; +import InviteMemberListItem from './SelectionList/InviteMemberListItem'; +import type {Section} from './SelectionList/types'; + +type SelectionListApprover = { + text: string; + alternateText: string; + keyForList: string; + isSelected: boolean; + login: string; + rightElement?: React.ReactNode; + icons: Icon[]; +}; +type ApproverSection = SectionListData>; + +type WorkspaceMembersSelectionListProps = { + policyID: string; + selectedApprover: string; + setApprover: (email: string) => void; +}; + +function WorkspaceMembersSelectionList({policyID, selectedApprover, setApprover}: WorkspaceMembersSelectionListProps) { + const {translate} = useLocalize(); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const personalDetails = usePersonalDetails(); + const policy = usePolicy(policyID); + + const sections: ApproverSection[] = useMemo(() => { + const approvers: SelectionListApprover[] = []; + + if (policy?.employeeList) { + const availableApprovers = Object.values(policy.employeeList) + .map((employee): SelectionListApprover | null => { + const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; + const email = employee.email; + + if (!email) { + return null; + } + + const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); + const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? ''); + const {avatar, displayName = email} = personalDetails?.[accountID] ?? {}; + + return { + text: displayName, + alternateText: email, + keyForList: email, + isSelected: selectedApprover === email, + login: email, + icons: [{source: avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: accountID}], + rightElement: isAdmin ? : undefined, + }; + }) + .filter((approver): approver is SelectionListApprover => !!approver); + + approvers.push(...availableApprovers); + } + + const filteredApprovers = + debouncedSearchTerm !== '' + ? approvers.filter((option) => { + const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(debouncedSearchTerm); + const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); + return isPartOfSearchTerm; + }) + : approvers; + + return [ + { + title: undefined, + data: OptionsListUtils.sortAlphabetically(filteredApprovers, 'text'), + shouldShow: true, + }, + ]; + }, [debouncedSearchTerm, personalDetails, policy?.employeeList, selectedApprover, translate]); + + const handleOnSelectRow = (approver: SelectionListApprover) => { + setApprover(approver.login); + }; + + const headerMessage = useMemo(() => (searchTerm && !sections[0].data.length ? translate('common.noResultsFound') : ''), [searchTerm, sections, translate]); + + return ( + + ); +} + +export default WorkspaceMembersSelectionList; diff --git a/src/languages/en.ts b/src/languages/en.ts index d53e750dbac1..63a7d36385e1 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3764,6 +3764,35 @@ export default { unlockFeatureGoToSubtitle: 'Go to', unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `and enable workflows, then add ${featureName} to unlock this feature.`, }, + categoryRules: { + title: 'Category rules', + approver: 'Approver', + requireDescription: 'Require description', + descriptionHint: 'Description hint', + descriptionHintDescription: (categoryName: string) => + `Remind employees to provide additional information for “${categoryName}” spend. This hint appears in the description field on expenses.`, + descriptionHintLabel: 'Hint', + descriptionHintSubtitle: 'Pro-tip: The shorter the better!', + maxAmount: 'Max amount', + flagAmountsOver: 'Flag amounts over', + flagAmountsOverDescription: (categoryName) => `Applies to the category “${categoryName}”.`, + flagAmountsOverSubtitle: 'This overrides the max amount for all expenses.', + expenseLimitTypes: { + expense: 'Individual expense', + expenseSubtitle: 'Flag expense amounts by category. This rule overrides the general workspace rule for max expense amount.', + daily: 'Category total', + dailySubtitle: 'Flag total category spend per expense report.', + }, + requireReceiptsOver: 'Require receipts over', + requireReceiptsOverList: { + default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Default`, + never: 'Never require receipts', + always: 'Always require receipts', + }, + defaultTaxRate: 'Default tax rate', + goTo: 'Go to', + andEnableWorkflows: 'and enable workflows, then add approvals to unlock this feature.', + }, }, }, getAssistancePage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 6f7e3b076f80..19384f1310d3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3814,6 +3814,35 @@ export default { unlockFeatureGoToSubtitle: 'Ir a', unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta función.`, }, + categoryRules: { + title: 'Reglas de categoría', + approver: 'Aprobador', + requireDescription: 'Requerir descripción', + descriptionHint: 'Sugerencia de descripción', + descriptionHintDescription: (categoryName: string) => + `Recuerda a los empleados que deben proporcionar información adicional para los gastos de “${categoryName}”. Esta sugerencia aparece en el campo de descripción en los gastos.`, + descriptionHintLabel: 'Sugerencia', + descriptionHintSubtitle: 'Consejo: ¡Cuanto más corta, mejor!', + maxAmount: 'Importe máximo', + flagAmountsOver: 'Señala importes superiores a', + flagAmountsOverDescription: (categoryName: string) => `Aplica a la categoría “${categoryName}”.`, + flagAmountsOverSubtitle: 'Esto anula el importe máximo para todos los gastos.', + expenseLimitTypes: { + expense: 'Gasto individual', + expenseSubtitle: 'Señala importes de gastos por categoría. Esta regla anula la regla general del espacio de trabajo para el importe máximo de gastos.', + daily: 'Total por categoría', + dailySubtitle: 'Marcar el gasto total por categoría en cada informe de gastos.', + }, + requireReceiptsOver: 'Requerir recibos para importes superiores a', + requireReceiptsOverList: { + default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Predeterminado`, + never: 'Nunca requerir recibos', + always: 'Requerir recibos siempre', + }, + defaultTaxRate: 'Tasa de impuesto predeterminada', + goTo: 'Ve a', + andEnableWorkflows: 'y habilita los flujos de trabajo, luego añade aprobaciones para desbloquear esta función.', + }, }, }, getAssistancePage: { diff --git a/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts b/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts new file mode 100644 index 000000000000..83e62db59811 --- /dev/null +++ b/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts @@ -0,0 +1,6 @@ +type RemovePolicyCategoryReceiptsRequiredParams = { + policyID: string; + categoryName: string; +}; + +export default RemovePolicyCategoryReceiptsRequiredParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts b/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts new file mode 100644 index 000000000000..197fdaf59df6 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryApproverParams = { + policyID: string; + categoryName: string; + approver: string; +}; + +export default SetPolicyCategoryApproverParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts b/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts new file mode 100644 index 000000000000..6a1748ff9ad1 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryDescriptionRequiredParams = { + policyID: string; + categoryName: string; + areCommentsRequired: boolean; +}; + +export default SetPolicyCategoryDescriptionRequiredParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts b/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts new file mode 100644 index 000000000000..6132f0a69b1b --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts @@ -0,0 +1,10 @@ +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; + +type SetPolicyCategoryMaxAmountParams = { + policyID: string; + categoryName: string; + maxExpenseAmount: number | null; + expenseLimitType: PolicyCategoryExpenseLimitType; +}; + +export default SetPolicyCategoryMaxAmountParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts b/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts new file mode 100644 index 000000000000..fe7c15bd8eff --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryReceiptsRequiredParams = { + policyID: string; + categoryName: string; + maxExpenseAmountNoReceipt: number; +}; + +export default SetPolicyCategoryReceiptsRequiredParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts b/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts new file mode 100644 index 000000000000..94a0a6025916 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryTaxParams = { + policyID: string; + categoryName: string; + taxID: string; +}; + +export default SetPolicyCategoryTaxParams; diff --git a/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts b/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts new file mode 100644 index 000000000000..d1c3b36975cb --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts @@ -0,0 +1,7 @@ +type SetWorkspaceCategoryDescriptionHintParams = { + policyID: string; + categoryName: string; + commentHint: string; +}; + +export default SetWorkspaceCategoryDescriptionHintParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 4cc90f8ae54a..cb18aae9b6a4 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -295,10 +295,17 @@ export type {default as ConfigureExpensifyCardsForPolicyParams} from './Configur export type {default as CreateExpensifyCardParams} from './CreateExpensifyCardParams'; export type {default as UpdateExpensifyCardTitleParams} from './UpdateExpensifyCardTitleParams'; export type {default as OpenCardDetailsPageParams} from './OpenCardDetailsPageParams'; +export type {default as SetPolicyCategoryDescriptionRequiredParams} from './SetPolicyCategoryDescriptionRequiredParams'; +export type {default as SetPolicyCategoryApproverParams} from './SetPolicyCategoryApproverParams'; +export type {default as SetWorkspaceCategoryDescriptionHintParams} from './SetWorkspaceCategoryDescriptionHintParams'; +export type {default as SetPolicyCategoryTaxParams} from './SetPolicyCategoryTaxParams'; +export type {default as SetPolicyCategoryMaxAmountParams} from './SetPolicyCategoryMaxAmountParams'; export type {default as EnablePolicyCompanyCardsParams} from './EnablePolicyCompanyCardsParams'; export type {default as ToggleCardContinuousReconciliationParams} from './ToggleCardContinuousReconciliationParams'; export type {default as CardDeactivateParams} from './CardDeactivateParams'; export type {default as UpdateExpensifyCardLimitTypeParams} from './UpdateExpensifyCardLimitTypeParams'; +export type {default as SetPolicyCategoryReceiptsRequiredParams} from './SetPolicyCategoryReceiptsRequiredParams'; +export type {default as RemovePolicyCategoryReceiptsRequiredParams} from './RemovePolicyCategoryReceiptsRequiredParams'; export type {default as UpdateQuickbooksOnlineAutoCreateVendorParams} from './UpdateQuickbooksOnlineAutoCreateVendorParams'; export type {default as ImportCategoriesSpreadsheetParams} from './ImportCategoriesSpreadsheet'; export type {default as UpdateXeroGenericTypeParams} from './UpdateXeroGenericTypeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 5be6cc08d816..12fddbf9e1e9 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -221,6 +221,13 @@ const WRITE_COMMANDS = { SET_POLICY_BILLABLE_MODE: ' SetPolicyBillableMode', DISABLE_POLICY_BILLABLE_MODE: 'DisablePolicyBillableExpenses', SET_WORKSPACE_ERECEIPTS_ENABLED: 'SetWorkspaceEReceiptsEnabled', + SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED: 'SetPolicyCategoryDescriptionRequired', + SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT: 'SetWorkspaceCategoryDescriptionHint', + SET_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'SetPolicyCategoryReceiptsRequired', + REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'RemoveWorkspaceCategoryReceiptsRequired', + SET_POLICY_CATEGORY_MAX_AMOUNT: 'SetPolicyCategoryMaxAmount', + SET_POLICY_CATEGORY_APPROVER: 'SetPolicyCategoryApprover', + SET_POLICY_CATEGORY_TAX: 'SetPolicyCategoryTax', SET_POLICY_TAXES_CURRENCY_DEFAULT: 'SetPolicyCurrencyDefaultTax', SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT: 'SetPolicyForeignCurrencyDefaultTax', SET_POLICY_CUSTOM_TAX_NAME: 'SetPolicyCustomTaxName', @@ -583,6 +590,13 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS]: Parameters.EnablePolicyCompanyCardsParams; [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; + [WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT]: Parameters.SetWorkspaceCategoryDescriptionHintParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.SetPolicyCategoryReceiptsRequiredParams; + [WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.RemovePolicyCategoryReceiptsRequiredParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_MAX_AMOUNT]: Parameters.SetPolicyCategoryMaxAmountParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER]: Parameters.SetPolicyCategoryApproverParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX]: Parameters.SetPolicyCategoryTaxParams; [WRITE_COMMANDS.JOIN_POLICY_VIA_INVITE_LINK]: Parameters.JoinPolicyInviteLinkParams; [WRITE_COMMANDS.ACCEPT_JOIN_REQUEST]: Parameters.AcceptJoinRequestParams; [WRITE_COMMANDS.DECLINE_JOIN_REQUEST]: Parameters.DeclineJoinRequestParams; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts new file mode 100644 index 000000000000..7f971f37d3fa --- /dev/null +++ b/src/libs/CategoryUtils.ts @@ -0,0 +1,62 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import type {Policy, TaxRate, TaxRatesWithDefault} from '@src/types/onyx'; +import type {ApprovalRule, ExpenseRule} from '@src/types/onyx/Policy'; +import * as CurrencyUtils from './CurrencyUtils'; + +function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], taxID: string, taxRate: TaxRate, policyTaxRates?: TaxRatesWithDefault) { + const taxRateText = `${taxRate.name} ${CONST.DOT_SEPARATOR} ${taxRate.value}`; + + if (!policyTaxRates) { + return taxRateText; + } + + const {defaultExternalID, foreignTaxDefault} = policyTaxRates; + let suffix; + + if (taxID === defaultExternalID && taxID === foreignTaxDefault) { + suffix = translate('common.default'); + } else if (taxID === defaultExternalID) { + suffix = translate('workspace.taxes.workspaceDefault'); + } else if (taxID === foreignTaxDefault) { + suffix = translate('workspace.taxes.foreignDefault'); + } + return `${taxRateText}${suffix ? ` ${CONST.DOT_SEPARATOR} ${suffix}` : ``}`; +} + +function formatRequireReceiptsOverText(translate: LocaleContextProps['translate'], policy: Policy, categoryMaxExpenseAmountNoReceipt?: number | null) { + const isAlwaysSelected = categoryMaxExpenseAmountNoReceipt === 0; + const isNeverSelected = categoryMaxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + + if (isAlwaysSelected) { + return translate(`workspace.rules.categoryRules.requireReceiptsOverList.always`); + } + + if (isNeverSelected) { + return translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`); + } + + const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount; + + return translate( + `workspace.rules.categoryRules.requireReceiptsOverList.default`, + CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD), + ); +} + +function getCategoryApprover(approvalRules: ApprovalRule[], categoryName: string) { + return approvalRules?.find((rule) => rule.applyWhen.some((when) => when.value === categoryName))?.approver; +} + +function getCategoryDefaultTaxRate(expenseRules: ExpenseRule[], categoryName: string, defaultTaxRate?: string) { + const categoryDefaultTaxRate = expenseRules?.find((rule) => rule.applyWhen.some((when) => when.value === categoryName))?.tax?.field_id_TAX?.externalID; + + // If the default taxRate is not found in expenseRules, use the default value for policy + if (!categoryDefaultTaxRate) { + return defaultTaxRate; + } + + return categoryDefaultTaxRate; +} + +export {formatDefaultTaxRateText, formatRequireReceiptsOverText, getCategoryApprover, getCategoryDefaultTaxRate}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 282bd760b6fd..a8c1c985ba29 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -247,6 +247,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/EditCategoryPage').default, [SCREENS.WORKSPACE.CATEGORY_PAYROLL_CODE]: () => require('../../../../pages/workspace/categories/CategoryPayrollCodePage').default, [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: () => require('../../../../pages/workspace/categories/CategoryGLCodePage').default, + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: () => require('../../../../pages/workspace/categories/CategoryDefaultTaxRatePage').default, + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: () => require('../../../../pages/workspace/categories/CategoryFlagAmountsOverPage').default, + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: () => require('../../../../pages/workspace/categories/CategoryDescriptionHintPage').default, + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: () => require('../../../../pages/workspace/categories/CategoryRequireReceiptsOverPage').default, + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: () => require('../../../../pages/workspace/categories/CategoryApproverPage').default, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require('../../../../pages/workspace/distanceRates/CreateDistanceRatePage').default, [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default, [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default, diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index ef634d9cb615..1b6e17eca61a 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -143,6 +143,11 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.CATEGORY_EDIT, SCREENS.WORKSPACE.CATEGORY_GL_CODE, SCREENS.WORKSPACE.CATEGORY_PAYROLL_CODE, + SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE, + SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER, + SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT, + SCREENS.WORKSPACE.CATEGORY_APPROVER, + SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER, ], [SCREENS.WORKSPACE.DISTANCE_RATES]: [ SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 64325904fdbd..03026379a3ee 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -608,6 +608,36 @@ const config: LinkingOptions['config'] = { categoryName: (categoryName: string) => decodeURIComponent(categoryName), }, }, + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: { + path: ROUTES.WORSKPACE_CATEGORY_DEFAULT_TAX_RATE.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: { + path: ROUTES.WORSKPACE_CATEGORY_FLAG_AMOUNTS_OVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: { + path: ROUTES.WORSKPACE_CATEGORY_DESCRIPTION_HINT.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: { + path: ROUTES.WORSKPACE_CATEGORY_APPROVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: { + path: ROUTES.WORSKPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { path: ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 44d1b6b7d152..c4e7492d7836 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -213,6 +213,26 @@ type SettingsNavigatorParamList = { policyID: string; categoryName: string; }; + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { policyID: string; categoryName: string; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 4be9c8d37d63..16d28481f06f 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1,9 +1,23 @@ +import lodashCloneDeep from 'lodash/cloneDeep'; import lodashUnion from 'lodash/union'; import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {EnablePolicyCategoriesParams, OpenPolicyCategoriesPageParams, SetPolicyDistanceRatesDefaultCategoryParams, UpdatePolicyCategoryGLCodeParams} from '@libs/API/parameters'; +import type { + EnablePolicyCategoriesParams, + OpenPolicyCategoriesPageParams, + RemovePolicyCategoryReceiptsRequiredParams, + SetPolicyCategoryApproverParams, + SetPolicyCategoryDescriptionRequiredParams, + SetPolicyCategoryMaxAmountParams, + SetPolicyCategoryReceiptsRequiredParams, + SetPolicyCategoryTaxParams, + SetPolicyDistanceRatesDefaultCategoryParams, + SetWorkspaceCategoryDescriptionHintParams, + UpdatePolicyCategoryGLCodeParams, +} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import {translateLocal} from '@libs/Localize'; @@ -14,7 +28,8 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, RecentlyUsedCategories, Report} from '@src/types/onyx'; -import type {CustomUnit} from '@src/types/onyx/Policy'; +import type {ApprovalRule, CustomUnit, ExpenseRule} from '@src/types/onyx/Policy'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; const allPolicies: OnyxCollection = {}; @@ -287,6 +302,196 @@ function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Recor API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED, parameters, onyxData); } +function setPolicyCategoryDescriptionRequired(policyID: string, categoryName: string, areCommentsRequired: boolean) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + const originalAreCommentsRequired = policyCategoryToUpdate?.areCommentsRequired; + const originalCommentHint = policyCategoryToUpdate?.commentHint; + + // When areCommentsRequired is set to false, commentHint has to be reset + const updatedCommentHint = areCommentsRequired ? allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.commentHint : ''; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + areCommentsRequired: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + areCommentsRequired, + commentHint: updatedCommentHint, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + areCommentsRequired: null, + }, + areCommentsRequired, + commentHint: updatedCommentHint, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + areCommentsRequired: null, + }, + areCommentsRequired: originalAreCommentsRequired, + commentHint: originalCommentHint, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryDescriptionRequiredParams = { + policyID, + categoryName, + areCommentsRequired, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED, parameters, onyxData); +} + +function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: string, maxExpenseAmountNoReceipt: number) { + const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmountNoReceipt, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryReceiptsRequiredParams = { + policyID, + categoryName, + maxExpenseAmountNoReceipt, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); +} + +function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: string) { + const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmountNoReceipt: null, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + }, + }, + }, + ], + }; + + const parameters: RemovePolicyCategoryReceiptsRequiredParams = { + policyID, + categoryName, + }; + + API.write(WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); +} + function createPolicyCategory(policyID: string, categoryName: string) { const onyxData = buildOptimisticPolicyCategories(policyID, [categoryName]); @@ -793,10 +998,303 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); } +function setWorkspaceCategoryDescriptionHint(policyID: string, categoryName: string, commentHint: string) { + const originalCommentHint = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.commentHint; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + commentHint: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + commentHint, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + commentHint: null, + }, + commentHint, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + commentHint: null, + }, + commentHint: originalCommentHint, + }, + }, + }, + ], + }; + + const parameters: SetWorkspaceCategoryDescriptionHintParams = { + policyID, + categoryName, + commentHint, + }; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT, parameters, onyxData); +} + +function setPolicyCategoryMaxAmount(policyID: string, categoryName: string, maxExpenseAmount: string, expenseLimitType: PolicyCategoryExpenseLimitType) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + const originalMaxExpenseAmount = policyCategoryToUpdate?.maxExpenseAmount; + const originalExpenseLimitType = policyCategoryToUpdate?.expenseLimitType; + const parsedMaxExpenseAmount = maxExpenseAmount === '' ? null : CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAmount)); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + expenseLimitType: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmount: null, + expenseLimitType: null, + }, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmount: null, + expenseLimitType: null, + }, + maxExpenseAmount: originalMaxExpenseAmount, + expenseLimitType: originalExpenseLimitType, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryMaxAmountParams = { + policyID, + categoryName, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_MAX_AMOUNT, parameters, onyxData); +} + +function setPolicyCategoryApprover(policyID: string, categoryName: string, approver: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const approvalRules = policy?.rules?.approvalRules ?? []; + let updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); + const existingCategoryApproverRule = updatedApprovalRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + let newApprover = approver; + + if (!existingCategoryApproverRule) { + updatedApprovalRules.push({ + approver, + applyWhen: [ + { + condition: 'matches', + field: 'category', + value: categoryName, + }, + ], + }); + } else if (existingCategoryApproverRule?.approver === approver) { + updatedApprovalRules = updatedApprovalRules.filter((rule) => rule.approver !== approver); + newApprover = ''; + } else { + const indexToUpdate = updatedApprovalRules.indexOf(existingCategoryApproverRule); + updatedApprovalRules[indexToUpdate].approver = approver; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: updatedApprovalRules, + pendingFields: { + approvalRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + pendingFields: { + approvalRules: null, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules, + pendingFields: { + approvalRules: null, + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryApproverParams = { + policyID, + categoryName, + approver: newApprover, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER, parameters, onyxData); +} + +function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const expenseRules = policy?.rules?.expenseRules ?? []; + const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); + const existingCategoryExpenseRule = updatedExpenseRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + + if (!existingCategoryExpenseRule) { + updatedExpenseRules.push({ + tax: { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxID, + }, + }, + applyWhen: [ + { + condition: 'matches', + field: 'category', + value: categoryName, + }, + ], + }); + } else { + const indexToUpdate = updatedExpenseRules.indexOf(existingCategoryExpenseRule); + updatedExpenseRules[indexToUpdate].tax.field_id_TAX.externalID = taxID; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules: updatedExpenseRules, + pendingFields: { + expenseRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + pendingFields: { + expenseRules: null, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules, + pendingFields: { + expenseRules: null, + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryTaxParams = { + policyID, + categoryName, + taxID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); +} + export { openPolicyCategoriesPage, buildOptimisticPolicyRecentlyUsedCategories, setWorkspaceCategoryEnabled, + setPolicyCategoryDescriptionRequired, + setWorkspaceCategoryDescriptionHint, setWorkspaceRequiresCategory, setPolicyCategoryPayrollCode, createPolicyCategory, @@ -807,5 +1305,10 @@ export { setPolicyDistanceRatesDefaultCategory, deleteWorkspaceCategories, buildOptimisticPolicyCategories, + setPolicyCategoryReceiptsRequired, + removePolicyCategoryReceiptsRequired, + setPolicyCategoryMaxAmount, + setPolicyCategoryApprover, + setPolicyCategoryTax, importPolicyCategories, }; diff --git a/src/pages/workspace/categories/CategoryApproverPage.tsx b/src/pages/workspace/categories/CategoryApproverPage.tsx new file mode 100644 index 000000000000..390a577d9cf8 --- /dev/null +++ b/src/pages/workspace/categories/CategoryApproverPage.tsx @@ -0,0 +1,62 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import WorkspaceMembersSelectionList from '@components/WorkspaceMembersSelectionList'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CategoryUtils from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type EditCategoryPageProps = StackScreenProps; + +function CategoryApproverPage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + + const selectedApprover = CategoryUtils.getCategoryApprover(policy?.rules?.approvalRules ?? [], categoryName) ?? ''; + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + { + Category.setPolicyCategoryApprover(policyID, categoryName, email); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + /> + + + ); +} + +CategoryApproverPage.displayName = 'CategoryApproverPage'; + +export default CategoryApproverPage; diff --git a/src/pages/workspace/categories/CategoryDefaultTaxRatePage.tsx b/src/pages/workspace/categories/CategoryDefaultTaxRatePage.tsx new file mode 100644 index 000000000000..16ea5b9bd2a7 --- /dev/null +++ b/src/pages/workspace/categories/CategoryDefaultTaxRatePage.tsx @@ -0,0 +1,97 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CategoryUtils from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {TaxRate} from '@src/types/onyx'; + +type EditCategoryPageProps = StackScreenProps; + +function CategoryDefaultTaxRatePage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + + const selectedTaxRate = CategoryUtils.getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); + + const textForDefault = useCallback( + (taxID: string, taxRate: TaxRate) => CategoryUtils.formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates), + [policy?.taxRates, translate], + ); + + const taxesList = useMemo(() => { + if (!policy) { + return []; + } + return Object.entries(policy.taxRates?.taxes ?? {}) + .map(([key, value]) => ({ + text: textForDefault(key, value), + keyForList: key, + isSelected: key === selectedTaxRate, + isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), + })) + .sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? '')); + }, [policy, selectedTaxRate, textForDefault]); + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + { + if (!item.keyForList) { + return; + } + + if (item.keyForList === selectedTaxRate) { + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName)); + return; + } + + Category.setPolicyCategoryTax(policyID, categoryName, item.keyForList); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + shouldSingleExecuteRowSelect + containerStyle={[styles.pt3]} + initiallyFocusedOptionKey={selectedTaxRate} + /> + + + ); +} + +CategoryDefaultTaxRatePage.displayName = 'CategoryDefaultTaxRatePage'; + +export default CategoryDefaultTaxRatePage; diff --git a/src/pages/workspace/categories/CategoryDescriptionHintPage.tsx b/src/pages/workspace/categories/CategoryDescriptionHintPage.tsx new file mode 100644 index 000000000000..7589c9b19881 --- /dev/null +++ b/src/pages/workspace/categories/CategoryDescriptionHintPage.tsx @@ -0,0 +1,85 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspaceCategoryDescriptionHintForm'; + +type EditCategoryPageProps = StackScreenProps; + +function CategoryDescriptionHintPage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + + const {inputCallbackRef} = useAutoFocusInput(); + + const commentHintDefaultValue = policyCategories?.[categoryName]?.commentHint; + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + { + Category.setWorkspaceCategoryDescriptionHint(policyID, categoryName, commentHint); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + submitButtonText={translate('common.save')} + enabledWhenOffline + > + + {translate('workspace.rules.categoryRules.descriptionHintDescription', categoryName)} + + {translate('workspace.rules.categoryRules.descriptionHintSubtitle')} + + + + + ); +} + +CategoryDescriptionHintPage.displayName = 'CategoryDescriptionHintPage'; + +export default CategoryDescriptionHintPage; diff --git a/src/pages/workspace/categories/CategoryFlagAmountsOverPage.tsx b/src/pages/workspace/categories/CategoryFlagAmountsOverPage.tsx new file mode 100644 index 000000000000..1db409c9aaef --- /dev/null +++ b/src/pages/workspace/categories/CategoryFlagAmountsOverPage.tsx @@ -0,0 +1,105 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspaceCategoryFlagAmountsOverForm'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; +import ExpenseLimitTypeSelector from './ExpenseLimitTypeSelector/ExpenseLimitTypeSelector'; + +type EditCategoryPageProps = StackScreenProps; + +function CategoryFlagAmountsOverPage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const policy = usePolicy(policyID); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [expenseLimitType, setExpenseLimitType] = useState(policyCategories?.[categoryName]?.expenseLimitType ?? CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE); + + const {inputCallbackRef} = useAutoFocusInput(); + + const policyCategoryMaxExpenseAmount = policyCategories?.[categoryName]?.maxExpenseAmount; + + const defaultValue = + policyCategoryMaxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policyCategoryMaxExpenseAmount + ? '' + : CurrencyUtils.convertToFrontendAmountAsString(policyCategoryMaxExpenseAmount, policy?.outputCurrency); + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + { + Category.setPolicyCategoryMaxAmount(policyID, categoryName, maxExpenseAmount, expenseLimitType); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + submitButtonText={translate('workspace.editor.save')} + enabledWhenOffline + submitButtonStyles={styles.ph5} + > + + {translate('workspace.rules.categoryRules.flagAmountsOverDescription', categoryName)} + + {translate('workspace.rules.categoryRules.flagAmountsOverSubtitle')} + + + + + + ); +} + +CategoryFlagAmountsOverPage.displayName = 'CategoryFlagAmountsOverPage'; + +export default CategoryFlagAmountsOverPage; diff --git a/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx b/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx new file mode 100644 index 000000000000..fa6b6555ec53 --- /dev/null +++ b/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx @@ -0,0 +1,114 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type EditCategoryPageProps = StackScreenProps; + +function getInitiallyFocusedOptionKey(isAlwaysSelected: boolean, isNeverSelected: boolean): ValueOf { + if (isAlwaysSelected) { + return CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.ALWAYS; + } + + if (isNeverSelected) { + return CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.NEVER; + } + + return CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.DEFAULT; +} + +function CategoryRequireReceiptsOverPage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + + const isAlwaysSelected = policyCategories?.[categoryName]?.maxExpenseAmountNoReceipt === 0; + const isNeverSelected = policyCategories?.[categoryName]?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount; + + const requireReceiptsOverListData = [ + { + value: null, + text: translate( + `workspace.rules.categoryRules.requireReceiptsOverList.default`, + CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD), + ), + keyForList: CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.DEFAULT, + isSelected: !isAlwaysSelected && !isNeverSelected, + }, + { + value: CONST.DISABLED_MAX_EXPENSE_VALUE, + text: translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`), + keyForList: CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.NEVER, + isSelected: isNeverSelected, + }, + { + value: 0, + text: translate(`workspace.rules.categoryRules.requireReceiptsOverList.always`), + keyForList: CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.ALWAYS, + isSelected: isAlwaysSelected, + }, + ]; + + const initiallyFocusedOptionKey = getInitiallyFocusedOptionKey(isAlwaysSelected, isNeverSelected); + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + { + if (typeof item.value === 'number') { + Category.setPolicyCategoryReceiptsRequired(policyID, categoryName, item.value); + } else { + Category.removePolicyCategoryReceiptsRequired(policyID, categoryName); + } + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + shouldSingleExecuteRowSelect + containerStyle={[styles.pt3]} + initiallyFocusedOptionKey={initiallyFocusedOptionKey} + /> + + + ); +} + +CategoryRequireReceiptsOverPage.displayName = 'CategoryRequireReceiptsOverPage'; + +export default CategoryRequireReceiptsOverPage; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index af8b62a5a061..0cac6b8a7bda 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -1,8 +1,8 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -10,18 +10,21 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; import Switch from '@components/Switch'; import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as CategoryUtils from '@libs/CategoryUtils'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy} from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import {setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; import * as Category from '@userActions/Policy/Category'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,49 +39,95 @@ type CategorySettingsPageOnyxProps = { type CategorySettingsPageProps = CategorySettingsPageOnyxProps & StackScreenProps; -function CategorySettingsPage({route, policyCategories, navigation}: CategorySettingsPageProps) { +function CategorySettingsPage({ + route: { + params: {backTo, policyID, categoryName}, + }, + policyCategories, + navigation, +}: CategorySettingsPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [deleteCategoryConfirmModalVisible, setDeleteCategoryConfirmModalVisible] = useState(false); - const backTo = route.params?.backTo; - const policy = usePolicy(route.params.policyID); + const policy = usePolicy(policyID); + + const policyCategory = policyCategories?.[categoryName] ?? Object.values(policyCategories ?? {}).find((category) => category.previousCategoryName === categoryName); + const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + const policyCategoryExpenseLimitType = policyCategory?.expenseLimitType ?? CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE; - const policyCategory = - policyCategories?.[route.params.categoryName] ?? Object.values(policyCategories ?? {}).find((category) => category.previousCategoryName === route.params.categoryName); + const areCommentsRequired = policyCategory?.areCommentsRequired ?? false; const navigateBack = () => { if (backTo) { - Navigation.goBack(ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(route.params.policyID, backTo)); + Navigation.goBack(ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(policyID, backTo)); return; } Navigation.goBack(); }; useEffect(() => { - if (policyCategory?.name === route.params.categoryName || !policyCategory) { + if (policyCategory?.name === categoryName || !policyCategory) { return; } navigation.setParams({categoryName: policyCategory?.name}); - }, [route.params.categoryName, navigation, policyCategory]); + }, [categoryName, navigation, policyCategory]); + + const flagAmountsOverText = useMemo(() => { + if (policyCategory?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policyCategory?.maxExpenseAmount) { + return ''; + } + + return `${CurrencyUtils.convertToDisplayString(policyCategory?.maxExpenseAmount, policyCurrency)} ${CONST.DOT_SEPARATOR} ${translate( + `workspace.rules.categoryRules.expenseLimitTypes.${policyCategoryExpenseLimitType}`, + )}`; + }, [policyCategory?.maxExpenseAmount, policyCategoryExpenseLimitType, policyCurrency, translate]); + + const approverText = useMemo(() => { + const categoryApprover = CategoryUtils.getCategoryApprover(policy?.rules?.approvalRules ?? [], categoryName); + return categoryApprover ?? ''; + }, [categoryName, policy?.rules?.approvalRules]); + + const defaultTaxRateText = useMemo(() => { + const taxID = CategoryUtils.getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); + + if (!taxID) { + return ''; + } + + const taxRate = policy?.taxRates?.taxes[taxID]; + + if (!taxRate) { + return ''; + } + + return CategoryUtils.formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates); + }, [categoryName, policy?.rules?.expenseRules, policy?.taxRates, translate]); + + const requireReceiptsOverText = useMemo(() => { + if (!policy) { + return ''; + } + return CategoryUtils.formatRequireReceiptsOverText(translate, policy, policyCategory?.maxExpenseAmountNoReceipt); + }, [policy, policyCategory?.maxExpenseAmountNoReceipt, translate]); if (!policyCategory) { return ; } const updateWorkspaceRequiresCategory = (value: boolean) => { - setWorkspaceCategoryEnabled(route.params.policyID, {[policyCategory.name]: {name: policyCategory.name, enabled: value}}); + Category.setWorkspaceCategoryEnabled(policyID, {[policyCategory.name]: {name: policyCategory.name, enabled: value}}); }; const navigateToEditCategory = () => { if (backTo) { - Navigation.navigate(ROUTES.SETTINGS_CATEGORY_EDIT.getRoute(route.params.policyID, policyCategory.name, backTo)); + Navigation.navigate(ROUTES.SETTINGS_CATEGORY_EDIT.getRoute(policyID, policyCategory.name, backTo)); return; } - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_EDIT.getRoute(route.params.policyID, policyCategory.name)); + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_EDIT.getRoute(policyID, policyCategory.name)); }; const deleteCategory = () => { - Category.deleteWorkspaceCategories(route.params.policyID, [route.params.categoryName]); + Category.deleteWorkspaceCategories(policyID, [categoryName]); setDeleteCategoryConfirmModalVisible(false); navigateBack(); }; @@ -88,7 +137,7 @@ function CategorySettingsPage({route, policyCategories, navigation}: CategorySet return ( - - setDeleteCategoryConfirmModalVisible(false)} - title={translate('workspace.categories.deleteCategory')} - prompt={translate('workspace.categories.deleteCategoryPrompt')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - - Category.clearCategoryErrors(route.params.policyID, route.params.categoryName)} - > - - - {translate('workspace.categories.enableCategory')} - - - - - - ( + <> + - - - { - if (!isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - route.params.policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, - ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(route.params.policyID, policyCategory.name), - ), - ); - return; - } - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(route.params.policyID, policyCategory.name)); - }} - shouldShowRightIcon + setDeleteCategoryConfirmModalVisible(false)} + title={translate('workspace.categories.deleteCategory')} + prompt={translate('workspace.categories.deleteCategoryPrompt')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger /> - - - { - if (!isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - route.params.policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, - ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(route.params.policyID, policyCategory.name), - ), - ); - return; - } - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(route.params.policyID, policyCategory.name)); - }} - shouldShowRightIcon - /> - - {!isThereAnyAccountingConnection && ( - setDeleteCategoryConfirmModalVisible(true)} - /> - )} - + + Category.clearCategoryErrors(policyID, categoryName)} + > + + + {translate('workspace.categories.enableCategory')} + + + + + + + + + { + if (!isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, + ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name), + ), + ); + return; + } + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + + + { + if (!isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, + ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(policyID, policyCategory.name), + ), + ); + return; + } + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + + + {policy?.areRulesEnabled && ( + <> + + {translate('workspace.rules.categoryRules.title')} + + + + + {translate('workspace.rules.categoryRules.requireDescription')} + Category.setPolicyCategoryDescriptionRequired(policyID, categoryName, !areCommentsRequired)} + /> + + + + {policyCategory?.areCommentsRequired && ( + + { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_DESCRIPTION_HINT.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + + )} + + { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_APPROVER.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + disabled={!policy?.areWorkflowsEnabled} + /> + + {!policy?.areWorkflowsEnabled && ( + + {translate('workspace.rules.categoryRules.goTo')}{' '} + Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID))} + > + {translate('workspace.common.moreFeatures')} + {' '} + {translate('workspace.rules.categoryRules.andEnableWorkflows')} + + )} + {policy?.tax?.trackingEnabled && ( + + { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_DEFAULT_TAX_RATE.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + + )} + + + { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_FLAG_AMOUNTS_OVER.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + + + { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + + + )} + + {!isThereAnyAccountingConnection && ( + setDeleteCategoryConfirmModalVisible(true)} + /> + )} + + + )} ); diff --git a/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelector.tsx b/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelector.tsx new file mode 100644 index 000000000000..e6c30a4913e1 --- /dev/null +++ b/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelector.tsx @@ -0,0 +1,69 @@ +import React, {useState} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; +import ExpenseLimitTypeSelectorModal from './ExpenseLimitTypeSelectorModal'; + +type ExpenseLimitTypeSelectorProps = { + /** Function to call when the user selects an expense limit type */ + setNewExpenseLimitType: (value: PolicyCategoryExpenseLimitType) => void; + + /** Currently selected expense limit type */ + defaultValue: PolicyCategoryExpenseLimitType; + + /** Label to display on field */ + label: string; + + /** Any additional styles to apply */ + wrapperStyle: StyleProp; +}; + +function ExpenseLimitTypeSelector({defaultValue, wrapperStyle, label, setNewExpenseLimitType}: ExpenseLimitTypeSelectorProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [isPickerVisible, setIsPickerVisible] = useState(false); + + const showPickerModal = () => { + setIsPickerVisible(true); + }; + + const hidePickerModal = () => { + setIsPickerVisible(false); + }; + + const updateExpenseLimitTypeInput = (expenseLimitType: PolicyCategoryExpenseLimitType) => { + setNewExpenseLimitType(expenseLimitType); + hidePickerModal(); + }; + + const title = translate(`workspace.rules.categoryRules.expenseLimitTypes.${defaultValue}`); + const descStyle = !title ? styles.textNormal : null; + + return ( + + + + + ); +} + +ExpenseLimitTypeSelector.displayName = 'ExpenseLimitTypeSelector'; + +export default ExpenseLimitTypeSelector; diff --git a/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelectorModal.tsx b/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelectorModal.tsx new file mode 100644 index 000000000000..34bd47c484ba --- /dev/null +++ b/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelectorModal.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Modal from '@components/Modal'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; + +type ExpenseLimitTypeSelectorModalProps = { + /** Whether the modal is visible */ + isVisible: boolean; + + /** Selected expense limit type */ + currentExpenseLimitType: PolicyCategoryExpenseLimitType; + + /** Function to call when the user selects an expense limit type */ + onExpenseLimitTypeSelected: (value: PolicyCategoryExpenseLimitType) => void; + + /** Function to call when the user closes the expense limit type selector modal */ + onClose: () => void; + + /** Label to display on field */ + label: string; +}; + +function ExpenseLimitTypeSelectorModal({isVisible, currentExpenseLimitType, onExpenseLimitTypeSelected, onClose, label}: ExpenseLimitTypeSelectorModalProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const expenseLimitTypes = Object.values(CONST.POLICY.EXPENSE_LIMIT_TYPES).map((value) => ({ + value, + text: translate(`workspace.rules.categoryRules.expenseLimitTypes.${value}`), + alternateText: translate(`workspace.rules.categoryRules.expenseLimitTypes.${value}Subtitle`), + keyForList: value, + isSelected: currentExpenseLimitType === value, + })); + + return ( + + + + onExpenseLimitTypeSelected(item.value)} + shouldSingleExecuteRowSelect + containerStyle={[styles.pt3]} + initiallyFocusedOptionKey={currentExpenseLimitType} + /> + + + ); +} + +ExpenseLimitTypeSelectorModal.displayName = 'ExpenseLimitTypeSelectorModal'; + +export default ExpenseLimitTypeSelectorModal; diff --git a/src/types/form/WorkspaceCategoryDescriptionHintForm.ts b/src/types/form/WorkspaceCategoryDescriptionHintForm.ts new file mode 100644 index 000000000000..4c96f080eac5 --- /dev/null +++ b/src/types/form/WorkspaceCategoryDescriptionHintForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + COMMENT_HINT: 'commentHint', +} as const; + +type InputID = ValueOf; + +type WorkspaceCategoryDescriptionHintForm = Form< + InputID, + { + [INPUT_IDS.COMMENT_HINT]: string; + } +>; + +export type {WorkspaceCategoryDescriptionHintForm}; +export default INPUT_IDS; diff --git a/src/types/form/WorkspaceCategoryFlagAmountsOverForm.ts b/src/types/form/WorkspaceCategoryFlagAmountsOverForm.ts new file mode 100644 index 000000000000..e5418cdb8da1 --- /dev/null +++ b/src/types/form/WorkspaceCategoryFlagAmountsOverForm.ts @@ -0,0 +1,20 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + MAX_EXPENSE_AMOUNT: 'maxExpenseAmount', + EXPENSE_LIMIT_TYPE: 'expenseLimitType', +} as const; + +type InputID = ValueOf; + +type WorkspaceCategoryFlagAmountsOverForm = Form< + InputID, + { + [INPUT_IDS.MAX_EXPENSE_AMOUNT]: string; + [INPUT_IDS.EXPENSE_LIMIT_TYPE]: string; + } +>; + +export type {WorkspaceCategoryFlagAmountsOverForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 23903526c89d..618419258d8c 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -69,13 +69,15 @@ export type {NetSuiteTokenInputForm} from './NetSuiteTokenInputForm'; export type {NetSuiteCustomFormIDForm} from './NetSuiteCustomFormIDForm'; export type {SearchAdvancedFiltersForm} from './SearchAdvancedFiltersForm'; export type {EditExpensifyCardLimitForm} from './EditExpensifyCardLimitForm'; +export type {default as TextPickerModalForm} from './TextPickerModalForm'; +export type {default as Form} from './Form'; export type {RulesCustomNameModalForm} from './RulesCustomNameModalForm'; export type {RulesAutoApproveReportsUnderModalForm} from './RulesAutoApproveReportsUnderModalForm'; export type {RulesRandomReportAuditModalForm} from './RulesRandomReportAuditModalForm'; export type {RulesAutoPayReportsUnderModalForm} from './RulesAutoPayReportsUnderModalForm'; -export type {default as TextPickerModalForm} from './TextPickerModalForm'; -export type {default as Form} from './Form'; export type {RulesRequiredReceiptAmountForm} from './RulesRequiredReceiptAmountForm'; export type {RulesMaxExpenseAmountForm} from './RulesMaxExpenseAmountForm'; export type {RulesMaxExpenseAgeForm} from './RulesMaxExpenseAgeForm'; +export type {WorkspaceCategoryDescriptionHintForm} from './WorkspaceCategoryDescriptionHintForm'; +export type {WorkspaceCategoryFlagAmountsOverForm} from './WorkspaceCategoryFlagAmountsOverForm'; export type {WorkspaceCompanyCardFeedName} from './WorkspaceCompanyCardFeedName'; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 139f3782c117..f2604d723f05 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1370,6 +1370,48 @@ type PendingJoinRequestPolicy = { >; }; +/** Data informing when a given rule should be applied */ +type ApplyRulesWhen = { + /** The condition for applying the rule to the workspace */ + condition: 'matches'; + + /** The target field to which the rule is applied */ + field: 'category'; + + /** The value of the target field */ + value: string; +}; + +/** Approval rule data model */ +type ApprovalRule = { + /** The approver's email */ + approver: string; + + /** Set of conditions under which the approval rule should be applied */ + applyWhen: ApplyRulesWhen[]; + + /** An id of the rule */ + id?: string; +}; + +/** Expense rule data model */ +type ExpenseRule = { + /** Object containing information about the tax field id and its external identifier */ + tax: { + /** Object wrapping the external tax id */ + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + /** The external id of the tax field. */ + externalID: string; + }; + }; + /** Set of conditions under which the expense rule should be applied */ + applyWhen: ApplyRulesWhen[]; + + /** An id of the rule */ + id?: string; +}; + /** Model of policy data */ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< { @@ -1543,6 +1585,15 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Collection of tax rates attached to a policy */ taxRates?: TaxRatesWithDefault; + /** A set of rules related to the workpsace */ + rules?: OnyxCommon.OnyxValueWithOfflineFeedback<{ + /** A set of rules related to the workpsace approvals */ + approvalRules?: ApprovalRule[]; + + /** A set of rules related to the workpsace expenses */ + expenseRules?: ExpenseRule[]; + }>; + /** ReportID of the admins room for this workspace */ chatReportIDAdmins?: number; @@ -1706,5 +1757,7 @@ export type { SageIntacctConnectionsConfig, SageIntacctExportConfig, ACHAccount, + ApprovalRule, + ExpenseRule, NetSuiteConnectionConfig, }; diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts index 18a714208819..ed2638f95491 100644 --- a/src/types/onyx/PolicyCategory.ts +++ b/src/types/onyx/PolicyCategory.ts @@ -1,5 +1,8 @@ import type * as OnyxCommon from './OnyxCommon'; +/** The type of policy category expense limit */ +type PolicyCategoryExpenseLimitType = 'expense' | 'daily'; + /** Model of policy category */ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Name of a category */ @@ -33,9 +36,21 @@ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** A list of errors keyed by microtime */ errors?: OnyxCommon.Errors | null; + + /** A description hint related to the category */ + commentHint?: string; + + /** Maximum amount allowed for an expense in this category */ + maxExpenseAmount?: number; + + /** The type of expense limit associated with this category */ + expenseLimitType?: PolicyCategoryExpenseLimitType; + + /** Max expense amount with no receipt violation */ + maxExpenseAmountNoReceipt?: number | null; }>; /** Record of policy categories, indexed by their name */ type PolicyCategories = Record; -export type {PolicyCategory, PolicyCategories}; +export type {PolicyCategory, PolicyCategories, PolicyCategoryExpenseLimitType};