diff --git a/src/CONST.ts b/src/CONST.ts index defcb1cb57a1..75e1bbaeefb1 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1496,6 +1496,11 @@ const CONST = { DISABLE: 'disable', ENABLE: 'enable', }, + TAX_RATES_BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, }, CUSTOM_UNITS: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 99973935b20a..9a1b5a395505 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -420,6 +420,10 @@ const ONYXKEYS = { POLICY_TAG_NAME_FORM_DRAFT: 'policyTagNameFormDraft', WORKSPACE_NEW_TAX_FORM: 'workspaceNewTaxForm', WORKSPACE_NEW_TAX_FORM_DRAFT: 'workspaceNewTaxFormDraft', + WORKSPACE_TAX_NAME_FORM: 'workspaceTaxNameForm', + WORKSPACE_TAX_NAME_FORM_DRAFT: 'workspaceTaxNameFormDraft', + WORKSPACE_TAX_VALUE_FORM: 'workspaceTaxValueForm', + WORKSPACE_TAX_VALUE_FORM_DRAFT: 'workspaceTaxValueFormDraft', }, } as const; @@ -471,6 +475,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm; [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm; [ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm; + [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm; + [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; }; type OnyxFormDraftValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 831921f33122..9ce32835c8d7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -616,6 +616,18 @@ const ROUTES = { route: 'settings/workspaces/:policyID/taxes/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/new` as const, }, + WORKSPACE_TAX_EDIT: { + route: 'settings/workspaces/:policyID/tax/:taxID', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}` as const, + }, + WORKSPACE_TAX_NAME: { + route: 'settings/workspaces/:policyID/tax/:taxID/name', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}/name` as const, + }, + WORKSPACE_TAX_VALUE: { + route: 'settings/workspaces/:policyID/tax/:taxID/value', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}/value` as const, + }, WORKSPACE_DISTANCE_RATES: { route: 'settings/workspaces/:policyID/distance-rates', getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cd7bb934247f..4d4e9ea327c6 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -217,6 +217,9 @@ const SCREENS = { TAGS_EDIT: 'Tags_Edit', TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', + TAX_EDIT: 'Workspace_Tax_Edit', + TAX_NAME: 'Workspace_Tax_Name', + TAX_VALUE: 'Workspace_Tax_Value', TAXES_SETTINGS: 'Workspace_Taxes_Settings', TAXES_SETTINGS_CUSTOM_TAX_NAME: 'Workspace_Taxes_Settings_CustomTaxName', TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT: 'Workspace_Taxes_Settings_WorkspaceCurrency', diff --git a/src/components/AmountPicker/index.tsx b/src/components/AmountPicker/index.tsx index 701c75175c02..45e511f24748 100644 --- a/src/components/AmountPicker/index.tsx +++ b/src/components/AmountPicker/index.tsx @@ -25,7 +25,8 @@ function AmountPicker({value, description, title, errorText = '', onInputChange, const updateInput = (updatedValue: string) => { if (updatedValue !== value) { - onInputChange?.(updatedValue); + // We cast the updatedValue to a number and then back to a string to remove any leading zeros and separating commas + onInputChange?.(String(Number(updatedValue))); } hidePickerModal(); }; diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 798369292958..83100788761f 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -12,6 +12,8 @@ type WorkspaceMemberBulkActionType = DeepValueOf; +type WorkspaceTaxRatesBulkActionType = DeepValueOf; + type DropdownOption = { value: TValueType; text: string; @@ -73,4 +75,4 @@ type ButtonWithDropdownMenuProps = { wrapperStyle?: StyleProp; }; -export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps}; +export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps, WorkspaceTaxRatesBulkActionType}; diff --git a/src/languages/en.ts b/src/languages/en.ts index cd2d92e25b22..54dcb8717a6d 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1876,7 +1876,19 @@ export default { errors: { taxRateAlreadyExists: 'This tax name is already in use.', valuePercentageRange: 'Please enter a valid percentage between 0 and 100.', - genericFailureMessage: 'An error occurred while updating the tax rate, please try again.', + deleteFailureMessage: 'An error occurred while deleting the tax rate. Please try again or ask Concierge for help.', + updateFailureMessage: 'An error occurred while updating the tax rate. Please try again or ask Concierge for help.', + createFailureMessage: 'An error occurred while creating the tax rate. Please try again or ask Concierge for help.', + }, + deleteTaxConfirmation: 'Are you sure you want to delete this tax?', + deleteMultipleTaxConfirmation: ({taxAmount}) => `Are you sure you want to delete ${taxAmount} taxes?`, + actions: { + delete: 'Delete rate', + deleteMultiple: 'Delete rates', + disable: 'Disable rate', + disableMultiple: 'Disable rates', + enable: 'Enable rate', + enableMultiple: 'Enable rates', }, }, emptyWorkspace: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 3a50e332fd57..8e8e3356476c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1899,8 +1899,20 @@ export default { value: 'Valor', errors: { taxRateAlreadyExists: 'Ya existe un impuesto con este nombre', - valuePercentageRange: 'Introduzca un porcentaje válido entre 0 y 100', - genericFailureMessage: 'Se produjo un error al actualizar el tipo impositivo, inténtelo nuevamente.', + valuePercentageRange: 'Por favor, introduce un porcentaje entre 0 y 100', + deleteFailureMessage: 'Se ha producido un error al intentar eliminar la tasa de impuesto. Por favor, inténtalo más tarde.', + updateFailureMessage: 'Se ha producido un error al intentar modificar la tasa de impuesto. Por favor, inténtalo más tarde.', + createFailureMessage: 'Se ha producido un error al intentar crear la tasa de impuesto. Por favor, inténtalo más tarde.', + }, + deleteTaxConfirmation: '¿Estás seguro de que quieres eliminar este impuesto?', + deleteMultipleTaxConfirmation: ({taxAmount}) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`, + actions: { + delete: 'Eliminar tasa', + deleteMultiple: 'Eliminar tasas', + disable: 'Desactivar tasa', + disableMultiple: 'Desactivar tasas', + enable: 'Activar tasa', + enableMultiple: 'Activar tasas', }, }, emptyWorkspace: { diff --git a/src/libs/API/parameters/DeletePolicyTaxesParams.ts b/src/libs/API/parameters/DeletePolicyTaxesParams.ts new file mode 100644 index 000000000000..9e0963cdcb28 --- /dev/null +++ b/src/libs/API/parameters/DeletePolicyTaxesParams.ts @@ -0,0 +1,11 @@ +type DeletePolicyTaxesParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + * Each element is a tax name + */ + taxNames: string; +}; + +export default DeletePolicyTaxesParams; diff --git a/src/libs/API/parameters/RenamePolicyTaxParams.ts b/src/libs/API/parameters/RenamePolicyTaxParams.ts new file mode 100644 index 000000000000..b722f14e7b6e --- /dev/null +++ b/src/libs/API/parameters/RenamePolicyTaxParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCurrencyDefaultParams = { + policyID: string; + taxCode: string; + newName: string; +}; + +export default SetPolicyCurrencyDefaultParams; diff --git a/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts b/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts new file mode 100644 index 000000000000..4ed0a05cfdec --- /dev/null +++ b/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts @@ -0,0 +1,10 @@ +type SetPolicyTaxesEnabledParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{taxCode: string, enabled: bool}> + */ + taxFieldsArray: string; +}; + +export default SetPolicyTaxesEnabledParams; diff --git a/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts b/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts new file mode 100644 index 000000000000..1124755ea9ef --- /dev/null +++ b/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts @@ -0,0 +1,7 @@ +type UpdatePolicyTaxValueParams = { + policyID: string; + taxCode: string; + taxAmount: number; +}; + +export default UpdatePolicyTaxValueParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 84ace32d6261..5b42f25d19be 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -184,8 +184,12 @@ export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDist export type {default as SetPolicyDistanceRatesUnitParams} from './SetPolicyDistanceRatesUnitParams'; export type {default as SetPolicyDistanceRatesDefaultCategoryParams} from './SetPolicyDistanceRatesDefaultCategoryParams'; export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams'; +export type {default as SetPolicyTaxesEnabledParams} from './SetPolicyTaxesEnabledParams'; +export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams'; +export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams'; export type {default as RenamePolicyTagsParams} from './RenamePolicyTagsParams'; export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams'; export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams'; export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams'; export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrencyDefaultParams'; +export type {default as RenamePolicyTaxParams} from './RenamePolicyTaxParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 8d359febfd0f..c04702f38f6a 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -182,6 +182,10 @@ const WRITE_COMMANDS = { ACCEPT_JOIN_REQUEST: 'AcceptJoinRequest', DECLINE_JOIN_REQUEST: 'DeclineJoinRequest', CREATE_POLICY_TAX: 'CreatePolicyTax', + SET_POLICY_TAXES_ENABLED: 'SetPolicyTaxesEnabled', + DELETE_POLICY_TAXES: 'DeletePolicyTaxes', + UPDATE_POLICY_TAX_VALUE: 'UpdatePolicyTaxValue', + RENAME_POLICY_TAX: 'RenamePolicyTax', CREATE_POLICY_DISTANCE_RATE: 'CreatePolicyDistanceRate', SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit', SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory', @@ -365,7 +369,11 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_CUSTOM_TAX_NAME]: Parameters.SetPolicyCustomTaxNameParams; [WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT]: Parameters.SetPolicyForeignCurrencyDefaultParams; [WRITE_COMMANDS.CREATE_POLICY_TAX]: Parameters.CreatePolicyTaxParams; + [WRITE_COMMANDS.SET_POLICY_TAXES_ENABLED]: Parameters.SetPolicyTaxesEnabledParams; + [WRITE_COMMANDS.DELETE_POLICY_TAXES]: Parameters.DeletePolicyTaxesParams; + [WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE]: Parameters.UpdatePolicyTaxValueParams; [WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE]: Parameters.CreatePolicyDistanceRateParams; + [WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; }; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 784d339a4a0d..d38700efd53d 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -110,6 +110,21 @@ function getEarliestErrorField(onyxDa return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } +/** + * Method used to get the latest error field for any field + */ +function getLatestErrorFieldForAnyField(onyxData: TOnyxData): Errors { + const errorFields = onyxData.errorFields ?? {}; + + if (Object.keys(errorFields).length === 0) { + return {}; + } + + const fieldNames = Object.keys(errorFields); + const latestErrorFields = fieldNames.map((fieldName) => getLatestErrorField(onyxData, fieldName)); + return latestErrorFields.reduce((acc, error) => ({...acc, ...error}), {}); +} + /** * Method used to attach already translated message with isTranslated property * @param errors - An object containing current errors in the form @@ -176,6 +191,7 @@ export { getLatestErrorField, getLatestErrorMessage, getLatestErrorMessageField, + getLatestErrorFieldForAnyField, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, isReceiptError, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index b576f0e7601a..bd5bfc46134a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -284,6 +284,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default as React.ComponentType, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../pages/workspace/taxes/WorkspaceEditTaxPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_NAME]: () => require('../../../pages/workspace/taxes/NamePage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_VALUE]: () => require('../../../pages/workspace/taxes/ValuePage').default as React.ComponentType, [SCREENS.WORKSPACE.TAX_CREATE]: () => require('../../../pages/workspace/taxes/WorkspaceCreateTaxPage').default as React.ComponentType, }); 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 7388d6447ffa..17f5049aab91 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -17,6 +17,10 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME, SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT, SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT, + SCREENS.WORKSPACE.TAX_CREATE, + SCREENS.WORKSPACE.TAX_EDIT, + SCREENS.WORKSPACE.TAX_NAME, + SCREENS.WORKSPACE.TAX_VALUE, ], [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS, SCREENS.WORKSPACE.TAG_EDIT], [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS, SCREENS.WORKSPACE.CATEGORY_EDIT], diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 391d584d5a78..130fdf23732f 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -354,6 +354,15 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAX_CREATE]: { path: ROUTES.WORKSPACE_TAX_CREATE.route, }, + [SCREENS.WORKSPACE.TAX_EDIT]: { + path: ROUTES.WORKSPACE_TAX_EDIT.route, + }, + [SCREENS.WORKSPACE.TAX_NAME]: { + path: ROUTES.WORKSPACE_TAX_NAME.route, + }, + [SCREENS.WORKSPACE.TAX_VALUE]: { + path: ROUTES.WORKSPACE_TAX_VALUE.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 90fad4f29f22..9b0d9ce4decc 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -242,6 +242,18 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.TAX_CREATE]: { policyID: string; }; + [SCREENS.WORKSPACE.TAX_EDIT]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_NAME]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_VALUE]: { + policyID: string; + taxID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 6f39879d6f26..39e6c8932aad 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -4,7 +4,7 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList, Policy, PolicyCategories, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, PolicyCategories, PolicyMembers, PolicyTagList, PolicyTags, TaxRate} from '@src/types/onyx'; import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -35,7 +35,7 @@ function hasPolicyMemberError(policyMembers: OnyxEntry): boolean * Check if the policy has any tax rate errors. */ function hasTaxRateError(policy: OnyxEntry): boolean { - return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0); + return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)); } /** @@ -277,6 +277,18 @@ function goBackFromInvalidPolicy() { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); } +/** Get a tax with given ID from policy */ +function getTaxByID(policy: OnyxEntry, taxID: string): TaxRate | undefined { + return policy?.taxRates?.taxes?.[taxID]; +} + +/** + * Whether the tax rate can be deleted and disabled + */ +function canEditTaxRate(policy: Policy, taxID: string): boolean { + return policy.taxRates?.defaultExternalID !== taxID; +} + function isPolicyFeatureEnabled(policy: OnyxEntry | EmptyObject, featureName: PolicyFeatureName): boolean { if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { return Boolean(policy?.tax?.trackingEnabled); @@ -305,6 +317,7 @@ export { getIneligibleInvitees, getTagLists, getTagListName, + canEditTaxRate, getTagList, getCleanedTagName, getCountOfEnabledTagsOfList, @@ -317,6 +330,7 @@ export { goBackFromInvalidPolicy, isPolicyFeatureEnabled, hasTaxRateError, + getTaxByID, hasPolicyCategoriesError, }; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 5876ccf5d7d7..cacab8333868 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -471,8 +471,9 @@ function isValidPercentage(value: string): boolean { /** * Validates the given value if it is correct tax name. */ -function isExistingTaxName(value: string, taxRates: TaxRates): boolean { - return !!Object.values(taxRates).find((taxRate) => taxRate.name === value); +function isExistingTaxName(taxName: string, taxRates: TaxRates): boolean { + const trimmedTaxName = taxName.trim(); + return !!Object.values(taxRates).find((taxRate) => taxRate.name === trimmedTaxName); } export { diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index 23cb53a317b0..f808f602a1c6 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -92,8 +92,9 @@ function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection, policyM function hasWorkspaceSettingsRBR(policy: Policy) { const policyMemberError = allPolicyMembers ? hasPolicyMemberError(allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policy.id}`]) : false; + const taxRateError = hasTaxRateError(policy); - return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError; + return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError; } function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined { diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 1bad1de0a9f5..3f2420c76f87 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -1,14 +1,25 @@ +import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {FormOnyxValues} from '@components/Form/types'; import * as API from '@libs/API'; -import type {CreatePolicyTaxParams} from '@libs/API/parameters'; +import type {CreatePolicyTaxParams, DeletePolicyTaxesParams, RenamePolicyTaxParams, SetPolicyTaxesEnabledParams, UpdatePolicyTaxValueParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import * as ErrorUtils from '@src/libs/ErrorUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {TaxRate, TaxRates} from '@src/types/onyx'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import INPUT_IDS from '@src/types/form/WorkspaceNewTaxForm'; +import type {Policy, TaxRate, TaxRates} from '@src/types/onyx'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {OnyxData} from '@src/types/onyx/Request'; +let allPolicies: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + /** * Get tax value with percentage */ @@ -20,6 +31,34 @@ function covertTaxNameToID(name: string) { return `id_${name.toUpperCase().replaceAll(' ', '_')}`; } +/** + * Function to validate tax name + */ +const validateTaxName = (policy: Policy, values: FormOnyxValues) => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]); + + const name = values[INPUT_IDS.NAME]; + if (policy?.taxRates?.taxes && ValidationUtils.isExistingTaxName(name, policy.taxRates.taxes)) { + errors[INPUT_IDS.NAME] = 'workspace.taxes.errors.taxRateAlreadyExists'; + } + + return errors; +}; + +/** + * Function to validate tax value + */ +const validateTaxValue = (values: FormOnyxValues) => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE]); + + const value = values[INPUT_IDS.VALUE]; + if (!ValidationUtils.isValidPercentage(value)) { + errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange'; + } + + return errors; +}; + /** * Get new tax ID */ @@ -39,7 +78,8 @@ function getNextTaxCode(name: string, taxRates?: TaxRates): string { function createPolicyTax(policyID: string, taxRate: TaxRate) { if (!taxRate.code) { - throw new Error('Tax code is required when creating a new tax rate.'); + console.debug('Policy or tax rates not found'); + return; } const onyxData: OnyxData = { @@ -83,7 +123,7 @@ function createPolicyTax(policyID: string, taxRate: TaxRate) { taxRates: { taxes: { [taxRate.code]: { - errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.genericFailureMessage'), + errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.createFailureMessage'), }, }, }, @@ -105,7 +145,24 @@ function createPolicyTax(policyID: string, taxRate: TaxRate) { API.write(WRITE_COMMANDS.CREATE_POLICY_TAX, parameters, onyxData); } -function clearTaxRateError(policyID: string, taxID: string, pendingAction?: PendingAction) { +function clearTaxRateFieldError(policyID: string, taxID: string, field: keyof TaxRate) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + taxRates: { + taxes: { + [taxID]: { + pendingFields: { + [field]: null, + }, + errorFields: { + [field]: null, + }, + }, + }, + }, + }); +} + +function clearTaxRateError(policyID: string, taxID: string, pendingAction?: OnyxCommon.PendingAction) { if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { taxRates: { @@ -119,10 +176,288 @@ function clearTaxRateError(policyID: string, taxID: string, pendingAction?: Pend Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { taxRates: { taxes: { - [taxID]: {pendingAction: null, errors: null}, + [taxID]: {pendingAction: null, errors: null, errorFields: null}, }, }, }); } -export {createPolicyTax, clearTaxRateError, getNextTaxCode, getTaxValueWithPercentage}; +type TaxRateEnabledMap = Record>; + +function setPolicyTaxesEnabled(policyID: string, taxesIDsToUpdate: string[], isEnabled: boolean) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxes = {...policy?.taxRates?.taxes}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = { + isDisabled: !isEnabled, + pendingFields: {isDisabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {isDisabled: null}, + }; + return acc; + }, {}), + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = {pendingFields: {isDisabled: null}, errorFields: {isDisabled: null}}; + return acc; + }, {}), + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = { + isDisabled: !!originalTaxes[taxID].isDisabled, + pendingFields: {isDisabled: null}, + errorFields: {isDisabled: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }; + return acc; + }, {}), + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxFieldsArray: JSON.stringify(taxesIDsToUpdate.map((taxID) => ({taxCode: taxID, enabled: isEnabled}))), + } satisfies SetPolicyTaxesEnabledParams; + + API.write(WRITE_COMMANDS.SET_POLICY_TAXES_ENABLED, parameters, onyxData); +} + +type TaxRateDeleteMap = Record< + string, + | (Pick & { + errors: OnyxCommon.Errors | null; + }) + | null +>; + +function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const policyTaxRates = policy?.taxRates?.taxes; + + if (!policyTaxRates) { + console.debug('Policy or tax rates not found'); + return; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null}; + return acc; + }, {}), + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = null; + return acc; + }, {}), + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = { + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.deleteFailureMessage'), + }; + return acc; + }, {}), + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), + } satisfies DeletePolicyTaxesParams; + + API.write(WRITE_COMMANDS.DELETE_POLICY_TAXES, parameters, onyxData); +} + +function updatePolicyTaxValue(policyID: string, taxID: string, taxValue: number) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxRate = {...policy?.taxRates?.taxes[taxID]}; + const stringTaxValue = `${taxValue}%`; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + value: stringTaxValue, + pendingFields: {value: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {value: null}, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: {pendingFields: {value: null}, errorFields: {value: null}}, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + value: originalTaxRate.value, + pendingFields: {value: null}, + errorFields: {value: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxCode: taxID, + taxAmount: Number(taxValue), + } satisfies UpdatePolicyTaxValueParams; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE, parameters, onyxData); +} + +function renamePolicyTax(policyID: string, taxID: string, newName: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxRate = {...policy?.taxRates?.taxes[taxID]}; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + name: newName, + pendingFields: {name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {name: null}, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: {pendingFields: {name: null}, errorFields: {name: null}}, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + name: originalTaxRate.name, + pendingFields: {name: null}, + errorFields: {name: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxCode: taxID, + newName, + } satisfies RenamePolicyTaxParams; + + API.write(WRITE_COMMANDS.RENAME_POLICY_TAX, parameters, onyxData); +} + +export { + createPolicyTax, + getNextTaxCode, + clearTaxRateError, + clearTaxRateFieldError, + getTaxValueWithPercentage, + setPolicyTaxesEnabled, + validateTaxName, + validateTaxValue, + deletePolicyTaxes, + updatePolicyTaxValue, + renamePolicyTax, +}; diff --git a/src/pages/workspace/taxes/NamePage.tsx b/src/pages/workspace/taxes/NamePage.tsx new file mode 100644 index 000000000000..1efb983be19e --- /dev/null +++ b/src/pages/workspace/taxes/NamePage.tsx @@ -0,0 +1,120 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {renamePolicyTax, validateTaxName} from '@libs/actions/TaxRate'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +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/WorkspaceTaxNameForm'; + +type NamePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; + +const parser = new ExpensiMark(); + +function NamePage({ + route: { + params: {policyID, taxID}, + }, + policy, +}: NamePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID); + const {inputCallbackRef} = useAutoFocusInput(); + + const [name, setName] = useState(() => parser.htmlToMarkdown(currentTaxRate?.name ?? '')); + + const goBack = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID ?? '', taxID)), [policyID, taxID]); + + const submit = () => { + renamePolicyTax(policyID, taxID, name); + goBack(); + }; + + const validate = useCallback( + (values: FormOnyxValues) => { + if (!policy) { + return {}; + } + if (values[INPUT_IDS.NAME] === currentTaxRate?.name) { + return {}; + } + return validateTaxName(policy, values); + }, + [currentTaxRate?.name, policy], + ); + + if (!currentTaxRate) { + return ; + } + + return ( + + + + + + + + + + + + + + + + ); +} + +NamePage.displayName = 'NamePage'; + +export default withPolicyAndFullscreenLoading(NamePage); diff --git a/src/pages/workspace/taxes/ValuePage.tsx b/src/pages/workspace/taxes/ValuePage.tsx new file mode 100644 index 000000000000..d008b11ecb15 --- /dev/null +++ b/src/pages/workspace/taxes/ValuePage.tsx @@ -0,0 +1,103 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useState} from 'react'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updatePolicyTaxValue, validateTaxValue} from '@libs/actions/TaxRate'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +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/WorkspaceTaxValueForm'; + +type ValuePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; + +function ValuePage({ + route: { + params: {policyID, taxID}, + }, + policy, +}: ValuePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID); + const [value, setValue] = useState(currentTaxRate?.value?.replace('%', '')); + + const goBack = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID ?? '', taxID)), [policyID, taxID]); + + const submit = useCallback( + (values: FormOnyxValues) => { + updatePolicyTaxValue(policyID, taxID, Number(values.value)); + goBack(); + }, + [goBack, policyID, taxID], + ); + + if (!currentTaxRate) { + return ; + } + + return ( + + + + + + + + %} + /> + + + + + + ); +} + +ValuePage.displayName = 'ValuePage'; + +export default withPolicyAndFullscreenLoading(ValuePage); diff --git a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx index a2f40b378b3a..ccc0d4ad9e7b 100644 --- a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx @@ -11,10 +11,9 @@ import Text from '@components/Text'; import TextPicker from '@components/TextPicker'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {createPolicyTax, getNextTaxCode, getTaxValueWithPercentage} from '@libs/actions/TaxRate'; +import {createPolicyTax, getNextTaxCode, getTaxValueWithPercentage, validateTaxName, validateTaxValue} from '@libs/actions/TaxRate'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import * as ValidationUtils from '@libs/ValidationUtils'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; @@ -37,25 +36,6 @@ function WorkspaceCreateTaxPage({ const styles = useThemeStyles(); const {translate} = useLocalize(); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE, INPUT_IDS.NAME]); - - const value = values[INPUT_IDS.VALUE]; - if (!ValidationUtils.isValidPercentage(value)) { - errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange'; - } - - const name = values[INPUT_IDS.NAME]; - if (policy?.taxRates?.taxes && ValidationUtils.isExistingTaxName(name, policy.taxRates.taxes)) { - errors[INPUT_IDS.NAME] = 'workspace.taxes.errors.taxRateAlreadyExists'; - } - - return errors; - }, - [policy?.taxRates?.taxes], - ); - const submitForm = useCallback( ({value, ...values}: FormOnyxValues) => { const taxRate = { @@ -69,6 +49,19 @@ function WorkspaceCreateTaxPage({ [policy?.taxRates?.taxes, policyID], ); + const validateForm = useCallback( + (values: FormOnyxValues): FormInputErrors => { + if (!policy) { + return {}; + } + return { + ...validateTaxName(policy, values), + ...validateTaxValue(values), + }; + }, + [policy], + ); + return ( @@ -87,7 +80,7 @@ function WorkspaceCreateTaxPage({ style={[styles.flexGrow1, styles.mh5]} formID={ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM} onSubmit={submitForm} - validate={validate} + validate={validateForm} submitButtonText={translate('common.save')} enabledWhenOffline shouldValidateOnBlur={false} diff --git a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx new file mode 100644 index 000000000000..ec04b77df3ca --- /dev/null +++ b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx @@ -0,0 +1,163 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useMemo, useState} from 'react'; +import {View} from 'react-native'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {clearTaxRateFieldError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type WorkspaceEditTaxPageBaseProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; + +function WorkspaceEditTaxPage({ + route: { + params: {policyID, taxID}, + }, + policy, +}: WorkspaceEditTaxPageBaseProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID); + const {windowWidth} = useWindowDimensions(); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const canEdit = policy && PolicyUtils.canEditTaxRate(policy, taxID); + + const toggleTaxRate = () => { + if (!currentTaxRate) { + return; + } + setPolicyTaxesEnabled(policyID, [taxID], !!currentTaxRate.isDisabled); + }; + + const deleteTaxRate = () => { + if (!policyID) { + return; + } + deletePolicyTaxes(policyID, [taxID]); + setIsDeleteModalVisible(false); + Navigation.goBack(); + }; + + const threeDotsMenuItems: ThreeDotsMenuItem[] = useMemo( + () => [ + { + icon: Expensicons.Trashcan, + text: translate('common.delete'), + onSelected: () => setIsDeleteModalVisible(true), + }, + ], + [translate], + ); + + if (!currentTaxRate) { + return ; + } + + return ( + + + + + + + clearTaxRateFieldError(policyID, taxID, 'isDisabled')} + > + + + {translate('workspace.taxes.actions.enable')} + + + + + clearTaxRateFieldError(policyID, taxID, 'name')} + > + Navigation.navigate(ROUTES.WORKSPACE_TAX_NAME.getRoute(`${policyID}`, taxID))} + /> + + clearTaxRateFieldError(policyID, taxID, 'value')} + > + Navigation.navigate(ROUTES.WORKSPACE_TAX_VALUE.getRoute(`${policyID}`, taxID))} + /> + + + setIsDeleteModalVisible(false)} + prompt={translate('workspace.taxes.deleteTaxConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + + + + + ); +} + +WorkspaceEditTaxPage.displayName = 'WorkspaceEditTaxPage'; + +export default withPolicyAndFullscreenLoading(WorkspaceEditTaxPage); diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 74595a98cc81..bad82d827c5d 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -1,7 +1,10 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption, WorkspaceTaxRatesBulkActionType} from '@components/ButtonWithDropdownMenu/types'; +import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -17,8 +20,10 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {openPolicyTaxesPage} from '@libs/actions/Policy'; -import {clearTaxRateError} from '@libs/actions/TaxRate'; +import {clearTaxRateError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; @@ -31,17 +36,24 @@ import type SCREENS from '@src/SCREENS'; type WorkspaceTaxesPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; -function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) { +function WorkspaceTaxesPage({ + policy, + route: { + params: {policyID}, + }, +}: WorkspaceTaxesPageProps) { const {isSmallScreenWidth} = useWindowDimensions(); const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); const [selectedTaxesIDs, setSelectedTaxesIDs] = useState([]); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const defaultExternalID = policy?.taxRates?.defaultExternalID; const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; + const dropdownButtonRef = useRef(null); const fetchTaxes = () => { - openPolicyTaxesPage(route.params.policyID); + openPolicyTaxesPage(policyID); }; const {isOffline} = useNetwork({onReconnect: fetchTaxes}); @@ -67,34 +79,34 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) { [defaultExternalID, foreignTaxDefault, translate], ); - const taxesList = useMemo( - () => - Object.entries(policy?.taxRates?.taxes ?? {}) - .map(([key, value]) => ({ - text: value.name, - alternateText: textForDefault(key), - keyForList: key, - isSelected: !!selectedTaxesIDs.includes(key), - isDisabledCheckbox: key === defaultExternalID, - pendingAction: value.pendingAction, - errors: value.errors, - rightElement: ( - - - {value.isDisabled ? translate('workspace.common.disabled') : translate('workspace.common.enabled')} - - - - + const taxesList = useMemo(() => { + if (!policy) { + return []; + } + return Object.entries(policy.taxRates?.taxes ?? {}) + .map(([key, value]) => ({ + text: value.name, + alternateText: textForDefault(key), + keyForList: key, + isSelected: !!selectedTaxesIDs.includes(key), + isDisabledCheckbox: !PolicyUtils.canEditTaxRate(policy, key), + 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), + errors: value.errors ?? ErrorUtils.getLatestErrorFieldForAnyField(value), + rightElement: ( + + {value.isDisabled ? translate('workspace.common.disabled') : translate('workspace.common.enabled')} + + - ), - })) - .sort((a, b) => a.text.localeCompare(b.text)), - [policy?.taxRates?.taxes, textForDefault, defaultExternalID, selectedTaxesIDs, styles, theme.icon, translate], - ); + + ), + })) + .sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? '')); + }, [policy, textForDefault, selectedTaxesIDs, styles.flexRow, styles.disabledText, styles.alignSelfCenter, styles.p1, styles.pl2, translate, theme.icon]); const isLoading = !isOffline && taxesList === undefined; @@ -130,31 +142,103 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) { ); - const headerButtons = ( + const deleteTaxes = useCallback(() => { + if (!policyID) { + return; + } + deletePolicyTaxes(policyID, selectedTaxesIDs); + setSelectedTaxesIDs([]); + setIsDeleteModalVisible(false); + }, [policyID, selectedTaxesIDs]); + + const toggleTaxes = useCallback( + (isEnabled: boolean) => { + if (!policyID) { + return; + } + setPolicyTaxesEnabled(policyID, selectedTaxesIDs, isEnabled); + setSelectedTaxesIDs([]); + }, + [policyID, selectedTaxesIDs], + ); + + const navigateToEditTaxRate = (taxRate: ListItem) => { + if (!taxRate.keyForList) { + return; + } + setSelectedTaxesIDs([]); + Navigation.navigate(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxRate.keyForList)); + }; + + const dropdownMenuOptions = useMemo(() => { + const isMultiple = selectedTaxesIDs.length > 1; + const options: Array> = [ + { + icon: Expensicons.Trashcan, + text: isMultiple ? translate('workspace.taxes.actions.deleteMultiple') : translate('workspace.taxes.actions.delete'), + value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.DELETE, + onSelected: () => setIsDeleteModalVisible(true), + }, + ]; + + // `Disable rates` when at least one enabled rate is selected. + if (selectedTaxesIDs.some((taxID) => !policy?.taxRates?.taxes[taxID]?.isDisabled)) { + options.push({ + icon: Expensicons.DocumentSlash, + text: isMultiple ? translate('workspace.taxes.actions.disableMultiple') : translate('workspace.taxes.actions.disable'), + value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.DISABLE, + onSelected: () => toggleTaxes(false), + }); + } + + // `Enable rates` when at least one disabled rate is selected. + if (selectedTaxesIDs.some((taxID) => policy?.taxRates?.taxes[taxID]?.isDisabled)) { + options.push({ + icon: Expensicons.Document, + text: isMultiple ? translate('workspace.taxes.actions.enableMultiple') : translate('workspace.taxes.actions.enable'), + value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.ENABLE, + onSelected: () => toggleTaxes(true), + }); + } + return options; + }, [policy?.taxRates?.taxes, selectedTaxesIDs, toggleTaxes, translate]); + + const headerButtons = !selectedTaxesIDs.length ? (