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')}
+
+
+ )}
+
+
+
+ );
+}
+
+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;