diff --git a/src/CONST.ts b/src/CONST.ts index 93d2921e704f..732efd4d35b6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -87,6 +87,10 @@ const CONST = { DEFAULT_ONYX_DUMP_FILE_NAME: 'onyx-state.txt', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], DISABLED_MAX_EXPENSE_VALUE: 10000000000, + POLICY_BILLABLE_MODES: { + BILLABLE: 'billable', + NON_BILLABLE: 'nonBillable', + }, // Note: Group and Self-DM excluded as these are not tied to a Workspace WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT], @@ -593,6 +597,7 @@ const CONST = { CONCIERGE_ICON_URL: `${CLOUDFRONT_URL}/images/icons/concierge_2022.png`, UPWORK_URL: 'https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22', DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works', + DEEP_DIVE_ERECEIPTS: 'https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts/', GITHUB_URL: 'https://github.com/Expensify/App', TERMS_URL: `${USE_EXPENSIFY_URL}/terms`, PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b7b6cf53a176..48288e29095f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -623,6 +623,12 @@ const ONYXKEYS = { SEARCH_ADVANCED_FILTERS_FORM_DRAFT: 'searchAdvancedFiltersFormDraft', TEXT_PICKER_MODAL_FORM: 'textPickerModalForm', TEXT_PICKER_MODAL_FORM_DRAFT: 'textPickerModalFormDraft', + RULES_REQUIRED_RECEIPT_AMOUNT_FORM: 'rulesRequiredReceiptAmountForm', + RULES_REQUIRED_RECEIPT_AMOUNT_FORM_DRAFT: 'rulesRequiredReceiptAmountFormDraft', + RULES_MAX_EXPENSE_AMOUNT_FORM: 'rulesMaxExpenseAmountForm', + RULES_MAX_EXPENSE_AMOUNT_FORM_DRAFT: 'rulesMaxExpenseAmountFormDraft', + RULES_MAX_EXPENSE_AGE_FORM: 'rulesMaxExpenseAgeForm', + RULES_MAX_EXPENSE_AGE_FORM_DRAFT: 'rulesMaxExpenseAgeFormDraft', }, } as const; @@ -702,6 +708,9 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SAGE_INTACCT_DIMENSION_TYPE_FORM]: FormTypes.SageIntacctDimensionForm; [ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM]: FormTypes.SearchAdvancedFiltersForm; [ONYXKEYS.FORMS.TEXT_PICKER_MODAL_FORM]: FormTypes.TextPickerModalForm; + [ONYXKEYS.FORMS.RULES_REQUIRED_RECEIPT_AMOUNT_FORM]: FormTypes.RulesRequiredReceiptAmountForm; + [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm; + [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; }; type OnyxFormDraftValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 73271d85ea49..0f61001574db 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -960,6 +960,22 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, }, + RULES_RECEIPT_REQUIRED_AMOUNT: { + route: 'settings/workspaces/:policyID/rules/receipt-required-amount', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/receipt-required-amount` as const, + }, + RULES_MAX_EXPENSE_AMOUNT: { + route: 'settings/workspaces/:policyID/rules/max-expense-amount', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/max-expense-amount` as const, + }, + RULES_MAX_EXPENSE_AGE: { + route: 'settings/workspaces/:policyID/rules/max-expense-age', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/max-expense-age` as const, + }, + RULES_BILLABLE_DEFAULT: { + route: 'settings/workspaces/:policyID/rules/billable', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/billable` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 142b2f80a66e..e53ebac63ae7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -442,6 +442,10 @@ const SCREENS = { DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', UPGRADE: 'Workspace_Upgrade', RULES: 'Policy_Rules', + RULES_RECEIPT_REQUIRED_AMOUNT: 'Rules_Receipt_Required_Amount', + RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount', + RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', + RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default', }, EDIT_REQUEST: { diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 1eb272dce49a..8ad01d4437ae 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -1,7 +1,7 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; @@ -12,6 +12,7 @@ import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import BigNumberPad from './BigNumberPad'; import FormHelpMessage from './FormHelpMessage'; +import TextInput from './TextInput'; import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; @@ -41,6 +42,10 @@ type AmountFormProps = { /** Custom max amount length. It defaults to CONST.IOU.AMOUNT_MAX_LENGTH */ amountMaxLength?: number; + + label?: string; + + displayAsTextInput?: boolean; } & Pick & Pick; @@ -57,7 +62,19 @@ const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; const NUM_PAD_VIEW_ID = 'numPadView'; function AmountForm( - {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, amountMaxLength, errorText, onInputChange, onCurrencyButtonPress, isCurrencyPressable = true, ...rest}: AmountFormProps, + { + value: amount, + currency = CONST.CURRENCY.USD, + extraDecimals = 0, + amountMaxLength, + errorText, + onInputChange, + onCurrencyButtonPress, + displayAsTextInput = false, + isCurrencyPressable = true, + label, + ...rest + }: AmountFormProps, forwardedRef: ForwardedRef, ) { const styles = useThemeStyles(); @@ -124,6 +141,29 @@ function AmountForm( [amountMaxLength, currentAmount, decimals, onInputChange, selection], ); + /** + * Set a new amount value properly formatted + * + * @param text - Changed text from user input + */ + const setFormattedAmount = (text: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(text); + const replacedCommasAmount = MoneyRequestUtils.replaceCommasWithPeriod(newAmountWithoutSpaces); + const withLeadingZero = MoneyRequestUtils.addLeadingZero(replacedCommasAmount); + + if (!MoneyRequestUtils.validateAmount(withLeadingZero, decimals, amountMaxLength)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(withLeadingZero); + const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + setSelection(getNewSelection(selection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + onInputChange?.(strippedAmount); + }; + // Modifies the amount to match the decimals for changed currency. useEffect(() => { // If the changed currency supports decimals, we can return @@ -195,6 +235,31 @@ function AmountForm( const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); + if (displayAsTextInput) { + return ( + { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef && 'current' in forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + prefixCharacter={currency} + prefixStyle={styles.colorMuted} + keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} + inputMode={CONST.INPUT_MODE.DECIMAL} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + /> + ); + } + return ( <> 0 || !!prefixCharacter; + const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter; const [isFocused, setIsFocused] = useState(false); const [passwordHidden, setPasswordHidden] = useState(inputProps.secureTextEntry); @@ -142,13 +145,13 @@ function BaseTextInput( const deactivateLabel = useCallback(() => { const newValue = value ?? ''; - if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter) { + if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter || suffixCharacter) { return; } animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE); isLabelActive.current = false; - }, [animateLabel, forceActiveLabel, prefixCharacter, value]); + }, [animateLabel, forceActiveLabel, prefixCharacter, suffixCharacter, value]); const onFocus = (event: NativeSyntheticEvent) => { inputProps.onFocus?.(event); @@ -245,7 +248,7 @@ function BaseTextInput( // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const inputHelpText = errorText || hint; - const newPlaceholder = !!prefixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined; + const newPlaceholder = !!prefixCharacter || !!suffixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined; const newTextInputContainerStyles: StyleProp = StyleSheet.flatten([ styles.textInputContainer, textInputContainerStyles, @@ -276,6 +279,7 @@ function BaseTextInput( }, [inputStyle]); const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft); + const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight); return ( <> @@ -366,6 +370,7 @@ function BaseTextInput( inputStyle, (!hasLabel || isMultiline) && styles.pv0, inputPaddingLeft, + inputPaddingRight, inputProps.secureTextEntry && styles.secureInput, // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear @@ -402,6 +407,17 @@ function BaseTextInput( defaultValue={defaultValue} markdownStyle={markdownStyle} /> + {!!suffixCharacter && ( + + + {suffixCharacter} + + + )} {isFocused && !isReadOnly && shouldShowClearButton && !!value && setValue('')} />} {inputProps.isLoading && ( ; + /** Style for the suffix */ + suffixStyle?: StyleProp; + + /** Style for the suffix container */ + suffixContainerStyle?: StyleProp; + /** The width of inner content */ contentWidth?: number; }; diff --git a/src/languages/en.ts b/src/languages/en.ts index f5d441923344..f22af98f8e39 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -375,6 +375,7 @@ export default { filterLogs: 'Filter Logs', network: 'Network', reportID: 'Report ID', + days: 'days', }, location: { useCurrent: 'Use current location', @@ -3599,6 +3600,23 @@ export default { individualExpenseRules: { title: 'Expenses', subtitle: 'Set spend controls and defaults for individual expenses. You can also create rules for', + receiptRequiredAmount: 'Receipt required amount', + receiptRequiredAmountDescription: 'Require receipts when spend exceeds this amount, unless overridden by a category rule.', + maxExpenseAmount: 'Max expense amount', + maxExpenseAmountDescription: 'Flag spend that exceeds this amount, unless overridden by a category rule.', + maxAge: 'Max age', + maxExpenseAge: 'Max expense age', + maxExpenseAgeDescription: 'Flag spend older than a specific number of days.', + maxExpenseAgeDays: (age: number) => `${age} ${Str.pluralize('day', 'days', age)}`, + billableDefault: 'Billable default', + billableDefaultDescription: 'Choose whether cash and credit card expenses should be billable by default. Billable expenses are enabled or disabled in', + billable: 'Billable', + billableDescription: 'Expenses are most often re-billed to clients', + nonBillable: 'Non-billable', + nonBillableDescription: 'Expenses are occasionally re-billed to clients', + eReceipts: 'eReceipts', + eReceiptsHint: 'eReceipts are auto-created', + eReceiptsHintLink: 'for most USD credit transactions', }, expenseReportRules: { title: 'Expense reports', diff --git a/src/languages/es.ts b/src/languages/es.ts index ca84c83d16e1..a882a014bb6f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -365,6 +365,7 @@ export default { filterLogs: 'Registros de filtrado', network: 'La red', reportID: 'ID del informe', + days: 'días', }, connectionComplete: { title: 'Conexión completa', @@ -3648,6 +3649,23 @@ export default { individualExpenseRules: { title: 'Gastos', subtitle: 'Establece controles y valores predeterminados para gastos individuales. También puedes crear reglas para', + receiptRequiredAmount: 'Cantidad requerida para los recibos', + receiptRequiredAmountDescription: 'Exige recibos cuando los gastos superen este importe, a menos que lo anule una regla de categoría.', + maxExpenseAmount: 'Importe máximo del gasto', + maxExpenseAmountDescription: 'Marca los gastos que superen este importe, a menos que una regla de categoría lo anule.', + maxAge: 'Antigüedad máxima', + maxExpenseAge: 'Antigüedad máxima de los gastos', + maxExpenseAgeDescription: 'Marca los gastos de más de un número determinado de días.', + maxExpenseAgeDays: (age: number) => `${age} ${Str.pluralize('día', 'días', age)}`, + billableDefault: 'Valor predeterminado facturable', + billableDefaultDescription: 'Elige si los gastos en efectivo y con tarjeta de crédito deben ser facturables por defecto. Los gastos facturables se activan o desactivan en', + billable: 'Facturable', + billableDescription: 'Los gastos se vuelven a facturar a los clientes en la mayoría de los casos', + nonBillable: 'No facturable', + nonBillableDescription: 'Los gastos se vuelven a facturar a los clientes en ocasiones', + eReceipts: 'Recibos electrónicos', + eReceiptsHint: 'Los recibos electrónicos se crean automáticamente', + eReceiptsHintLink: 'para la mayoría de las transacciones en USD', }, expenseReportRules: { title: 'Informes de gastos', diff --git a/src/libs/API/parameters/SetPolicyBillableMode.ts b/src/libs/API/parameters/SetPolicyBillableMode.ts new file mode 100644 index 000000000000..2a313ab2bf0c --- /dev/null +++ b/src/libs/API/parameters/SetPolicyBillableMode.ts @@ -0,0 +1,12 @@ +type SetPolicyBillableMode = { + defaultBillable: boolean; + /** + * Stringified JSON object with type of following structure: + * disabledFields: { + * defaultBillable: boolean; + * }; + */ + disabledFields: string; +}; + +export default SetPolicyBillableMode; diff --git a/src/libs/API/parameters/SetPolicyExpenseMaxAge.ts b/src/libs/API/parameters/SetPolicyExpenseMaxAge.ts new file mode 100644 index 000000000000..df625978b425 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyExpenseMaxAge.ts @@ -0,0 +1,6 @@ +type SetPolicyExpenseMaxAge = { + policyID: string; + maxExpenseAge: number; +}; + +export default SetPolicyExpenseMaxAge; diff --git a/src/libs/API/parameters/SetPolicyExpenseMaxAmount.ts b/src/libs/API/parameters/SetPolicyExpenseMaxAmount.ts new file mode 100644 index 000000000000..79fe09c6c326 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyExpenseMaxAmount.ts @@ -0,0 +1,6 @@ +type SetPolicyExpenseMaxAmount = { + policyID: string; + maxExpenseAmount: number; +}; + +export default SetPolicyExpenseMaxAmount; diff --git a/src/libs/API/parameters/SetPolicyExpenseMaxAmountNoReceipt.ts b/src/libs/API/parameters/SetPolicyExpenseMaxAmountNoReceipt.ts new file mode 100644 index 000000000000..856294c1fc42 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyExpenseMaxAmountNoReceipt.ts @@ -0,0 +1,6 @@ +type SetPolicyExpenseMaxAmountNoReceipt = { + policyID: string; + maxExpenseAmountNoReceipt: number; +}; + +export default SetPolicyExpenseMaxAmountNoReceipt; diff --git a/src/libs/API/parameters/SetWorkspaceEReceiptsEnabled.ts b/src/libs/API/parameters/SetWorkspaceEReceiptsEnabled.ts new file mode 100644 index 000000000000..29031eca2cec --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceEReceiptsEnabled.ts @@ -0,0 +1,5 @@ +type SetWorkspaceEReceiptsEnabled = { + eReceipts: boolean; +}; + +export default SetWorkspaceEReceiptsEnabled; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index a72220c3d943..5647027c369a 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -273,6 +273,11 @@ export type {default as UpdateExpensifyCardLimitParams} from './UpdateExpensifyC export type {CreateWorkspaceApprovalParams, UpdateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams} from './WorkspaceApprovalParams'; export type {default as StartIssueNewCardFlowParams} from './StartIssueNewCardFlowParams'; export type {default as SetPolicyRulesEnabledParams} from './SetPolicyRulesEnabledParams'; +export type {default as SetPolicyExpenseMaxAmountNoReceipt} from './SetPolicyExpenseMaxAmountNoReceipt'; +export type {default as SetPolicyExpenseMaxAmount} from './SetPolicyExpenseMaxAmount'; +export type {default as SetPolicyExpenseMaxAge} from './SetPolicyExpenseMaxAge'; +export type {default as SetPolicyBillableMode} from './SetPolicyBillableMode'; +export type {default as SetWorkspaceEReceiptsEnabled} from './SetWorkspaceEReceiptsEnabled'; export type {default as ConfigureExpensifyCardsForPolicyParams} from './ConfigureExpensifyCardsForPolicyParams'; export type {default as CreateExpensifyCardParams} from './CreateExpensifyCardParams'; export type {default as UpdateExpensifyCardTitleParams} from './UpdateExpensifyCardTitleParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index de63ed032afe..c97ea27ba355 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -201,6 +201,11 @@ const WRITE_COMMANDS = { ENABLE_POLICY_EXPENSIFY_CARDS: 'EnablePolicyExpensifyCards', ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', + SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT: 'SetPolicyExpenseMaxAmountNoReceipt', + SET_POLICY_EXPENSE_MAX_AMOUNT: 'SetPolicyExpenseMaxAmount', + SET_POLICY_EXPENSE_MAX_AGE: ' SetPolicyExpenseMaxAge', + SET_POLICY_BILLABLE_MODE: ' SetPolicyBillableMode', + SET_WORKSPACE_ERECEIPTS_ENABLED: 'SetWorkspaceEReceiptsEnabled', SET_POLICY_TAXES_CURRENCY_DEFAULT: 'SetPolicyCurrencyDefaultTax', SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT: 'SetPolicyForeignCurrencyDefaultTax', SET_POLICY_CUSTOM_TAX_NAME: 'SetPolicyCustomTaxName', @@ -555,6 +560,12 @@ type WriteCommandParameters = { [WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams; [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION]: Parameters.CancelBillingSubscriptionParams; + [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT]: Parameters.SetPolicyExpenseMaxAmountNoReceipt; + [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT]: Parameters.SetPolicyExpenseMaxAmount; + [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AGE]: Parameters.SetPolicyExpenseMaxAge; + [WRITE_COMMANDS.SET_POLICY_BILLABLE_MODE]: Parameters.SetPolicyBillableMode; + [WRITE_COMMANDS.SET_WORKSPACE_ERECEIPTS_ENABLED]: Parameters.SetWorkspaceEReceiptsEnabled; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_ENABLE_NEW_CATEGORIES]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_AUTO_CREATE_VENDOR]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 4694a2e73d5c..69b44deb9618 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -453,6 +453,10 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/intacct/import/SageIntacctAddUserDimensionPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EDIT_USER_DIMENSION]: () => require('../../../../pages/workspace/accounting/intacct/import/SageIntacctEditUserDimensionsPage').default, + [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: () => require('../../../../pages/workspace/rules/RulesReceiptRequiredAmountPage').default, + [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT]: () => require('../../../../pages/workspace/rules/RulesMaxExpenseAmountPage').default, + [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: () => require('../../../../pages/workspace/rules/RulesMaxExpenseAgePage').default, + [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesBillableDefaultPage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ 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 942a23068979..9014aca652d2 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -171,6 +171,12 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT, SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT_TYPE, ], + [SCREENS.WORKSPACE.RULES]: [ + SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT, + SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT, + SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE, + SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT, + ], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index bb9d92c7a5a3..69fb001855cb 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -744,6 +744,18 @@ const config: LinkingOptions['config'] = { taxID: (taxID: string) => decodeURIComponent(taxID), }, }, + [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: { + path: ROUTES.RULES_RECEIPT_REQUIRED_AMOUNT.route, + }, + [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT]: { + path: ROUTES.RULES_MAX_EXPENSE_AMOUNT.route, + }, + [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: { + path: ROUTES.RULES_MAX_EXPENSE_AGE.route, + }, + [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { + path: ROUTES.RULES_BILLABLE_DEFAULT.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index c85f0972d84a..6ebaf6c37cf2 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -703,6 +703,18 @@ type SettingsNavigatorParamList = { policyID: string; cardID: string; }; + [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { + policyID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 19585a5e69c5..ba22c59441f7 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -45,6 +45,7 @@ import type { import type SetPolicyRulesEnabledParams from '@libs/API/parameters/SetPolicyRulesEnabledParams'; import type UpdatePolicyAddressParams from '@libs/API/parameters/UpdatePolicyAddressParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; @@ -3380,6 +3381,284 @@ function upgradeToCorporate(policyID: string, featureName: string) { API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {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 + * @param maxExpenseAmountNoReceipt - new value of the receipt required amount + */ +function setPolicyMaxExpenseAmountNoReceipt(policyID: string, maxExpenseAmountNoReceipt: string) { + const policy = getPolicy(policyID); + const parsedMaxExpenseAmountNoReceipt = maxExpenseAmountNoReceipt === '' ? CONST.DISABLED_MAX_EXPENSE_VALUE : CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAmountNoReceipt)); + const originalMaxExpenseAmountNoReceipt = policy?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAmountNoReceipt: parsedMaxExpenseAmountNoReceipt, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {maxExpenseAmountNoReceipt: null}, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + pendingFields: {maxExpenseAmountNoReceipt: null}, + errorFields: {maxExpenseAmountNoReceipt: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + maxExpenseAmountNoReceipt: parsedMaxExpenseAmountNoReceipt, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT, parameters, onyxData); +} + +/** + * Call the API to set the max expense amount for the given policy + * @param policyID - id of the policy to set the max expense amount + * @param maxExpenseAmount - new value of the max expense amount + */ +function setPolicyMaxExpenseAmount(policyID: string, maxExpenseAmount: string) { + const policy = getPolicy(policyID); + const parsedMaxExpenseAmount = maxExpenseAmount === '' ? CONST.DISABLED_MAX_EXPENSE_VALUE : CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAmount)); + const originalMaxExpenseAmount = policy?.maxExpenseAmount; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAmount: parsedMaxExpenseAmount, + pendingFields: { + maxExpenseAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {maxExpenseAmount: null}, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAmount: originalMaxExpenseAmount, + pendingFields: {maxExpenseAmount: null}, + errorFields: {maxExpenseAmount: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + maxExpenseAmount: parsedMaxExpenseAmount, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT, parameters, onyxData); +} + +/** + * Call the API to set the max expense age for the given policy + * @param policyID - id of the policy to set the max expense age + * @param maxExpenseAge - the max expense age value given in days + */ +function setPolicyMaxExpenseAge(policyID: string, maxExpenseAge: string) { + const policy = getPolicy(policyID); + const parsedMaxExpenseAge = maxExpenseAge === '' ? CONST.DISABLED_MAX_EXPENSE_VALUE : parseInt(maxExpenseAge, 10); + const originalMaxExpenseAge = policy?.maxExpenseAge; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAge: parsedMaxExpenseAge, + pendingFields: { + maxExpenseAge: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + maxExpenseAge: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAge: originalMaxExpenseAge, + pendingFields: {maxExpenseAge: null}, + errorFields: {maxExpenseAge: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + maxExpenseAge: parsedMaxExpenseAge, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AGE, parameters, onyxData); +} + +/** + * Call the API to enable or disable the billable mode for the given policy + * @param policyID - id of the policy to enable or disable the bilable mode + * @param defaultBillable - whether the billable mode is enabled in the given policy + */ +function setPolicyBillableMode(policyID: string, defaultBillable: boolean) { + const policy = getPolicy(policyID); + + const originalDefaultBillable = policy?.defaultBillable; + const originalDefaultBillableDisabled = policy?.disabledFields?.defaultBillable; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + defaultBillable, + disabledFields: { + defaultBillable: false, + }, + pendingFields: { + defaultBillable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + defaultBillable: null, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + disabledFields: {defaultBillable: originalDefaultBillableDisabled}, + defaultBillable: originalDefaultBillable, + pendingFields: {defaultBillable: null}, + errorFields: {defaultBillable: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + defaultBillable, + disabledFields: JSON.stringify({ + defaultBillable: false, + }), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_BILLABLE_MODE, parameters, onyxData); +} + +function setWorkspaceEReceiptsEnabled(policyID: string, eReceipts: boolean) { + const policy = getPolicy(policyID); + + const originalEReceipts = policy?.eReceipts; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + eReceipts, + pendingFields: { + eReceipts: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + eReceipts: null, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + eReceipts: originalEReceipts, + pendingFields: {defaultBillable: null}, + errorFields: {defaultBillable: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + eReceipts, + }; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_ERECEIPTS_ENABLED, parameters, onyxData); +} + function getAdminPoliciesConnectedToSageIntacct(): Policy[] { return Object.values(allPolicies ?? {}).filter((policy): policy is Policy => !!policy && policy.role === CONST.POLICY.ROLE.ADMIN && !!policy?.connections?.intacct); } @@ -3465,6 +3744,11 @@ export { getAdminPoliciesConnectedToSageIntacct, hasInvoicingDetails, enablePolicyRules, + setPolicyMaxExpenseAmountNoReceipt, + setPolicyMaxExpenseAmount, + setPolicyMaxExpenseAge, + setPolicyBillableMode, + setWorkspaceEReceiptsEnabled, }; export type {NewCustomUnit}; diff --git a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx new file mode 100644 index 000000000000..78dbcb3b16c8 --- /dev/null +++ b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx @@ -0,0 +1,202 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Section from '@components/Section'; +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 CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {ThemeStyles} from '@styles/index'; +import * as Link from '@userActions/Link'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +type IndividualExpenseRulesSectionProps = { + policyID: string; +}; + +type IndividualExpenseRulesSectionSubtitleProps = { + policy?: Policy; + translate: LocaleContextProps['translate']; + styles: ThemeStyles; +}; + +type IndividualExpenseRulesMenuItem = { + title: string; + descriptionTranslationKey: TranslationPaths; + action: () => void; + pendingAction?: PendingAction; +}; + +function IndividualExpenseRulesSectionSubtitle({policy, translate, styles}: IndividualExpenseRulesSectionSubtitleProps) { + const policyID = policy?.id ?? '-1'; + + const handleOnPressCategoriesLink = () => { + if (policy?.areCategoriesEnabled) { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); + return; + } + + Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + }; + + const handleOnPressTagsLink = () => { + if (policy?.areTagsEnabled) { + Navigation.navigate(ROUTES.WORKSPACE_TAGS.getRoute(policyID)); + return; + } + + Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + }; + + return ( + + {translate('workspace.rules.individualExpenseRules.subtitle')}{' '} + + {translate('workspace.common.categories').toLowerCase()} + {' '} + {translate('common.and')}{' '} + + {translate('workspace.common.tags').toLowerCase()} + + . + + ); +} + +function IndividualExpenseRulesSection({policyID}: IndividualExpenseRulesSectionProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policy = usePolicy(policyID); + + const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + + const maxExpenseAmountNoReceiptText = useMemo(() => { + if (policy?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE || !policy?.maxExpenseAmountNoReceipt) { + return ''; + } + + return CurrencyUtils.convertToDisplayString(policy?.maxExpenseAmountNoReceipt, policyCurrency); + }, [policy?.maxExpenseAmountNoReceipt, policyCurrency]); + + const maxExpenseAmountText = useMemo(() => { + if (policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policy?.maxExpenseAmount) { + return ''; + } + + return CurrencyUtils.convertToDisplayString(policy?.maxExpenseAmount, policyCurrency); + }, [policy?.maxExpenseAmount, policyCurrency]); + + const maxExpenseAgeText = useMemo(() => { + if (policy?.maxExpenseAge === CONST.DISABLED_MAX_EXPENSE_VALUE || !policy?.maxExpenseAge) { + return ''; + } + + return translate('workspace.rules.individualExpenseRules.maxExpenseAgeDays', policy?.maxExpenseAge); + }, [policy?.maxExpenseAge, translate]); + + const billableModeText = translate(`workspace.rules.individualExpenseRules.${policy?.defaultBillable ? 'billable' : 'nonBillable'}`); + + const individualExpenseRulesItems: IndividualExpenseRulesMenuItem[] = [ + { + title: maxExpenseAmountNoReceiptText, + descriptionTranslationKey: 'workspace.rules.individualExpenseRules.receiptRequiredAmount', + action: () => Navigation.navigate(ROUTES.RULES_RECEIPT_REQUIRED_AMOUNT.getRoute(policyID)), + pendingAction: policy?.pendingFields?.maxExpenseAmountNoReceipt, + }, + { + title: maxExpenseAmountText, + descriptionTranslationKey: 'workspace.rules.individualExpenseRules.maxExpenseAmount', + action: () => Navigation.navigate(ROUTES.RULES_MAX_EXPENSE_AMOUNT.getRoute(policyID)), + pendingAction: policy?.pendingFields?.maxExpenseAmount, + }, + { + title: maxExpenseAgeText, + descriptionTranslationKey: 'workspace.rules.individualExpenseRules.maxExpenseAge', + action: () => Navigation.navigate(ROUTES.RULES_MAX_EXPENSE_AGE.getRoute(policyID)), + pendingAction: policy?.pendingFields?.maxExpenseAge, + }, + { + title: billableModeText, + descriptionTranslationKey: 'workspace.rules.individualExpenseRules.billableDefault', + action: () => Navigation.navigate(ROUTES.RULES_BILLABLE_DEFAULT.getRoute(policyID)), + pendingAction: policy?.pendingFields?.defaultBillable, + }, + ]; + + const areEReceiptsEnabled = policy?.eReceipts ?? false; + + return ( +
( + + )} + subtitle={translate('workspace.rules.individualExpenseRules.subtitle')} + titleStyles={styles.accountSettingsSectionTitle} + > + + {individualExpenseRulesItems.map((item) => ( + + + + ))} + + + + + {translate('workspace.rules.individualExpenseRules.eReceipts')} + PolicyActions.setWorkspaceEReceiptsEnabled(policyID, !areEReceiptsEnabled)} + disabled={policyCurrency !== CONST.CURRENCY.USD} + /> + + + + {translate('workspace.rules.individualExpenseRules.eReceiptsHint')}{' '} + Link.openExternalLink(CONST.DEEP_DIVE_ERECEIPTS)} + > + {translate('workspace.rules.individualExpenseRules.eReceiptsHintLink')} + + . + + + +
+ ); +} + +export default IndividualExpenseRulesSection; diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx index ec7cdffb8df5..bd9e911e2a5e 100644 --- a/src/pages/workspace/rules/PolicyRulesPage.tsx +++ b/src/pages/workspace/rules/PolicyRulesPage.tsx @@ -2,50 +2,27 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import {View} from 'react-native'; import Section from '@components/Section'; -import Text from '@components/Text'; -import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; -import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import * as Illustrations from '@src/components/Icon/Illustrations'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import IndividualExpenseRulesSection from './IndividualExpenseRulesSection'; type PolicyRulesPageProps = StackScreenProps; function PolicyRulesPage({route}: PolicyRulesPageProps) { const {translate} = useLocalize(); const {policyID} = route.params; - const policy = usePolicy(policyID); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {canUseWorkspaceRules} = usePermissions(); - const handleOnPressCategoriesLink = () => { - if (policy?.areCategoriesEnabled) { - Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); - return; - } - - Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); - }; - - const handleOnPressTagsLink = () => { - if (policy?.areTagsEnabled) { - Navigation.navigate(ROUTES.WORKSPACE_TAGS.getRoute(policyID)); - return; - } - - Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); - }; - return ( -
( - - {translate('workspace.rules.individualExpenseRules.subtitle')}{' '} - - {translate('workspace.common.categories').toLowerCase()} - {' '} - {translate('common.and')}{' '} - - {translate('workspace.common.tags').toLowerCase()} - - . - - )} - subtitle={translate('workspace.rules.individualExpenseRules.subtitle')} - titleStyles={styles.accountSettingsSectionTitle} - /> +
; + +function RulesBillableDefaultPage({ + route: { + params: {policyID}, + }, +}: RulesBillableDefaultPageProps) { + const policy = usePolicy(policyID); + + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const billableModes = [ + { + value: true, + text: translate(`workspace.rules.individualExpenseRules.billable`), + alternateText: translate(`workspace.rules.individualExpenseRules.billableDescription`), + keyForList: CONST.POLICY_BILLABLE_MODES.BILLABLE, + isSelected: policy?.defaultBillable, + }, + { + value: false, + text: translate(`workspace.rules.individualExpenseRules.nonBillable`), + alternateText: translate(`workspace.rules.individualExpenseRules.nonBillableDescription`), + keyForList: CONST.POLICY_BILLABLE_MODES.NON_BILLABLE, + isSelected: !policy?.defaultBillable, + }, + ]; + + const initiallyFocusedOptionKey = policy?.defaultBillable ? CONST.POLICY_BILLABLE_MODES.BILLABLE : CONST.POLICY_BILLABLE_MODES.NON_BILLABLE; + + const handleOnPressTagsLink = () => { + if (policy?.areTagsEnabled) { + Navigation.navigate(ROUTES.WORKSPACE_TAGS.getRoute(policyID)); + return; + } + + Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + }; + + return ( + + + Navigation.goBack()} + /> + + {translate('workspace.rules.individualExpenseRules.billableDefaultDescription')}{' '} + + {translate('workspace.common.tags').toLowerCase()} + + . + + { + Policy.setPolicyBillableMode(policyID, item.value); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + shouldSingleExecuteRowSelect + containerStyle={[styles.pt3]} + initiallyFocusedOptionKey={initiallyFocusedOptionKey} + /> + + + ); +} + +RulesBillableDefaultPage.displayName = 'RulesBillableDefaultPage'; + +export default RulesBillableDefaultPage; diff --git a/src/pages/workspace/rules/RulesMaxExpenseAgePage.tsx b/src/pages/workspace/rules/RulesMaxExpenseAgePage.tsx new file mode 100644 index 000000000000..514360473568 --- /dev/null +++ b/src/pages/workspace/rules/RulesMaxExpenseAgePage.tsx @@ -0,0 +1,95 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; +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 usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/RulesMaxExpenseAgeForm'; + +type RulesMaxExpenseAgePageProps = StackScreenProps; + +function RulesMaxExpenseAgePage({ + route: { + params: {policyID}, + }, +}: RulesMaxExpenseAgePageProps) { + const policy = usePolicy(policyID); + + const {inputCallbackRef} = useAutoFocusInput(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const maxExpenseAgeDefaultValue = policy?.maxExpenseAge === CONST.DISABLED_MAX_EXPENSE_VALUE || !policy?.maxExpenseAge ? '' : `${policy?.maxExpenseAge}`; + + const [maxExpenseAgeValue, setMaxExpenseAgeValue] = useState(maxExpenseAgeDefaultValue); + + const onChangeMaxExpenseAge = useCallback((newValue: string) => { + // replace all characters that are not spaces or digits + const validMaxExpenseAge = newValue.replace(/[^0-9]/g, ''); + setMaxExpenseAgeValue(validMaxExpenseAge); + }, []); + + return ( + + + Navigation.goBack()} + /> + { + PolicyActions.setPolicyMaxExpenseAge(policyID, maxExpenseAge); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + submitButtonText={translate('workspace.editor.save')} + enabledWhenOffline + > + + + {translate('workspace.rules.individualExpenseRules.maxExpenseAgeDescription')} + + + + + ); +} + +RulesMaxExpenseAgePage.displayName = 'RulesMaxExpenseAgePage'; + +export default RulesMaxExpenseAgePage; diff --git a/src/pages/workspace/rules/RulesMaxExpenseAmountPage.tsx b/src/pages/workspace/rules/RulesMaxExpenseAmountPage.tsx new file mode 100644 index 000000000000..dba43789bd34 --- /dev/null +++ b/src/pages/workspace/rules/RulesMaxExpenseAmountPage.tsx @@ -0,0 +1,88 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +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 '@libs/Navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/RulesMaxExpenseAmountForm'; + +type RulesMaxExpenseAmountPageProps = StackScreenProps; + +function RulesMaxExpenseAmountPage({ + route: { + params: {policyID}, + }, +}: RulesMaxExpenseAmountPageProps) { + const policy = usePolicy(policyID); + + const {inputCallbackRef} = useAutoFocusInput(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const defaultValue = + policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policy?.maxExpenseAmount + ? '' + : CurrencyUtils.convertToFrontendAmountAsString(policy?.maxExpenseAmount, policy?.outputCurrency); + + return ( + + + Navigation.goBack()} + /> + { + PolicyActions.setPolicyMaxExpenseAmount(policyID, maxExpenseAmount); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + submitButtonText={translate('workspace.editor.save')} + enabledWhenOffline + > + + + {translate('workspace.rules.individualExpenseRules.maxExpenseAmountDescription')} + + + + + ); +} + +RulesMaxExpenseAmountPage.displayName = 'RulesMaxExpenseAmountPage'; + +export default RulesMaxExpenseAmountPage; diff --git a/src/pages/workspace/rules/RulesReceiptRequiredAmountPage.tsx b/src/pages/workspace/rules/RulesReceiptRequiredAmountPage.tsx new file mode 100644 index 000000000000..9e7098d45502 --- /dev/null +++ b/src/pages/workspace/rules/RulesReceiptRequiredAmountPage.tsx @@ -0,0 +1,88 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +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 '@libs/Navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/RulesRequiredReceiptAmountForm'; + +type RulesReceiptRequiredAmountPageProps = StackScreenProps; + +function RulesReceiptRequiredAmountPage({ + route: { + params: {policyID}, + }, +}: RulesReceiptRequiredAmountPageProps) { + const policy = usePolicy(policyID); + + const {inputCallbackRef} = useAutoFocusInput(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const defaultValue = + policy?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE || !policy?.maxExpenseAmountNoReceipt + ? '' + : CurrencyUtils.convertToFrontendAmountAsString(policy?.maxExpenseAmountNoReceipt, policy?.outputCurrency); + + return ( + + + Navigation.goBack()} + /> + { + PolicyActions.setPolicyMaxExpenseAmountNoReceipt(policyID, maxExpenseAmountNoReceipt); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + submitButtonText={translate('workspace.editor.save')} + enabledWhenOffline + > + + + {translate('workspace.rules.individualExpenseRules.receiptRequiredAmountDescription')} + + + + + ); +} + +RulesReceiptRequiredAmountPage.displayName = 'RulesReceiptRequiredAmountPage'; + +export default RulesReceiptRequiredAmountPage; diff --git a/src/styles/index.ts b/src/styles/index.ts index 9f93c799abb5..87e0d3517fe7 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1282,6 +1282,18 @@ const styles = (theme: ThemeColors) => paddingBottom: 8, }, + textInputSuffixWrapper: { + position: 'absolute', + right: 0, + top: 0, + height: variables.inputHeight, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + paddingTop: 23, + paddingBottom: 8, + }, + textInputPrefix: { color: theme.text, ...FontUtils.fontFamily.platform.EXP_NEUE, @@ -1289,6 +1301,13 @@ const styles = (theme: ThemeColors) => verticalAlign: 'middle', }, + textInputSuffix: { + color: theme.text, + ...FontUtils.fontFamily.platform.EXP_NEUE, + fontSize: variables.fontSizeNormal, + verticalAlign: 'middle', + }, + pickerContainer: { borderBottomWidth: 2, paddingLeft: 0, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 56dd9a583c9a..def901b716bd 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -664,6 +664,15 @@ function getPaddingLeft(paddingLeft: number): ViewStyle { }; } +/** + * Get variable padding-right as style + */ +function getPaddingRight(paddingRight: number): ViewStyle { + return { + paddingRight, + }; +} + /** * Checks to see if the iOS device has safe areas or not */ @@ -1119,6 +1128,7 @@ const staticStyleUtils = { getBackgroundColorStyle, getBackgroundColorWithOpacityStyle, getPaddingLeft, + getPaddingRight, hasSafeAreas, getHeight, getMinimumHeight, diff --git a/src/types/form/RulesMaxExpenseAgeForm.ts b/src/types/form/RulesMaxExpenseAgeForm.ts new file mode 100644 index 000000000000..bc66e194522d --- /dev/null +++ b/src/types/form/RulesMaxExpenseAgeForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + MAX_EXPENSE_AGE: 'maxExpenseAge', +} as const; + +type InputID = ValueOf; + +type RulesMaxExpenseAgeForm = Form< + InputID, + { + [INPUT_IDS.MAX_EXPENSE_AGE]: string; + } +>; + +export type {RulesMaxExpenseAgeForm}; +export default INPUT_IDS; diff --git a/src/types/form/RulesMaxExpenseAmountForm.ts b/src/types/form/RulesMaxExpenseAmountForm.ts new file mode 100644 index 000000000000..ec8e8736a803 --- /dev/null +++ b/src/types/form/RulesMaxExpenseAmountForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + MAX_EXPENSE_AMOUNT: 'maxExpenseAmount', +} as const; + +type InputID = ValueOf; + +type RulesMaxExpenseAmountForm = Form< + InputID, + { + [INPUT_IDS.MAX_EXPENSE_AMOUNT]: string; + } +>; + +export type {RulesMaxExpenseAmountForm}; +export default INPUT_IDS; diff --git a/src/types/form/RulesRequiredReceiptAmountForm.ts b/src/types/form/RulesRequiredReceiptAmountForm.ts new file mode 100644 index 000000000000..18b59c8f92dd --- /dev/null +++ b/src/types/form/RulesRequiredReceiptAmountForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + MAX_EXPENSE_AMOUNT_NO_RECEIPT: 'maxExpenseAmountNoReceipt', +} as const; + +type InputID = ValueOf; + +type RulesRequiredReceiptAmountForm = Form< + InputID, + { + [INPUT_IDS.MAX_EXPENSE_AMOUNT_NO_RECEIPT]: string; + } +>; + +export type {RulesRequiredReceiptAmountForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 61d3e9918164..e2421d000557 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -70,3 +70,6 @@ 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 {RulesRequiredReceiptAmountForm} from './RulesRequiredReceiptAmountForm'; +export type {RulesMaxExpenseAmountForm} from './RulesMaxExpenseAmountForm'; +export type {RulesMaxExpenseAgeForm} from './RulesMaxExpenseAgeForm'; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 9bac5f2e4de4..508f902f7c94 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1510,7 +1510,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the workflows feature is enabled */ areWorkflowsEnabled?: boolean; - /** Whether the reules feature is enabled */ + /** Whether the rules feature is enabled */ areRulesEnabled?: boolean; /** Whether the Report Fields feature is enabled */ @@ -1525,6 +1525,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** The verified bank account linked to the policy */ achAccount?: ACHAccount; + /** Whether the eReceipts are enabled */ + eReceipts?: boolean; + /** Indicates if the Policy is in loading state */ isLoading?: boolean;