diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 21553b0dbccc..076b5b98e32c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2914,6 +2914,18 @@ const CONST = { MANAGER: 'manager', CUSTOM: 'custom', }, + SYNC_STATUS: { + SYNCING: 'SYNCING', + DONE: 'DONE', + FAILED: 'FAILED', + DISABLED: 'DISABLED', + }, + SYNC_TYPE: { + INITIAL: 'initial', + MANUAL: 'manual', + AUTO: 'auto', + WEBHOOK: 'webhook', + }, }, QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE: { @@ -3765,7 +3777,7 @@ const CONST = { DOWNLOAD_CSV: 'downloadCSV', SETTINGS: 'settings', EXPORT: 'export', - SYNC_WITH_GUSTO: 'syncWithGusto', + SYNC_WITH_HR: 'syncWithHR', }, MEMBERS_BULK_ACTION_TYPES: { REMOVE: 'remove', @@ -4005,7 +4017,6 @@ const CONST = { ZENEFITS_SYNC_TITLE: 'zenefitsSyncTitle', ZENEFITS_SYNC_LOAD_DATA: 'zenefitsSyncLoadData', ZENEFITS_SYNC_PROVISIONING: 'zenefitsSyncProvisioning', - MERGE_HR_SYNC_TITLE: 'mergeHRSyncTitle', FINANCIAL_FORCE_SYNC_CONNECTION: 'financialForceSyncConnection', }, SYNC_STAGE_TIMEOUT_MINUTES: 20, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 24ec39448a75..8e30de4e3f7b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -748,6 +748,7 @@ const ONYXKEYS = { // object should mirror the data as it's stored in the database. POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED: 'policyHasConnectionsDataBeenFetched_', POLICY_CONNECTION_SYNC_PROGRESS: 'policyConnectionSyncProgress_', + POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN: 'policyMergeHRInitialSyncModalShown_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', WORKSPACE_INVITE_ROLE_DRAFT: 'workspaceInviteRoleDraft_', @@ -1351,6 +1352,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStepDeprecated; [ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER]: OnyxTypes.PolicyJoinMember; [ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress; + [ONYXKEYS.COLLECTION.POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN]: boolean; [ONYXKEYS.COLLECTION.SNAPSHOT]: OnyxTypes.SearchResults; [ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT]: OnyxTypes.AgentPrompt; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod; diff --git a/src/hooks/useMergeHRInitialSyncingModal.ts b/src/hooks/useMergeHRInitialSyncingModal.ts new file mode 100644 index 000000000000..db0bbaabcb6d --- /dev/null +++ b/src/hooks/useMergeHRInitialSyncingModal.ts @@ -0,0 +1,53 @@ +import {useEffect, useEffectEvent, useState} from 'react'; +import {setMergeHRInitialSyncModalShown} from '@libs/actions/connections/MergeHR'; +// eslint-disable-next-line no-restricted-imports -- the hook does not use React Navigation hooks internally (isFocused is passed in as a parameter), so there is no navigation instance available to use navigation.addListener for transition detection. +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import Visibility from '@libs/Visibility'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; +import usePolicy from './usePolicy'; + +/** + * Shows a one-time informational modal when the Merge HR connection's initial sync is in progress. + */ +function useMergeHRInitialSyncingModal(policyID: string, isFocused: boolean) { + const policy = usePolicy(policyID); + const {showConfirmModal} = useConfirmModal(); + const {translate} = useLocalize(); + const [hasShownModal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN}${policyID}`); + const [isAppVisible, setIsAppVisible] = useState(Visibility.isVisible); + const [isAnyModalVisible] = useOnyx(ONYXKEYS.MODAL, {selector: (modal) => !!modal?.isVisible}); + + useEffect(() => Visibility.onVisibilityChange(() => setIsAppVisible(Visibility.isVisible())), []); + + const showSyncingModal = useEffectEvent(() => { + if (hasShownModal) { + return; + } + setMergeHRInitialSyncModalShown(policyID); + showConfirmModal({ + id: `merge-hr-syncing-${policyID}`, + title: translate('workspace.hr.syncingModalTitle'), + prompt: translate('workspace.hr.syncingModalDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }); + + const mergeLastSync = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]?.lastSync; + + useEffect(() => { + const isInitialSyncInProgress = mergeLastSync?.syncStatus === CONST.MERGE_HR.SYNC_STATUS.SYNCING && mergeLastSync?.syncType === CONST.MERGE_HR.SYNC_TYPE.INITIAL; + if (!isFocused || !isInitialSyncInProgress || !isAppVisible || isAnyModalVisible) { + return; + } + + const handle = TransitionTracker.runAfterTransitions({callback: showSyncingModal, waitForUpcomingTransition: true}); + return () => handle.cancel(); + }, [mergeLastSync?.syncStatus, mergeLastSync?.syncType, isFocused, isAppVisible, isAnyModalVisible]); +} + +export default useMergeHRInitialSyncingModal; diff --git a/src/languages/de.ts b/src/languages/de.ts index 211bd7a28eb5..6d660e70727c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6093,8 +6093,8 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU approvers: 'Genehmigende', auditors: 'Prüfer', emptyRoleFilter: {title: 'Keine Mitglieder entsprechen diesem Filter', subtitle: 'Laden Sie ein Mitglied ein oder ändern Sie den Filter oben.'}, - configureGustoSync: 'Gusto-Synchronisierung konfigurieren.', - syncWithGusto: 'Mit Gusto synchronisieren', + configureHRSync: (providerName: string) => `Synchronisierung mit ${providerName} einrichten.`, + syncWithHR: (providerName: string) => `Mit ${providerName} synchronisieren`, }, card: { getStartedIssuing: 'Beginne, indem du deine erste virtuelle oder physische Karte ausstellst.', @@ -7208,6 +7208,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc zenefits: { title: 'TriNet', }, + syncingModalTitle: 'Ihre Verbindung wird synchronisiert', + syncingModalDescription: 'Die erste Verbindung kann einige Zeit dauern. Sie werden über alle Fehler benachrichtigt.', + syncing: 'Mitarbeitende werden synchronisiert', }, }, getAssistancePage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 2bf3987a7840..2f9ddc766478 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6096,8 +6096,8 @@ const translations = { addedWithPrimary: 'Some members were added with their primary logins.', invitedBySecondaryLogin: (secondaryLogin: string) => `Added by secondary login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Total workspace members: ${count}`, - configureGustoSync: 'Configure Gusto sync.', - syncWithGusto: 'Sync with Gusto', + configureHRSync: (providerName: string) => `Configure ${providerName} sync.`, + syncWithHR: (providerName: string) => `Sync with ${providerName}`, allMembers: 'All members', admins: 'Admins', approvers: 'Approvers', @@ -6459,6 +6459,9 @@ const translations = { finalApprover: 'Final approver', providerFinalApprover: (providerName: string) => `${providerName} final approver`, notSet: 'Not set', + syncing: 'Syncing employees', + syncingModalTitle: 'Your connection is syncing', + syncingModalDescription: "The first connection can take some time. You'll be notified of any errors.", approvalModeDescription: (providerName: string) => `Members and managers are set up to sync with ${providerName}.`, approvalModeWarningTitle: 'Change approval mode?', approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) => @@ -6486,8 +6489,6 @@ const translations = { return 'Loading data from TriNet'; case 'zenefitsSyncProvisioning': return 'Provisioning employees in policy'; - case 'mergeHRSyncTitle': - return 'Synchronizing HR Employees'; case 'jobDone': return 'Waiting for imported data to load'; default: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 0abb0f4103a5..5d19384c21b3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5911,8 +5911,6 @@ ${amount} para ${merchant} - ${date}`, addedWithPrimary: 'Se agregaron algunos miembros con sus nombres de usuario principales.', invitedBySecondaryLogin: (secondaryLogin) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, workspaceMembersCount: (count) => `Total de miembros del espacio de trabajo: ${count}`, - configureGustoSync: 'Configurar sincronización de Gusto.', - syncWithGusto: 'Sincronizar con Gusto', allMembers: 'Todos los miembros', admins: 'Administradores', approvers: 'Aprobadores', @@ -5934,6 +5932,8 @@ ${amount} para ${merchant} - ${date}`, `${memberName} tiene gastos pendientes por aprobar. Por favor, pídeles que aprueben o tomen el control de sus informes antes de eliminarlos del espacio de trabajo.`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} tiene un informe en proceso pendiente de acción. Pídele que complete la acción requerida antes de eliminarlo del espacio de trabajo.`, + configureHRSync: (providerName: string) => `Configura la sincronización de ${providerName}.`, + syncWithHR: (providerName: string) => `Sincronizar con ${providerName}`, }, accounting: { settings: 'configuración', @@ -6299,8 +6299,6 @@ ${amount} para ${merchant} - ${date}`, return 'Cargando datos desde TriNet'; case 'zenefitsSyncProvisioning': return 'Aprovisionar empleados en la política'; - case 'mergeHRSyncTitle': - return 'Sincronización de empleados de HR'; case 'jobDone': return 'Esperando a que se carguen los datos importados'; default: { @@ -6325,6 +6323,9 @@ ${amount} para ${merchant} - ${date}`, zenefits: { title: 'TriNet', }, + syncingModalTitle: 'Tu conexión se está sincronizando', + syncingModalDescription: 'La primera conexión puede tardar un poco. Se te notificará de cualquier error.', + syncing: 'Sincronizando empleados', }, export: { notReadyHeading: 'No está listo para exportar', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 719d4e1d3cdf..39bc4c90dd0d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6112,8 +6112,8 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. approvers: 'Approbateurs', auditors: 'Auditeurs', emptyRoleFilter: {title: 'Aucun membre ne correspond à ce filtre', subtitle: 'Invitez un membre ou modifiez le filtre ci-dessus.'}, - configureGustoSync: 'Configurer la synchronisation Gusto.', - syncWithGusto: 'Synchroniser avec Gusto', + configureHRSync: (providerName: string) => `Configurer la synchronisation ${providerName}.`, + syncWithHR: (providerName: string) => `Synchroniser avec ${providerName}`, }, card: { getStartedIssuing: 'Commencez par émettre votre première carte virtuelle ou physique.', @@ -7237,6 +7237,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e zenefits: { title: 'TriNet', }, + syncingModalTitle: 'Votre connexion est en cours de synchronisation', + syncingModalDescription: 'La première connexion peut prendre un certain temps. Vous serez informé de toute erreur.', + syncing: 'Synchronisation des employés', }, }, getAssistancePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 27ac66968b21..8c5263ef73bc 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6082,8 +6082,8 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. approvers: 'Approvatori', auditors: 'Revisori', emptyRoleFilter: {title: 'Nessun membro corrisponde a questo filtro', subtitle: 'Invita un membro o modifica il filtro qui sopra.'}, - configureGustoSync: 'Configura la sincronizzazione con Gusto.', - syncWithGusto: 'Sincronizza con Gusto', + configureHRSync: (providerName: string) => `Configura la sincronizzazione di ${providerName}.`, + syncWithHR: (providerName: string) => `Sincronizza con ${providerName}`, }, card: { getStartedIssuing: 'Inizia emettendo la tua prima carta virtuale o fisica.', @@ -7195,6 +7195,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, zenefits: { title: 'TriNet', }, + syncingModalTitle: 'La tua connessione è in sincronizzazione', + syncingModalDescription: "La prima connessione può richiedere un po' di tempo. Ti verrà notificato qualsiasi errore.", + syncing: 'Sincronizzazione dipendenti', }, }, getAssistancePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 967b5486f8f5..1b4e164da0a8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6014,8 +6014,8 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO approvers: '承認者', auditors: '監査担当者', emptyRoleFilter: {title: 'このフィルターに一致するメンバーはいません', subtitle: 'メンバーを招待するか、上のフィルターを変更してください。'}, - configureGustoSync: 'Gusto 同期を設定する。', - syncWithGusto: 'Gusto と同期', + configureHRSync: (providerName: string) => `${providerName} の同期を設定します。`, + syncWithHR: (providerName: string) => `${providerName}と同期`, }, card: { getStartedIssuing: 'まずは最初のバーチャルカードまたは物理カードを発行しましょう。', @@ -7114,6 +7114,9 @@ ${reportName} zenefits: { title: 'TriNet', }, + syncingModalTitle: '接続を同期しています', + syncingModalDescription: '最初の接続には時間がかかる場合があります。エラーが発生した場合は通知されます。', + syncing: '従業員を同期しています', }, }, getAssistancePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7b8f67e8f3e9..8b9213036c49 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6061,8 +6061,8 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ approvers: 'Fiatteurs', auditors: 'Accountants', emptyRoleFilter: {title: 'Geen leden komen overeen met dit filter', subtitle: 'Nodig een lid uit of wijzig het filter hierboven.'}, - configureGustoSync: 'Gusto-synchronisatie configureren.', - syncWithGusto: 'Synchroniseren met Gusto', + configureHRSync: (providerName: string) => `Stel ${providerName}-synchronisatie in.`, + syncWithHR: (providerName: string) => `Synchroniseren met ${providerName}`, }, card: { getStartedIssuing: 'Begin met het uitgeven van je eerste virtuele of fysieke kaart.', @@ -7170,6 +7170,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, zenefits: { title: 'TriNet', }, + syncingModalTitle: 'Je verbinding wordt gesynchroniseerd', + syncingModalDescription: 'De eerste verbinding kan even duren. Je krijgt een melding als er fouten optreden.', + syncing: 'Werknemers synchroniseren', }, }, getAssistancePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 2403f826f0cb..69c231bbb6eb 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6055,8 +6055,8 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy approvers: 'Osoby zatwierdzające', auditors: 'Audytorzy', emptyRoleFilter: {title: 'Żadni członkowie nie pasują do tego filtra', subtitle: 'Zaproś członka lub zmień filtr powyżej.'}, - configureGustoSync: 'Skonfiguruj synchronizację z Gusto.', - syncWithGusto: 'Synchronizuj z Gusto', + configureHRSync: (providerName: string) => `Skonfiguruj synchronizację ${providerName}.`, + syncWithHR: (providerName: string) => `Synchronizuj z ${providerName}`, }, card: { getStartedIssuing: 'Zacznij od wydania swojej pierwszej wirtualnej lub fizycznej karty.', @@ -7165,6 +7165,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, zenefits: { title: 'TriNet', }, + syncingModalTitle: 'Twoje połączenie jest synchronizowane', + syncingModalDescription: 'Pierwsze połączenie może chwilę potrwać. Zostaniesz powiadomiony o wszelkich błędach.', + syncing: 'Synchronizowanie pracowników', }, }, getAssistancePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 459c4d19a6dc..d8e09c998d22 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6061,8 +6061,8 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS approvers: 'Aprovadores', auditors: 'Auditores', emptyRoleFilter: {title: 'Nenhum membro corresponde a este filtro', subtitle: 'Convide um membro ou altere o filtro acima.'}, - configureGustoSync: 'Configurar sincronização com Gusto.', - syncWithGusto: 'Sincronizar com Gusto', + configureHRSync: (providerName: string) => `Configurar a sincronização do ${providerName}.`, + syncWithHR: (providerName: string) => `Sincronizar com ${providerName}`, }, card: { getStartedIssuing: 'Comece emitindo seu primeiro cartão virtual ou físico.', @@ -7170,6 +7170,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, zenefits: { title: 'TriNet', }, + syncingModalTitle: 'Sua conexão está sincronizando', + syncingModalDescription: 'A primeira conexão pode levar algum tempo. Você será notificado sobre quaisquer erros.', + syncing: 'Sincronizando funcionários', }, }, getAssistancePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index db4d4c09f31c..66606311f5bf 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5905,8 +5905,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM approvers: '审批人', auditors: '审计员', emptyRoleFilter: {title: '没有成员符合此筛选条件', subtitle: '邀请成员或更改上方的筛选条件。'}, - configureGustoSync: '配置 Gusto 同步。', - syncWithGusto: '与 Gusto 同步', + configureHRSync: (providerName: string) => `配置 ${providerName} 同步。`, + syncWithHR: (providerName: string) => `与 ${providerName} 同步`, }, card: { getStartedIssuing: '从发放您的第一张虚拟卡或实体卡开始使用。', @@ -6987,6 +6987,9 @@ ${reportName} zenefits: { title: 'TriNet', }, + syncingModalTitle: '您的连接正在同步', + syncingModalDescription: '首次连接可能需要一些时间。若发生任何错误,我们会通知你。', + syncing: '正在同步员工', }, }, getAssistancePage: { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f0e7780a1fa9..72141f81ca5f 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -67,7 +67,7 @@ type HRConnectionName = TupleToUnion> = [ +function syncMergeHR(policy: OnyxEntry) { + const policyID = policy?.id; + if (!policyID) { + return; + } + + const previousLastSync = policy?.connections?.merge_hris?.lastSync; + + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.MERGE_HR_SYNC_TITLE, - connectionName: CONST.POLICY.CONNECTIONS.NAME.MERGE_HR, - timestamp: new Date().toISOString(), + connections: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- merge_hris is the API-defined connection key + merge_hris: { + lastSync: { + syncStatus: CONST.MERGE_HR.SYNC_STATUS.SYNCING, + syncType: CONST.MERGE_HR.SYNC_TYPE.MANUAL, + }, + }, + }, }, }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`, - value: null, + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- merge_hris is the API-defined connection key + merge_hris: { + lastSync: previousLastSync ?? null, + }, + }, + }, }, ]; @@ -184,6 +205,10 @@ function updateMergeHRFinalApprover(policyID: string, finalApprover: string | nu ); } +function setMergeHRInitialSyncModalShown(policyID: string) { + Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN}${policyID}`, true); +} + type HRProviderName = TupleToUnion; type HRConnectionErrorFieldName = 'approvalMode' | 'finalApprover'; @@ -203,6 +228,6 @@ function clearHRConnectionErrorField(policyID: string | undefined, provider: HRP }); } -export {syncMergeHR, updateMergeHRApprovalMode, updateMergeHRFinalApprover, clearHRConnectionErrorField}; +export {syncMergeHR, updateMergeHRApprovalMode, updateMergeHRFinalApprover, clearHRConnectionErrorField, setMergeHRInitialSyncModalShown}; export default getMergeHRSetupLink; diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 7c6c58566c59..5a575bfb283c 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -27,6 +27,7 @@ function removePolicyConnection(policy: Policy, connectionName: PolicyConnection | typeof ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION | typeof ONYXKEYS.COLLECTION.TRAVEL_INVOICING_CONTINUOUS_RECONCILIATION_CONNECTION | typeof ONYXKEYS.COLLECTION.TRAVEL_INVOICING_USE_CONTINUOUS_RECONCILIATION + | typeof ONYXKEYS.COLLECTION.POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN > > = [ { @@ -65,6 +66,14 @@ function removePolicyConnection(policy: Policy, connectionName: PolicyConnection }, ]; + if (connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN}${policyID}`, + value: null, + }); + } + const successData: Array> = []; const failureData: Array> = []; const supportedConnections: PolicyConnectionName[] = [CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.POLICY.CONNECTIONS.NAME.XERO]; @@ -158,7 +167,7 @@ function syncConnection(policy: Policy | undefined, connectionName: PolicyConnec const policyID = policy.id; if (connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR) { - syncMergeHR(policyID); + syncMergeHR(policy); return; } diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 77bdffec60de..dcec588d43c0 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -66,6 +66,7 @@ import {isPersonalDetailsReady, sortAlphabetically} from '@libs/OptionsListUtils import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils'; import { canEditWorkspaceSettings, + getConnectedHRProvider, getConnectionExporters, getMemberAccountIDsForWorkspace, isControlPolicy, @@ -582,10 +583,14 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }, [isLoading, policy?.employeeList, translate, isOfflineAndNoMemberDataAvailable]); const memberCount = data.filter((member) => member.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; - const hasGustoConnection = !!policy?.connections?.gusto; - const shouldShowGustoSyncLink = isPolicyAdmin && hasGustoConnection; - const isGustoSyncInProgress = - hasGustoConnection && connectionSyncProgress?.connectionName === CONST.POLICY.CONNECTIONS.NAME.GUSTO && isConnectionInProgress(connectionSyncProgress, policy); + const connectedHRProvider = getConnectedHRProvider(policy); + const shouldShowHRSyncLink = isPolicyAdmin && !!connectedHRProvider; + const isMergeHRSyncInProgress = + connectedHRProvider?.connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR && + policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]?.lastSync?.syncStatus === CONST.MERGE_HR.SYNC_STATUS.SYNCING; + const isHRSyncInProgress = + shouldShowHRSyncLink && + (isMergeHRSyncInProgress || (connectionSyncProgress?.connectionName === connectedHRProvider?.connectionName && isConnectionInProgress(connectionSyncProgress, policy))); const isPendingAddOrDelete = isOffline && data?.some((member) => member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); const shouldShowSearchBar = data.length > CONST.SEARCH_ITEM_LIMIT; @@ -633,16 +638,18 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers {translate('workspace.people.workspaceMembersCount', memberCount)} - {shouldShowGustoSyncLink && '. '} - {shouldShowGustoSyncLink && ( - Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID))}>{translate('workspace.people.configureGustoSync')} + {shouldShowHRSyncLink && '. '} + {shouldShowHRSyncLink && ( + Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID))}> + {translate('workspace.people.configureHRSync', connectedHRProvider?.displayName ?? '')} + )} - {shouldShowGustoSyncLink && isGustoSyncInProgress && ( + {shouldShowHRSyncLink && isHRSyncInProgress && ( )} @@ -841,20 +848,21 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }, ]; - if (hasGustoConnection) { + const hrProvider = getConnectedHRProvider(policy); + if (hrProvider) { menuItems.push({ icon: icons.Sync, - text: translate('workspace.people.syncWithGusto'), + text: translate('workspace.people.syncWithHR', hrProvider.displayName), onSelected: () => { if (isOffline) { close(showRequiresInternetModal); return; } - close(() => syncConnection(policy, CONST.POLICY.CONNECTIONS.NAME.GUSTO)); + close(() => syncConnection(policy, hrProvider.connectionName)); }, - value: CONST.POLICY.SECONDARY_ACTIONS.SYNC_WITH_GUSTO, - disabled: isGustoSyncInProgress, + value: CONST.POLICY.SECONDARY_ACTIONS.SYNC_WITH_HR, + disabled: isHRSyncInProgress, }); } @@ -870,8 +878,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers policyID, showLockedAccountModal, showRequiresInternetModal, - hasGustoConnection, - isGustoSyncInProgress, + isHRSyncInProgress, policy, ]); diff --git a/src/pages/workspace/hr/HRProviderCard.tsx b/src/pages/workspace/hr/HRProviderCard.tsx index 718e001cebaa..418f0bd153cd 100644 --- a/src/pages/workspace/hr/HRProviderCard.tsx +++ b/src/pages/workspace/hr/HRProviderCard.tsx @@ -44,8 +44,8 @@ function HRProviderCard({card, policy, handleConnect}: HRProviderCardProps) { const cardIcon = typeof card.icon === 'string' && card.icon.startsWith('http') ? card.icon : (card.icon as IconAsset) || fallbackIcon; let connectionDescription: string | undefined; - if (card.isSyncInProgress && card.syncStageInProgress) { - connectionDescription = translate('workspace.hr.syncStageName', {stage: card.syncStageInProgress}); + if (card.isSyncInProgress) { + connectionDescription = card.syncStageInProgress ? translate('workspace.hr.syncStageName', {stage: card.syncStageInProgress}) : translate('workspace.hr.syncing'); } else if (card.successfulDate && !card.hasError) { connectionDescription = translate('workspace.hr.lastSync', datetimeToRelative(card.successfulDate)); } @@ -133,7 +133,7 @@ function HRProviderCard({card, policy, handleConnect}: HRProviderCardProps) { rightComponent={rightComponent} fallbackIcon={fallbackIcon} /> - {card.isConnected && !!approvalModeRoute && ( + {card.isConnected && !card.isInitialSyncInProgress && !!approvalModeRoute && ( )} - {card.isConnected && !!finalApproverRoute && ( + {card.isConnected && !card.isInitialSyncInProgress && !!finalApproverRoute && ( - {connectedCards.length > 0 && disconnectedCards.length > 0 && ( + {connectedCards.length > 0 && disconnectedCards.length > 0 && !connectedCards.some((c) => c.isInitialSyncInProgress) && ( ) { + const lastSync = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]?.lastSync; + const isSyncInProgress = lastSync?.syncStatus === CONST.MERGE_HR.SYNC_STATUS.SYNCING; + return { + isSyncInProgress, + isInitialSyncInProgress: isSyncInProgress && lastSync?.syncType === CONST.MERGE_HR.SYNC_TYPE.INITIAL, + hasError: lastSync?.syncStatus === CONST.MERGE_HR.SYNC_STATUS.FAILED, + syncStageInProgress: undefined, + successfulDate: lastSync?.successfulDate, + }; +} + +function getHRSyncState( + policy: OnyxEntry, + connectionName: ConnectionName, + connectionSyncProgress: OnyxEntry, + getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'], +) { + const connection = policy?.connections?.[connectionName]; + const syncProgress = connectionSyncProgress?.connectionName === connectionName ? connectionSyncProgress : undefined; + const isSyncInProgress = !!syncProgress && isConnectionInProgress(syncProgress, policy); + return { + isSyncInProgress, + isInitialSyncInProgress: undefined, + hasError: hasSynchronizationErrorMessage(policy, connectionName, isSyncInProgress), + syncStageInProgress: isSyncInProgress && syncProgress?.stageInProgress ? syncProgress.stageInProgress : undefined, + successfulDate: getIntegrationLastSuccessfulDate(getLocalDateFromDatetime, connection, syncProgress), + }; +} + /** Derives the runtime state (connected, syncing, errors, last sync date) for a single HR provider on a given policy. */ function getHRCardState({policy, connectionName, connectionSyncProgress, getLocalDateFromDatetime, mergeSlug}: GetHRCardStateParams) { - const isSyncInProgress = connectionSyncProgress?.connectionName === connectionName && isConnectionInProgress(connectionSyncProgress, policy); - const connectedProvider = getConnectedHRProvider(policy); const isConnected = connectedProvider?.connectionName === connectionName && (!mergeSlug || connectedProvider.mergeSlug === mergeSlug); - const connection = policy?.connections?.[connectionName]; - const syncProgress = connectionSyncProgress?.connectionName === connectionName ? connectionSyncProgress : undefined; - const successfulDate = getIntegrationLastSuccessfulDate(getLocalDateFromDatetime, connection, syncProgress); + const syncState = + connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR ? getMergeHRSyncState(policy) : getHRSyncState(policy, connectionName, connectionSyncProgress, getLocalDateFromDatetime); - const hasError = hasSynchronizationErrorMessage(policy, connectionName, !!isSyncInProgress); - const lastSyncErrorMessage = hasError ? (connection?.lastSync?.errorMessage ?? undefined) : undefined; - const syncStageInProgress = isSyncInProgress && syncProgress?.stageInProgress ? syncProgress.stageInProgress : undefined; + const lastSyncErrorMessage = syncState.hasError ? (policy?.connections?.[connectionName]?.lastSync?.errorMessage ?? undefined) : undefined; return { isConnected, - isSyncInProgress: !!isSyncInProgress, - successfulDate, - hasError, + ...syncState, lastSyncErrorMessage, - syncStageInProgress, }; } @@ -232,7 +257,7 @@ function getHRCards({policy, connectionSyncProgress, isBetaEnabled, getLocalDate if (isBetaEnabled(CONST.BETAS.MERGE_HR)) { const mergeConnectionName = CONST.POLICY.CONNECTIONS.NAME.MERGE_HR; - const disconnectedState = {isConnected: false, isSyncInProgress: false, hasError: false} as const; + const disconnectedState = {isConnected: false, isSyncInProgress: false, isInitialSyncInProgress: false, hasError: false} as const; for (const [slug, providerEntry] of Object.entries(MERGE_HR_PROVIDERS) as Array<[MergeHRProviderSlug, (typeof MERGE_HR_PROVIDERS)[MergeHRProviderSlug]]>) { const state = getHRCardState({policy, connectionName: mergeConnectionName, connectionSyncProgress, getLocalDateFromDatetime, mergeSlug: slug}); diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 6bfe2f4ba067..8293f0afaca6 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -302,6 +302,15 @@ type ConnectionLastSync = { isConnected?: boolean; }; +/** Last sync state specific to Merge HR connections */ +type MergeHRConnectionLastSync = ConnectionLastSync & { + /** Type of the sync */ + syncType?: ValueOf; + + /** Status of the sync */ + syncStatus?: ValueOf; +}; + /** * Model of QBO credentials data. */ @@ -1664,9 +1673,9 @@ type QBDConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback< >; /** State of integration connection */ -type Connection = { +type Connection = { /** State of the last synchronization */ - lastSync?: ConnectionLastSync; + lastSync?: TLastSync; /** Data imported from integration */ data?: ConnectionData; @@ -1702,7 +1711,7 @@ type Connections = { [CONST.POLICY.CONNECTIONS.NAME.ZENEFITS]: Connection; /** Merge HR integration connection */ - [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: Connection; + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: Connection; }; /** All integration connections, including unsupported ones */ @@ -2410,6 +2419,7 @@ export type { XeroTrackingCategory, NetSuiteConnection, ConnectionLastSync, + MergeHRConnectionLastSync, QBDReimbursableExportAccountType, NetSuiteSubsidiary, NetSuiteCustomList, diff --git a/tests/unit/HrUtilsTest.ts b/tests/unit/HrUtilsTest.ts index 8855bc1f16e3..f9a9dfeaae0f 100644 --- a/tests/unit/HrUtilsTest.ts +++ b/tests/unit/HrUtilsTest.ts @@ -214,21 +214,27 @@ describe('getHRCardState', () => { expect(state.isConnected).toBe(true); }); - it('detects sync in progress with stage info', () => { + it('detects sync in progress from lastSync.syncStatus', () => { const policy = makePolicy({ - // eslint-disable-next-line @typescript-eslint/naming-convention - connections: {merge_hris: {config: {integration: 'bamboohr'}, data: {}, lastSync: {}}} as unknown as Policy['connections'], + connections: { + // eslint-disable-next-line @typescript-eslint/naming-convention + merge_hris: { + config: {integration: 'bamboohr'}, + data: {}, + lastSync: {syncStatus: CONST.MERGE_HR.SYNC_STATUS.SYNCING, syncType: CONST.MERGE_HR.SYNC_TYPE.INITIAL}, + }, + } as unknown as Policy['connections'], }); - const syncProgress = makeSyncProgress(MERGE_HR, CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.MERGE_HR_SYNC_TITLE); const state = getHRCardState({ policy, connectionName: MERGE_HR, - connectionSyncProgress: syncProgress, + connectionSyncProgress: undefined, getLocalDateFromDatetime: stubGetLocalDateFromDatetime, mergeSlug: 'bamboohr', }); expect(state.isSyncInProgress).toBe(true); - expect(state.syncStageInProgress).toBe(CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.MERGE_HR_SYNC_TITLE); + expect(state.isInitialSyncInProgress).toBe(true); + expect(state.syncStageInProgress).toBeUndefined(); }); it('ignores sync progress for a different connection', () => { @@ -405,7 +411,7 @@ describe('getHRCards', () => { merge_hris: { config: {integration: 'bamboohr'}, data: {}, - lastSync: {isSuccessful: false, errorDate: new Date().toISOString(), errorMessage: 'Auth failed'}, + lastSync: {syncStatus: CONST.MERGE_HR.SYNC_STATUS.FAILED, errorMessage: 'Auth failed'}, }, } as unknown as Policy['connections'], }); @@ -437,24 +443,24 @@ describe('getHRCards', () => { expect(workday?.lastSyncErrorMessage).toBeUndefined(); }); - it('connected Merge card gets syncStageInProgress during sync', () => { + it('connected Merge card detects sync in progress from lastSync.syncStatus', () => { const policy = makePolicy({ connections: { // eslint-disable-next-line @typescript-eslint/naming-convention merge_hris: { config: {integration: 'bamboohr'}, data: {}, - lastSync: {}, + lastSync: {syncStatus: CONST.MERGE_HR.SYNC_STATUS.SYNCING, syncType: CONST.MERGE_HR.SYNC_TYPE.INITIAL}, }, } as unknown as Policy['connections'], }); - const syncProgress = makeSyncProgress(MERGE_HR, CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.MERGE_HR_SYNC_TITLE); const isBetaEnabled: GetHRCardsParams['isBetaEnabled'] = (beta) => beta === CONST.BETAS.MERGE_HR; - const cards = getHRCards(makeGetHRCardsParams({policy, connectionSyncProgress: syncProgress, isBetaEnabled})); + const cards = getHRCards(makeGetHRCardsParams({policy, connectionSyncProgress: undefined, isBetaEnabled})); const bamboo = cards.find((c) => c.key === 'merge_bamboohr'); expect(bamboo?.isSyncInProgress).toBe(true); - expect(bamboo?.syncStageInProgress).toBe(CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.MERGE_HR_SYNC_TITLE); + expect(bamboo?.isInitialSyncInProgress).toBe(true); + expect(bamboo?.syncStageInProgress).toBeUndefined(); }); it('uses provider icons from params for static providers', () => {