diff --git a/assets/images/user-check.svg b/assets/images/user-check.svg new file mode 100644 index 000000000000..2da67de751f4 --- /dev/null +++ b/assets/images/user-check.svg @@ -0,0 +1,9 @@ + + + + diff --git a/src/components/ApprovalWorkflowSection.tsx b/src/components/ApprovalWorkflowSection.tsx new file mode 100644 index 000000000000..899e83c9440b --- /dev/null +++ b/src/components/ApprovalWorkflowSection.tsx @@ -0,0 +1,106 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; +import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import MenuItem from './MenuItem'; +import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; +import Text from './Text'; + +type ApprovalWorkflowSectionProps = { + /** Single workflow displayed in this component */ + approvalWorkflow: ApprovalWorkflow; + + /** ID of the policy */ + policyId?: string; +}; + +function ApprovalWorkflowSection({approvalWorkflow, policyId}: ApprovalWorkflowSectionProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate, toLocaleOrdinal} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + const openApprovalsEdit = useCallback( + () => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyId ?? '', approvalWorkflow.approvers[0].email)), + [approvalWorkflow.approvers, policyId], + ); + const approverTitle = useCallback( + (index: number) => + approvalWorkflow.approvers.length > 1 ? `${toLocaleOrdinal(index + 1, true)} ${translate('workflowsPage.approver').toLowerCase()}` : `${translate('workflowsPage.approver')}`, + [approvalWorkflow.approvers.length, toLocaleOrdinal, translate], + ); + + return ( + + + {approvalWorkflow.isDefault && ( + + + + {translate('workflowsPage.addApprovalTip')} + + + )} + m.displayName).join(', ')} + icon={Expensicons.Users} + iconHeight={20} + iconWidth={20} + iconFill={theme.icon} + onPress={openApprovalsEdit} + shouldRemoveBackground + /> + + {approvalWorkflow.approvers.map((approver, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + + + ))} + + + + ); +} + +export default ApprovalWorkflowSection; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index a3fd1c93a261..8b70dfb27e83 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -179,6 +179,7 @@ import Trashcan from '@assets/images/trashcan.svg'; import Unlock from '@assets/images/unlock.svg'; import UploadAlt from '@assets/images/upload-alt.svg'; import Upload from '@assets/images/upload.svg'; +import UserCheck from '@assets/images/user-check.svg'; import User from '@assets/images/user.svg'; import Users from '@assets/images/users.svg'; import VolumeHigh from '@assets/images/volume-high.svg'; @@ -350,6 +351,7 @@ export { Upload, UploadAlt, User, + UserCheck, Users, VolumeHigh, VolumeLow, diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index 322a68ffe32a..383784a468d7 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -50,7 +50,7 @@ type LocaleContextProps = { toLocaleDigit: (digit: string) => string; /** Formats a number into its localized ordinal representation */ - toLocaleOrdinal: (number: number) => string; + toLocaleOrdinal: (number: number, returnWords?: boolean) => string; /** Gets the standard digit corresponding to a locale digit */ fromLocaleDigit: (digit: string) => string; @@ -101,7 +101,12 @@ function LocaleContextProvider({preferredLocale, currentUserPersonalDetails, chi const toLocaleDigit = useMemo(() => (digit) => LocaleDigitUtils.toLocaleDigit(locale, digit), [locale]); - const toLocaleOrdinal = useMemo(() => (number) => LocaleDigitUtils.toLocaleOrdinal(locale, number), [locale]); + const toLocaleOrdinal = useMemo( + () => + (number, writtenOrdinals = false) => + LocaleDigitUtils.toLocaleOrdinal(locale, number, writtenOrdinals), + [locale], + ); const fromLocaleDigit = useMemo(() => (localeDigit) => LocaleDigitUtils.fromLocaleDigit(locale, localeDigit), [locale]); diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 4865617ddb2e..8af6cd492c03 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -242,6 +242,9 @@ type MenuItemBaseProps = { /** Should we grey out the menu item when it is disabled? */ shouldGreyOutWhenDisabled?: boolean; + /** Should we remove the background color of the menu item */ + shouldRemoveBackground?: boolean; + /** Should we use default cursor for disabled content */ shouldUseDefaultCursorWhenDisabled?: boolean; @@ -377,6 +380,7 @@ function MenuItem( shouldRenderAsHTML = false, shouldEscapeText = undefined, shouldGreyOutWhenDisabled = true, + shouldRemoveBackground = false, shouldUseDefaultCursorWhenDisabled = false, shouldShowLoadingSpinnerIcon = false, isAnonymousAction = false, @@ -537,11 +541,12 @@ function MenuItem( containerStyle, combinedStyle, !interactive && styles.cursorDefault, - StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true), + !shouldRemoveBackground && + StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true), ...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]), !focused && (isHovered || pressed) && hoverAndPressStyle, shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled, - isHovered && interactive && !focused && !pressed && styles.hoveredComponentBG, + isHovered && interactive && !focused && !pressed && !shouldRemoveBackground && styles.hoveredComponentBG, ] as StyleProp } disabledStyle={shouldUseDefaultCursorWhenDisabled && [styles.cursorDefault]} diff --git a/src/languages/en.ts b/src/languages/en.ts index 8cc1f0887e75..44686dbc53df 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1253,6 +1253,7 @@ export default { submissionFrequencyDateOfMonth: 'Date of month', addApprovalsTitle: 'Add approvals', addApprovalButton: 'Add approval workflow', + addApprovalTip: 'This default workflow applies to all members, unless a more specific workflow exists', approver: 'Approver', connectBankAccount: 'Connect bank account', addApprovalsDescription: 'Require additional approval before authorizing a payment.', @@ -1276,6 +1277,18 @@ export default { two: 'nd', few: 'rd', other: 'th', + /* eslint-disable @typescript-eslint/naming-convention */ + '1': 'First', + '2': 'Second', + '3': 'Third', + '4': 'Fourth', + '5': 'Fifth', + '6': 'Sixth', + '7': 'Seventh', + '8': 'Eighth', + '9': 'Ninth', + '10': 'Tenth', + /* eslint-enable @typescript-eslint/naming-convention */ }, }, }, @@ -2045,6 +2058,7 @@ export default { edit: 'Edit workspace', enabled: 'Enabled', disabled: 'Disabled', + everyone: 'Everyone', delete: 'Delete workspace', settings: 'Settings', reimburse: 'Reimbursements', diff --git a/src/languages/es.ts b/src/languages/es.ts index b659123c0632..090185716d4d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1262,6 +1262,7 @@ export default { submissionFrequencyDateOfMonth: 'Fecha del mes', addApprovalsTitle: 'Requerir aprobaciones', addApprovalButton: 'Añadir flujo de aprobación', + addApprovalTip: 'Este flujo de trabajo por defecto se aplica a todos los miembros, a menos que exista un flujo de trabajo más específico', approver: 'Aprobador', connectBankAccount: 'Conectar cuenta bancaria', addApprovalsDescription: 'Requiere una aprobación adicional antes de autorizar un pago.', @@ -1285,6 +1286,18 @@ export default { two: '.º', few: '.º', other: '.º', + /* eslint-disable @typescript-eslint/naming-convention */ + '1': 'Primero', + '2': 'Segundo', + '3': 'Tercero', + '4': 'Cuarto', + '5': 'Quinto', + '6': 'Sexto', + '7': 'Séptimo', + '8': 'Octavo', + '9': 'Noveno', + '10': 'Décimo', + /* eslint-enable @typescript-eslint/naming-convention */ }, }, }, @@ -2080,6 +2093,7 @@ export default { edit: 'Editar espacio de trabajo', enabled: 'Activada', disabled: 'Desactivada', + everyone: 'Todos', delete: 'Eliminar espacio de trabajo', settings: 'Configuración', reimburse: 'Reembolsos', diff --git a/src/libs/LocaleDigitUtils.ts b/src/libs/LocaleDigitUtils.ts index a024276819c1..0362f4aa963f 100644 --- a/src/libs/LocaleDigitUtils.ts +++ b/src/libs/LocaleDigitUtils.ts @@ -73,10 +73,13 @@ function fromLocaleDigit(locale: Locale, localeDigit: string): string { /** * Formats a number into its localized ordinal representation i.e 1st, 2nd etc + * @param locale - The locale to use for formatting + * @param number - The number to format + * @param writtenOrdinals - If true, returns the written ordinal (e.g. "first", "second") for numbers 1-10 */ -function toLocaleOrdinal(locale: Locale, number: number): string { +function toLocaleOrdinal(locale: Locale, number: number, writtenOrdinals = false): string { // Defaults to "other" suffix or "th" in English - let suffixKey = 'workflowsPage.frequencies.ordinals.other'; + let suffixKey: TranslationPaths = 'workflowsPage.frequencies.ordinals.other'; // Calculate last digit of the number to determine basic ordinality const lastDigit = number % 10; @@ -84,6 +87,10 @@ function toLocaleOrdinal(locale: Locale, number: number): string { // Calculate last two digits to handle exceptions in the 11-13 range const lastTwoDigits = number % 100; + if (writtenOrdinals && number >= 1 && number <= 10) { + return Localize.translate(locale, `workflowsPage.frequencies.ordinals.${number}` as TranslationPaths); + } + if (lastDigit === 1 && lastTwoDigits !== 11) { suffixKey = 'workflowsPage.frequencies.ordinals.one'; } else if (lastDigit === 2 && lastTwoDigits !== 12) { @@ -92,7 +99,7 @@ function toLocaleOrdinal(locale: Locale, number: number): string { suffixKey = 'workflowsPage.frequencies.ordinals.few'; } - const suffix = Localize.translate(locale, suffixKey as TranslationPaths); + const suffix = Localize.translate(locale, suffixKey); return `${number}${suffix}`; } diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index ef265fae193a..65c7203ecc51 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -4,6 +4,7 @@ import React, {useCallback, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; +import ApprovalWorkflowSection from '@components/ApprovalWorkflowSection'; import ConfirmModal from '@components/ConfirmModal'; import getBankIcon from '@components/Icon/BankIcons'; import type {BankName} from '@components/Icon/BankIconsUtils'; @@ -57,21 +58,14 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const policyApproverEmail = policy?.approver; - const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]); const canUseAdvancedApproval = Permissions.canUseWorkflowsAdvancedApproval(betas); const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - + const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]); const approvalWorkflows = useMemo( - () => - convertPolicyEmployeesToApprovalWorkflows({ - personalDetails: personalDetails ?? {}, - employees: policy?.employeeList ?? {}, - defaultApprover: policyApproverEmail ?? '', - }), + () => convertPolicyEmployeesToApprovalWorkflows({employees: policy?.employeeList ?? {}, defaultApprover: policyApproverEmail ?? '', personalDetails: personalDetails ?? {}}), [personalDetails, policy?.employeeList, policyApproverEmail], ); - const displayNameForAuthorizedPayer = useMemo( () => PersonalDetailsUtils.getPersonalDetailByEmail(policy?.achAccount?.reimburser ?? '')?.displayName ?? policy?.achAccount?.reimburser, [policy?.achAccount?.reimburser], @@ -163,31 +157,36 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr onToggle: (isEnabled: boolean) => { Policy.setWorkspaceApprovalMode(route.params.policyID, policy?.owner ?? '', isEnabled ? CONST.POLICY.APPROVAL_MODE.BASIC : CONST.POLICY.APPROVAL_MODE.OPTIONAL); }, - subMenuItems: ( + subMenuItems: canUseAdvancedApproval ? ( <> - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID))} - shouldShowRightIcon - wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]} - brickRoadIndicator={hasApprovalError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> - {canUseAdvancedApproval && ( - ( + - )} + ))} + + ) : ( + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID))} + shouldShowRightIcon + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]} + brickRoadIndicator={hasApprovalError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> ), isActive: (policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC && !hasApprovalError) ?? false, pendingAction: policy?.pendingFields?.approvalMode, @@ -282,9 +281,11 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr translate, preferredLocale, onPressAutoReportingFrequency, - policyApproverName, canUseAdvancedApproval, - theme, + approvalWorkflows, + theme.success, + theme.spinner, + policyApproverName, createNewApprovalWorkflow, isOffline, isPolicyAdmin, diff --git a/src/styles/index.ts b/src/styles/index.ts index 3d5a2e98f3b9..52dee2c5e9fd 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5151,6 +5151,13 @@ const styles = (theme: ThemeColors) => width: 184, height: 112, }, + + workflowApprovalVerticalLine: { + height: 16, + width: 1, + marginLeft: 19, + backgroundColor: theme.border, + }, } satisfies Styles); type ThemeStyles = ReturnType;