diff --git a/src/components/ApprovalWorkflowSection.tsx b/src/components/ApprovalWorkflowSection.tsx index 6443dd5c1f21..9f19146fad48 100644 --- a/src/components/ApprovalWorkflowSection.tsx +++ b/src/components/ApprovalWorkflowSection.tsx @@ -30,9 +30,15 @@ type ApprovalWorkflowSectionProps = { /** Whether the workflow should be shown as read-only */ isDisabled?: boolean; + + /** HR provider display name, used in manager mode to show "Manager (from {provider})" */ + hrProviderName?: string; + + /** When true, uses HR manager mode labels: "Manager (from {provider})" then "Final approver" */ + isHRManagerMode?: boolean; }; -function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CURRENCY.USD, isDisabled = false}: ApprovalWorkflowSectionProps) { +function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CURRENCY.USD, isDisabled = false, hrProviderName, isHRManagerMode = false}: ApprovalWorkflowSectionProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Lightbulb', 'Users', 'UserCheck']); const styles = useThemeStyles(); const theme = useTheme(); @@ -40,8 +46,21 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU const {convertToDisplayString} = useCurrencyListActions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const approverTitle = (index: number) => - approvalWorkflow.approvers.length > 1 ? `${toLocaleOrdinal(index + 1, true)} ${translate('workflowsPage.approver').toLowerCase()}` : `${translate('workflowsPage.approver')}`; + const fromProviderSuffix = hrProviderName ? ` (${translate('workflowsPage.approverFromProvider', {provider: hrProviderName})})` : ''; + + const approverTitle = (index: number) => { + if (isHRManagerMode) { + if (approvalWorkflow.approvers.length <= 1) { + return translate('workflowsPage.approver'); + } + const isLastApprover = index === approvalWorkflow.approvers.length - 1; + if (isLastApprover && approvalWorkflow.approvers.length > 1) { + return translate('workflowsPage.finalApprover'); + } + return `${translate('workflowsPage.manager')}${fromProviderSuffix}`; + } + return approvalWorkflow.approvers.length > 1 ? `${toLocaleOrdinal(index + 1, true)} ${translate('workflowsPage.approver').toLowerCase()}` : translate('workflowsPage.approver'); + }; const sortedMembers = approvalWorkflow.isDefault ? [] : sortAlphabetically(approvalWorkflow.members, 'displayName', localeCompare); diff --git a/src/languages/de.ts b/src/languages/de.ts index 9bb7247a8f0b..6295ac0e3ef0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2617,6 +2617,9 @@ ${amount} für ${merchant} – ${date}`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `Genehmigungen werden über deine ${provider}-Integration verwaltet. Um deinen Genehmigungsworkflow zu aktualisieren, gehe zu deinen ${provider}-Verbindungseinstellungen.`, goToHRSettings: ({provider}: {provider: string}) => `Zu den ${provider}-Einstellungen gehen`, + approverFromProvider: ({provider}: {provider: string}) => `von ${provider}`, + finalApprover: 'Letzte*r Genehmigende*r', + manager: 'Manager', }, workflowsDelayedSubmissionPage: { autoReportingFrequencyErrorMessage: 'Sendehäufigkeit konnte nicht geändert werden. Bitte versuche es erneut oder kontaktiere den Support.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 892bce043fe8..4a7dc08baf5b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2624,6 +2624,9 @@ const translations = { hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `Approvals are managed by your ${provider} integration. To update your approval workflow, head to your ${provider} connection settings.`, goToHRSettings: ({provider}: {provider: string}) => `Go to ${provider} settings`, + approverFromProvider: ({provider}: {provider: string}) => `from ${provider}`, + finalApprover: 'Final approver', + manager: 'Manager', makeOrTrackPaymentsTitle: 'Payments', makeOrTrackPaymentsDescription: 'Add an authorized payer for payments made in Expensify or track payments made elsewhere.', customApprovalWorkflowEnabled: diff --git a/src/languages/es.ts b/src/languages/es.ts index 1bfa19194145..75ef5fcf926d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2440,6 +2440,9 @@ ${amount} para ${merchant} - ${date}`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `Las aprobaciones se gestionan mediante tu integración de ${provider}. Para actualizar tu flujo de aprobación, ve a la configuración de conexión de ${provider}.`, goToHRSettings: ({provider}: {provider: string}) => `Ir a la configuración de ${provider}`, + approverFromProvider: ({provider}: {provider: string}) => `de ${provider}`, + finalApprover: 'Aprobador final', + manager: 'Responsable', makeOrTrackPaymentsTitle: 'Realizar o seguir pagos', makeOrTrackPaymentsDescription: 'Añade un pagador autorizado para los pagos realizados en Expensify o realiza un seguimiento de los pagos realizados en otro lugar.', customApprovalWorkflowEnabled: diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ffc1167b2cfb..a49db1c96a51 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2622,6 +2622,9 @@ ${amount} pour ${merchant} - ${date}`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `Les validations sont gérées par votre intégration ${provider}. Pour mettre à jour votre workflow de validation, accédez aux paramètres de connexion ${provider}.`, goToHRSettings: ({provider}: {provider: string}) => `Aller aux paramètres ${provider}`, + approverFromProvider: ({provider}: {provider: string}) => `de la part de ${provider}`, + finalApprover: 'Approbateur final', + manager: 'Manager', }, workflowsDelayedSubmissionPage: { autoReportingFrequencyErrorMessage: "La fréquence de soumission n'a pas pu être modifiée. Veuillez réessayer ou contacter l'assistance.", diff --git a/src/languages/it.ts b/src/languages/it.ts index d0c76ccca048..2d1a6233c054 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2612,6 +2612,9 @@ ${amount} per ${merchant} - ${date}`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `Le approvazioni sono gestite dalla tua integrazione con ${provider}. Per aggiornare il flusso di approvazione, vai alle impostazioni di connessione di ${provider}.`, goToHRSettings: ({provider}: {provider: string}) => `Vai alle impostazioni di ${provider}`, + approverFromProvider: ({provider}: {provider: string}) => `da ${provider}`, + finalApprover: 'Approvazione finale', + manager: 'Responsabile', }, workflowsDelayedSubmissionPage: { autoReportingFrequencyErrorMessage: 'Impossibile modificare la frequenza di invio. Riprova oppure contatta l’assistenza.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index e0c4d62368e6..515a68187ccb 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2587,6 +2587,9 @@ ${date} の ${merchant} への ${amount}`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `承認は${provider}連携によって管理されています。承認ワークフローを更新するには、${provider}接続設定に移動してください。`, goToHRSettings: ({provider}: {provider: string}) => `${provider}設定に移動`, + approverFromProvider: ({provider}: {provider: string}) => `${provider}から`, + finalApprover: '最終承認者', + manager: 'マネージャー', }, workflowsDelayedSubmissionPage: { autoReportingFrequencyErrorMessage: '提出頻度を変更できませんでした。もう一度お試しいただくか、サポートまでご連絡ください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6ed6007bdaa6..c7070e689e99 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2609,6 +2609,9 @@ ${amount} voor ${merchant} - ${date}`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `Goedkeuringen worden beheerd door je ${provider}-integratie. Ga naar je ${provider}-verbindingsinstellingen om je goedkeuringsworkflow bij te werken.`, goToHRSettings: ({provider}: {provider: string}) => `Ga naar ${provider}-instellingen`, + approverFromProvider: ({provider}: {provider: string}) => `van ${provider}`, + finalApprover: 'Laatste fiatteur', + manager: 'Manager', }, workflowsDelayedSubmissionPage: { autoReportingFrequencyErrorMessage: 'Indienfrequentie kon niet worden gewijzigd. Probeer het opnieuw of neem contact op met support.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0dd56bf5fb0b..9e3d09c472e7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2605,6 +2605,9 @@ ${amount} dla ${merchant} - ${date}`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `Zatwierdzanie jest zarządzane przez Twoją integrację z ${provider}. Aby zaktualizować swój proces zatwierdzania, przejdź do ustawień połączenia z ${provider}.`, goToHRSettings: ({provider}: {provider: string}) => `Przejdź do ustawień ${provider}`, + approverFromProvider: ({provider}: {provider: string}) => `od ${provider}`, + finalApprover: 'Ostateczny akceptujący', + manager: 'Menedżer', }, workflowsDelayedSubmissionPage: { autoReportingFrequencyErrorMessage: 'Nie udało się zmienić częstotliwości wysyłania. Spróbuj ponownie lub skontaktuj się z pomocą techniczną.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 360efb9281d4..cf0c9f324a86 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2603,6 +2603,9 @@ ${amount} para ${merchant} - ${date}`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `As aprovações são gerenciadas pela sua integração com o ${provider}. Para atualizar seu fluxo de aprovação, vá até as configurações de conexão do ${provider}.`, goToHRSettings: ({provider}: {provider: string}) => `Ir para as configurações do ${provider}`, + approverFromProvider: ({provider}: {provider: string}) => `de ${provider}`, + finalApprover: 'Aprovador final', + manager: 'Gerente', }, workflowsDelayedSubmissionPage: { autoReportingFrequencyErrorMessage: 'Não foi possível alterar a frequência de envio. Tente novamente ou entre em contato com o suporte.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8cd8bf1877d8..93dd2cca27df 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2535,6 +2535,9 @@ ${amount},商户:${merchant} - 日期:${date}`, configureViaHR: ({provider}: {provider: string}) => `通过 ${provider} 配置。`, hrApprovalWorkflowLockedPrompt: ({provider}: {provider: string}) => `审批由你的 ${provider} 集成管理。若要更新审批流程,请前往 ${provider} 连接设置。`, goToHRSettings: ({provider}: {provider: string}) => `前往 ${provider} 设置`, + approverFromProvider: ({provider}: {provider: string}) => `来自 ${provider}`, + finalApprover: '最终审批人', + manager: '经理', }, workflowsDelayedSubmissionPage: { autoReportingFrequencyErrorMessage: '提交频率无法更改。请重试或联系支持团队。', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f0e7780a1fa9..ec4f2c57579e 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1300,6 +1300,26 @@ function getApprovalWorkflow(policy: OnyxEntry): ValueOf): string | null { + const mergeConfig = policy?.connections?.merge_hris?.config; + if (mergeConfig?.approvalMode === CONST.MERGE_HR.APPROVAL_MODE.BASIC && mergeConfig.finalApprover) { + return mergeConfig.finalApprover; + } + + return null; +} + +/** Returns the Merge HR finalApprover when the integration is in basic or manager mode, or null otherwise. */ +function getMergeHRFinalApprover(policy: OnyxEntry): string | null { + const mergeConfig = policy?.connections?.merge_hris?.config; + if ((mergeConfig?.approvalMode === CONST.MERGE_HR.APPROVAL_MODE.BASIC || mergeConfig?.approvalMode === CONST.MERGE_HR.APPROVAL_MODE.MANAGER) && mergeConfig?.finalApprover) { + return mergeConfig.finalApprover; + } + + return null; +} + function getDefaultApprover(policy: OnyxEntry): string { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return policy?.approver || policy?.owner || ''; @@ -2485,6 +2505,8 @@ export { getCurrentConnectionName, getCustomersOrJobsLabelNetSuite, getDefaultApprover, + getMergeHRBasicModeFinalApprover, + getMergeHRFinalApprover, getApprovalWorkflow, getReimburserAccountID, isControlPolicy, diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index cb8647d35a0f..166c56b22b09 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -13,7 +13,7 @@ import type Policy from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; import {isBankAccountPartiallySetup} from './BankAccountUtils'; -import {getDefaultApprover, isExpensifyTeam, shouldFilterExpensifyTeam} from './PolicyUtils'; +import {getDefaultApprover, getMergeHRFinalApprover, isExpensifyTeam, shouldFilterExpensifyTeam} from './PolicyUtils'; const INITIAL_APPROVAL_WORKFLOW: ApprovalWorkflowOnyx = { members: [], @@ -150,7 +150,7 @@ function findFirstNonExpensifyApprover(employees: PolicyEmployeeList, startEmail /** Convert a list of policy employees to a list of approval workflows */ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, firstApprover, localeCompare, currentUserLogin}: PolicyConversionParams): PolicyConversionResult { const employees = policy?.employeeList ?? {}; - const defaultApprover = getDefaultApprover(policy); + const defaultApprover = getMergeHRFinalApprover(policy) ?? getDefaultApprover(policy); const approvalWorkflows: Record = {}; const shouldFilterOutExpensifyTeam = shouldFilterExpensifyTeam(policy?.owner, currentUserLogin); @@ -161,6 +161,7 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir personalDetailsByEmail[value?.login ?? key] = value; } const availableMembers: Member[] = []; + const isMergeHRManagerMode = policy?.connections?.merge_hris?.config?.approvalMode === CONST.MERGE_HR.APPROVAL_MODE.MANAGER; for (const employee of Object.values(employees)) { const {email, submitsTo, pendingAction} = employee; @@ -179,19 +180,30 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir availableMembers.push(member); } - if (!submitsTo || !employees[submitsTo]) { + if (!submitsTo) { continue; } - // If submitsTo is an Expensify team member, find the first non-Expensify approver in the chain - const effectiveSubmitsTo = shouldFilterOutExpensifyTeam ? (findFirstNonExpensifyApprover(employees, submitsTo) ?? submitsTo) : submitsTo; - - if (!employees[effectiveSubmitsTo]) { + if (!employees[submitsTo] && !isMergeHRManagerMode) { continue; } + // If submitsTo is an Expensify team member, find the first non-Expensify approver in the chain + const effectiveSubmitsTo = shouldFilterOutExpensifyTeam && employees[submitsTo] ? (findFirstNonExpensifyApprover(employees, submitsTo) ?? submitsTo) : submitsTo; + if (!approvalWorkflows[effectiveSubmitsTo]) { let approvers = calculateApprovers({employees, firstEmail: effectiveSubmitsTo, personalDetailsByEmail}); + if (approvers.length === 0) { + approvers = [ + { + email: effectiveSubmitsTo, + forwardsTo: undefined, + avatar: personalDetailsByEmail[effectiveSubmitsTo]?.avatar, + displayName: personalDetailsByEmail[effectiveSubmitsTo]?.displayName ?? effectiveSubmitsTo, + isCircularReference: false, + }, + ]; + } if (shouldFilterOutExpensifyTeam) { approvers = approvers.filter((approver) => !isExpensifyTeam(approver.email)); } @@ -225,6 +237,25 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir } } + // In Merge HR manager mode, append the finalApprover to each chain if not already present + const mergeConfig = policy?.connections?.merge_hris?.config; + if (isMergeHRManagerMode && mergeConfig?.finalApprover) { + const finalApproverEmail = mergeConfig.finalApprover; + for (const workflow of Object.values(approvalWorkflows)) { + const lastApprover = workflow.approvers.at(-1); + if (lastApprover && lastApprover.email !== finalApproverEmail) { + workflow.approvers.push({ + email: finalApproverEmail, + forwardsTo: undefined, + avatar: personalDetailsByEmail[finalApproverEmail]?.avatar, + displayName: personalDetailsByEmail[finalApproverEmail]?.displayName ?? finalApproverEmail, + isCircularReference: false, + }); + usedApproverEmails.add(finalApproverEmail); + } + } + } + // Sort the workflows by the first approver's name (default workflow has priority) const sortedApprovalWorkflows = Object.values(approvalWorkflows).sort((a, b) => { if (a.isDefault) { diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 4e13e60f3a2d..403f5f472e21 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -253,8 +253,10 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.getRoute(route.params.policyID)); }, [policy, route.params.policyID, availableMembers, usedApproverEmails, canAccessSubmit2026Features, navigateToSubmitWorkspaceApprovalsUpgrade]); + const isMergeHRManagerMode = policy?.connections?.merge_hris?.config?.approvalMode === CONST.MERGE_HR.APPROVAL_MODE.MANAGER; + const filteredApprovalWorkflows = - policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.ADVANCED || policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL + policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.ADVANCED || policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL || isMergeHRManagerMode ? approvalWorkflows : approvalWorkflows.filter((workflow) => workflow.isDefault); @@ -458,6 +460,8 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { } currency={policy?.outputCurrency} isDisabled={shouldBlockApprovalWorkflowEditing} + hrProviderName={isHRConnected ? hrProviderName : undefined} + isHRManagerMode={isMergeHRManagerMode} /> ))} @@ -657,6 +661,8 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { onPressAutoReportingFrequency, isSmartLimitEnabled, isHRConnected, + hrProviderName, + isMergeHRManagerMode, shouldBlockApprovalWorkflowEditing, approvalSubtitle, navigateToSubmitWorkspaceApprovalsUpgrade, diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx index 9dd3757ea1f5..c1461c1a37b4 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isAnyHRReadOnlyWorkflowMode, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -37,7 +37,8 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr const [addExpenseApprovalsTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${addExpenseApprovalsTaskReportID}`); const formRef = useRef(null); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy); + const shouldShowNotFoundView = + (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy) || isAnyHRReadOnlyWorkflowMode(policy); const createApprovalWorkflow = useCallback(() => { if (!approvalWorkflow) { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index 062ea6326265..330dfd07e239 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -16,7 +16,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isAnyHRReadOnlyWorkflowMode, isPendingDeletePolicy} from '@libs/PolicyUtils'; import {convertPolicyEmployeesToApprovalWorkflows, mergeWorkflowMembersWithAvailableMembers} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -100,7 +100,12 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const {currentApprovalWorkflow, defaultWorkflowMembers, usedApproverEmails} = getApprovalWorkflowData(); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy) || !currentApprovalWorkflow; + const shouldShowNotFoundView = + (isEmptyObject(policy) && !isLoadingReportData) || + !canEditWorkspaceSettings(policy) || + isPendingDeletePolicy(policy) || + !currentApprovalWorkflow || + isAnyHRReadOnlyWorkflowMode(policy); // Set the initial approval workflow when the page is loaded useEffect(() => { diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index b955934aab05..994759d5d474 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -22,6 +22,8 @@ import { getEligibleBankAccountShareRecipients, getHRApprovalMode, getManagerAccountID, + getMergeHRBasicModeFinalApprover, + getMergeHRFinalApprover, getPolicyEmployeeAccountIDs, getRateDisplayValue, getSubmitToAccountID, @@ -2966,5 +2968,87 @@ describe('PolicyUtils', () => { expect(getHRApprovalMode(policy, CONST.POLICY.CONNECTIONS.NAME.GUSTO)).toBeNull(); }); }); + + describe('getMergeHRBasicModeFinalApprover', () => { + it('returns finalApprover when Merge HR is in basic mode', () => { + const policy = { + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: {config: {approvalMode: CONST.MERGE_HR.APPROVAL_MODE.BASIC, finalApprover: 'boss@company.com', integration: 'workday'}}, + }, + } as Policy; + expect(getMergeHRBasicModeFinalApprover(policy)).toBe('boss@company.com'); + }); + + it('returns null when Merge HR is in manager mode', () => { + const policy = { + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: {config: {approvalMode: CONST.MERGE_HR.APPROVAL_MODE.MANAGER, finalApprover: 'boss@company.com', integration: 'workday'}}, + }, + } as Policy; + expect(getMergeHRBasicModeFinalApprover(policy)).toBeNull(); + }); + + it('returns null when finalApprover is not set', () => { + const policy = { + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: {config: {approvalMode: CONST.MERGE_HR.APPROVAL_MODE.BASIC, finalApprover: null, integration: 'workday'}}, + }, + } as Policy; + expect(getMergeHRBasicModeFinalApprover(policy)).toBeNull(); + }); + + it('returns null when no Merge HR connection exists', () => { + const policy = { + ...createRandomPolicy(0), + connections: {}, + } as Policy; + expect(getMergeHRBasicModeFinalApprover(policy)).toBeNull(); + }); + }); + + describe('getMergeHRFinalApprover', () => { + it('returns finalApprover when in basic mode', () => { + const policy = { + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: {config: {approvalMode: CONST.MERGE_HR.APPROVAL_MODE.BASIC, finalApprover: 'boss@company.com', integration: 'workday'}}, + }, + } as Policy; + expect(getMergeHRFinalApprover(policy)).toBe('boss@company.com'); + }); + + it('returns finalApprover when in manager mode', () => { + const policy = { + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: {config: {approvalMode: CONST.MERGE_HR.APPROVAL_MODE.MANAGER, finalApprover: 'boss@company.com', integration: 'workday'}}, + }, + } as Policy; + expect(getMergeHRFinalApprover(policy)).toBe('boss@company.com'); + }); + + it('returns null when in custom mode', () => { + const policy = { + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: {config: {approvalMode: CONST.MERGE_HR.APPROVAL_MODE.CUSTOM, finalApprover: 'boss@company.com', integration: 'workday'}}, + }, + } as Policy; + expect(getMergeHRFinalApprover(policy)).toBeNull(); + }); + + it('returns null when finalApprover is not set', () => { + const policy = { + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: {config: {approvalMode: CONST.MERGE_HR.APPROVAL_MODE.MANAGER, finalApprover: null, integration: 'workday'}}, + }, + } as Policy; + expect(getMergeHRFinalApprover(policy)).toBeNull(); + }); + }); }); }); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 0015289aedd8..d63310ca6764 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -17,6 +17,7 @@ import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; import type {BankAccountList} from '@src/types/onyx/BankAccount'; import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; +import type {Connections} from '@src/types/onyx/Policy'; import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import createRandomPolicy from '../utils/collections/policies'; @@ -758,6 +759,58 @@ describe('WorkflowUtils', () => { const approverEmails = approvalWorkflows.flatMap((w) => w.approvers.map((a) => a.email)); expect(approverEmails).not.toContain('guide@expensify.com'); }); + + it('Should use HR finalApprover as default approver for unassigned employees in manager mode', () => { + const employees: PolicyEmployeeList = { + 'unassigned@example.com': { + email: 'unassigned@example.com', + submitsTo: 'finalapprover@example.com', + }, + 'assigned@example.com': { + email: 'assigned@example.com', + submitsTo: 'manager@external.com', + }, + 'finalapprover@example.com': { + email: 'finalapprover@example.com', + submitsTo: 'finalapprover@example.com', + }, + }; + const policy: Partial = { + ...createMockPolicy(employees, 'owner@example.com'), + owner: 'owner@example.com', + approver: 'owner@example.com', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: { + config: { + approvalMode: CONST.MERGE_HR.APPROVAL_MODE.MANAGER, + finalApprover: 'finalapprover@example.com', + integration: 'workday', + }, + }, + } as Connections, + }; + const personalDetailsForTest: PersonalDetailsList = { + 'unassigned@example.com': {accountID: 1, login: 'unassigned@example.com', displayName: 'Unassigned'}, + 'assigned@example.com': {accountID: 2, login: 'assigned@example.com', displayName: 'Assigned'}, + 'finalapprover@example.com': {accountID: 3, login: 'finalapprover@example.com', displayName: 'Final Approver'}, + 'manager@external.com': {accountID: 4, login: 'manager@external.com', displayName: 'Manager'}, + }; + + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({ + policy: policy as Policy, + personalDetails: personalDetailsForTest, + localeCompare, + }); + + // The default workflow should use HR finalApprover (not the policy owner) + const defaultWorkflow = approvalWorkflows.find((w) => w.isDefault); + expect(defaultWorkflow).toBeDefined(); + expect(defaultWorkflow?.approvers.at(0)?.email).toBe('finalapprover@example.com'); + + // Unassigned employee submits to the finalApprover (HR default), so they end up in the default workflow + const unassignedMember = defaultWorkflow?.members.find((m) => m.email === 'unassigned@example.com'); + expect(unassignedMember).toBeDefined(); + }); }); describe('mergeWorkflowMembersWithAvailableMembers', () => {