diff --git a/assets/images/tax.svg b/assets/images/tax.svg new file mode 100644 index 000000000000..aa3c68e72ea8 --- /dev/null +++ b/assets/images/tax.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ROUTES.ts b/src/ROUTES.ts index defb945ba8c2..ad2d9c10700b 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -585,6 +585,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/tags/edit', getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/edit` as const, }, + WORKSPACE_TAXES: { + route: 'settings/workspaces/:policyID/taxes', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes` as const, + }, WORKSPACE_MEMBER_DETAILS: { route: 'settings/workspaces/:policyID/members/:accountID', getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/members/${accountID}`, backTo), @@ -597,7 +601,6 @@ const ROUTES = { route: 'workspace/:policyID/distance-rates', getRoute: (policyID: string) => `workspace/${policyID}/distance-rates` as const, }, - // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 4db5fd9115a5..6c742f08bfb7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -219,6 +219,7 @@ const SCREENS = { TAGS: 'Workspace_Tags', TAGS_SETTINGS: 'Tags_Settings', TAGS_EDIT: 'Tags_Edit', + TAXES: 'Workspace_Taxes', TAG_CREATE: 'Tag_Create', CURRENCY: 'Workspace_Profile_Currency', WORKFLOWS: 'Workspace_Workflows', diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 3f1cde92a583..73a091815460 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -138,6 +138,7 @@ import Stopwatch from '@assets/images/stopwatch.svg'; import Sync from '@assets/images/sync.svg'; import Tag from '@assets/images/tag.svg'; import Task from '@assets/images/task.svg'; +import Tax from '@assets/images/tax.svg'; import ThreeDots from '@assets/images/three-dots.svg'; import ThumbsUp from '@assets/images/thumbs-up.svg'; import Transfer from '@assets/images/transfer.svg'; @@ -226,6 +227,7 @@ export { Fullscreen, Folder, Tag, + Tax, Gallery, Gear, Globe, diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 6eedc322f393..2af07701c5d3 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -80,9 +80,10 @@ function BaseListItem({ {item.isSelected && ( diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index db6204c8c1ef..5ab4eea6404a 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -119,7 +119,8 @@ function BaseSelectionList( }); // If disabled, add to the disabled indexes array - if (!!section.isDisabled || item.isDisabled) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!!section.isDisabled || item.isDisabled || item.isDisabledCheckbox) { disabledOptionsIndexes.push(disabledIndex); } disabledIndex += 1; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 152a44996fea..08b10369e31f 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -60,6 +60,9 @@ type ListItem = { /** Whether this option is selected */ isSelected?: boolean; + /** Whether the checkbox should be disabled */ + isDisabledCheckbox?: boolean; + /** Whether this option is disabled for selection */ isDisabled?: boolean | null; diff --git a/src/languages/en.ts b/src/languages/en.ts index c602b2fad14c..6ec5983583fc 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1741,6 +1741,7 @@ export default { reimburse: 'Reimbursements', categories: 'Categories', tags: 'Tags', + taxes: 'Taxes', bills: 'Bills', invoices: 'Invoices', travel: 'Travel', @@ -1848,6 +1849,12 @@ export default { existingTagError: 'A tag with this name already exists.', genericFailureMessage: 'An error occurred while updating the tag, please try again.', }, + taxes: { + subtitle: 'Add tax names, rates, and set defaults.', + addRate: 'Add rate', + workspaceDefault: 'Workspace currency default', + foreignDefault: 'Foreign currency default', + }, emptyWorkspace: { title: 'Create a workspace', subtitle: 'Workspaces are where you’ll chat with your team, reimburse expenses, issue cards, send invoices, pay bills, and more - all in one place.', diff --git a/src/languages/es.ts b/src/languages/es.ts index bb54aa6e51f9..c2eb6374affa 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1765,6 +1765,7 @@ export default { reimburse: 'Reembolsos', categories: 'Categorías', tags: 'Etiquetas', + taxes: 'Impuestos', bills: 'Pagar facturas', invoices: 'Enviar facturas', travel: 'Viajes', @@ -1872,6 +1873,12 @@ export default { existingTagError: 'Ya existe una etiqueta con este nombre.', genericFailureMessage: 'Se produjo un error al actualizar la etiqueta, inténtelo nuevamente.', }, + taxes: { + subtitle: 'Añade nombres, tasas y establezca valores por defecto para los impuestos.', + addRate: 'Añadir tasa', + workspaceDefault: 'Moneda por defecto del espacio de trabajo', + foreignDefault: 'Moneda extranjera por defecto', + }, emptyWorkspace: { title: 'Crea un espacio de trabajo', subtitle: 'En los espacios de trabajo podrás chatear con tu equipo, reembolsar gastos, emitir tarjetas, enviar y pagar facturas, y mucho más - todo en un mismo lugar.', diff --git a/src/libs/API/parameters/OpenPolicyTaxesPageParams.ts b/src/libs/API/parameters/OpenPolicyTaxesPageParams.ts new file mode 100644 index 000000000000..67c345e5db78 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyTaxesPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyTaxesPageParams = { + policyID: string; +}; + +export default OpenPolicyTaxesPageParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index b594e555518a..25c336753203 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -174,5 +174,6 @@ export type {default as DeclineJoinRequestParams} from './DeclineJoinRequest'; export type {default as JoinPolicyInviteLinkParams} from './JoinPolicyInviteLink'; export type {default as OpenPolicyWorkflowsPageParams} from './OpenPolicyWorkflowsPageParams'; export type {default as OpenPolicyDistanceRatesPageParams} from './OpenPolicyDistanceRatesPageParams'; +export type {default as OpenPolicyTaxesPageParams} from './OpenPolicyTaxesPageParams'; export type {default as OpenPolicyMoreFeaturesPageParams} from './OpenPolicyMoreFeaturesPageParams'; export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 24237dbb48bc..07f1ca09d7c5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -376,6 +376,7 @@ const READ_COMMANDS = { OPEN_WORKSPACE_MEMBERS_PAGE: 'OpenWorkspaceMembersPage', OPEN_POLICY_CATEGORIES_PAGE: 'OpenPolicyCategoriesPage', OPEN_POLICY_TAGS_PAGE: 'OpenPolicyTagsPage', + OPEN_POLICY_TAXES_PAGE: 'OpenPolicyTaxesPage', OPEN_WORKSPACE_INVITE_PAGE: 'OpenWorkspaceInvitePage', OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', @@ -415,6 +416,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_WORKSPACE_MEMBERS_PAGE]: Parameters.OpenWorkspaceMembersPageParams; [READ_COMMANDS.OPEN_POLICY_CATEGORIES_PAGE]: Parameters.OpenPolicyCategoriesPageParams; [READ_COMMANDS.OPEN_POLICY_TAGS_PAGE]: Parameters.OpenPolicyTagsPageParams; + [READ_COMMANDS.OPEN_POLICY_TAXES_PAGE]: Parameters.OpenPolicyTaxesPageParams; [READ_COMMANDS.OPEN_WORKSPACE_INVITE_PAGE]: Parameters.OpenWorkspaceInvitePageParams; [READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST]: Parameters.OpenDraftWorkspaceRequestParams; [READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE]: Parameters.OpenPolicyWorkflowsPageParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 7df1da23d068..a4d7593cf750 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -199,6 +199,7 @@ const WorkspaceSettingsModalStackNavigator = createModalStackNavigator( [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../pages/workspace/categories/WorkspaceCategoriesPage').default as React.ComponentType, [SCREENS.WORKSPACE.MORE_FEATURES]: () => require('../../../pages/workspace/WorkspaceMoreFeaturesPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS]: () => require('../../../pages/workspace/tags/WorkspaceTagsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAXES]: () => require('../../../pages/workspace/taxes/WorkspaceTaxesPage').default as React.ComponentType, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default as React.ComponentType, }, (styles) => ({cardStyle: styles.navigationScreenCardStyle, headerShown: false}), diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 1461c27e03e0..04bc53e7b542 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -582,6 +582,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAGS]: { path: ROUTES.WORKSPACE_TAGS.route, }, + [SCREENS.WORKSPACE.TAXES]: { + path: ROUTES.WORKSPACE_TAXES.route, + }, [SCREENS.WORKSPACE.DISTANCE_RATES]: { path: ROUTES.WORKSPACE_DISTANCE_RATES.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index a1ec30fa303e..da418625ff55 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -540,6 +540,9 @@ type WorkspacesCentralPaneNavigatorParamList = { policyID: string; categoryName: string; }; + [SCREENS.WORKSPACE.TAXES]: { + policyID: string; + }; [SCREENS.WORKSPACE.DISTANCE_RATES]: { policyID: string; }; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 652de8aedf7d..2adcfd29e00d 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -27,6 +27,7 @@ import type { OpenPolicyDistanceRatesPageParams, OpenPolicyMoreFeaturesPageParams, OpenPolicyTagsPageParams, + OpenPolicyTaxesPageParams, OpenPolicyWorkflowsPageParams, OpenWorkspaceInvitePageParams, OpenWorkspaceMembersPageParams, @@ -2109,6 +2110,19 @@ function openPolicyTagsPage(policyID: string) { API.read(READ_COMMANDS.OPEN_POLICY_TAGS_PAGE, params); } +function openPolicyTaxesPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyTaxesPage invalid params', {policyID}); + return; + } + + const params: OpenPolicyTaxesPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_TAXES_PAGE, params); +} + function openWorkspaceInvitePage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceInvitePage invalid params', {policyID, clientMemberEmails}); @@ -3342,6 +3356,10 @@ function enablePolicyTaxes(policyID: string, enabled: boolean) { const parameters: EnablePolicyTaxesParams = {policyID, enabled}; API.write(WRITE_COMMANDS.ENABLE_POLICY_TAXES, parameters, onyxData); + + if (enabled) { + navigateWhenEnableFeature(policyID, ROUTES.WORKSPACE_TAXES.getRoute(policyID)); + } } function enablePolicyWorkflows(policyID: string, enabled: boolean) { @@ -3530,6 +3548,7 @@ export { openWorkspaceMembersPage, openPolicyCategoriesPage, openPolicyTagsPage, + openPolicyTaxesPage, openWorkspaceInvitePage, openWorkspace, removeWorkspace, diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index c1b3a490fec0..871bb2cf7980 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -188,6 +188,15 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r }); } + if (policy?.tax?.trackingEnabled) { + protectedCollectPolicyMenuItems.push({ + translationKey: 'workspace.common.taxes', + icon: Expensicons.Tax, + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TAXES.getRoute(policyID)))), + routeName: SCREENS.WORKSPACE.TAXES, + }); + } + protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.moreFeatures', icon: Expensicons.Gear, diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 457c96ac2fd7..2c8123670e0b 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -88,6 +88,16 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro Policy.enablePolicyTags(policy?.id ?? '', isEnabled); }, }, + { + icon: Illustrations.Coins, + titleTranslationKey: 'workspace.moreFeatures.taxes.title', + subtitleTranslationKey: 'workspace.moreFeatures.taxes.subtitle', + isActive: policy?.tax?.trackingEnabled ?? false, + pendingAction: policy?.pendingFields?.tax, + action: (isEnabled: boolean) => { + Policy.enablePolicyTaxes(policy?.id ?? '', isEnabled); + }, + }, ]; const sections: SectionObject[] = [ diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx new file mode 100644 index 000000000000..18123d109645 --- /dev/null +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -0,0 +1,195 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {ActivityIndicator, View} from 'react-native'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import * as Illustrations from '@components/Icon/Illustrations'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import TableListItem from '@components/SelectionList/TableListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {openPolicyTaxesPage} from '@libs/actions/Policy'; +import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +type WorkspaceTaxesPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; + +function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) { + const {isSmallScreenWidth} = useWindowDimensions(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const [selectedTaxesIDs, setSelectedTaxesIDs] = useState([]); + const defaultExternalID = policy?.taxRates?.defaultExternalID; + const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; + + const fetchTaxes = () => { + openPolicyTaxesPage(route.params.policyID); + }; + + const {isOffline} = useNetwork({onReconnect: fetchTaxes}); + + useEffect(() => { + fetchTaxes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const textForDefault = useCallback( + (taxID: string): string => { + if (taxID === defaultExternalID && taxID === foreignTaxDefault) { + return translate('common.default'); + } + if (taxID === defaultExternalID) { + return translate('workspace.taxes.workspaceDefault'); + } + if (taxID === foreignTaxDefault) { + return translate('workspace.taxes.foreignDefault'); + } + return ''; + }, + [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, + 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], + ); + + const isLoading = !isOffline && taxesList === undefined; + + const toggleTax = (tax: ListItem) => { + const key = tax.keyForList; + if (typeof key !== 'string') { + return; + } + + setSelectedTaxesIDs((prev) => { + if (prev?.includes(key)) { + return prev.filter((item) => item !== key); + } + return [...prev, key]; + }); + }; + + const toggleAllTaxes = () => { + const taxesToSelect = taxesList.filter((tax) => tax.keyForList !== defaultExternalID); + setSelectedTaxesIDs((prev) => { + if (prev.length === taxesToSelect.length) { + return []; + } + + return taxesToSelect.map((item) => (item.keyForList ? item.keyForList : '')); + }); + }; + + const getCustomListHeader = () => ( + + {translate('common.name')} + {translate('statusPage.status')} + + ); + + const headerButtons = ( + +